TypeScript で DynamoDB のドメイン層を作ろう

はじめに

タイトルの表現が適切か自信無い。

TypeScriptを使い始めて、早数ヶ月なんとなく感覚を掴んできた。(気がする)
最近AWS Lambdaを扱うことが多く、もちろん実行環境はNode.js(JavaScript)を選択しています。

そんな中色々どう作っていこうかと考えていった中で、特にDynamoDBの操作は書いていくと、大体同じ書き方になるし何よりテストを書くのがめんどくさい。

こういうのはアプリケーションから分離して汎用化(外部モジュール化)してしまいたい。

本投稿では汎用化することを目的とし、以下を参考に TypeScript で型定義の恩恵を得つつ、どう作成したのかを書きます。

[ 技術講座 ] Domain-Driven Designのエッセンス -目次-|オブジェクトの広場

DDDについて

参考

ドメイン層を構成する要素を考える

ドメイン層はざっくりと以下の要素で構成します。

  1. エンティティ(1テーブル1エンティティ)
  2. リポジトリ(1テーブルを操作する機能を提供する)
  3. サービス(リポジトリはカプセル化し、リポジトリ操作をアプリケーション層へ提供する)
  4. 操作条件(リポジトリがテーブル操作するために必要なパラメータ)
  5. コレクション(同一エンティティをまとめたもの 配列みたいなもの)

リポジトリ(サービスも同様)が持つ機能を決める

使用頻度の高そうな以下の4つの機能を持たせます。

  • get
  • query(queryAll)
  • put
  • update

テーブル設計(サンプル)

顧客

No 論理名 物理名 データ型 
1 ID id 数値
2 顧客名 name 文字列
3 住所 address 文字列
4 論理削除 isDelete 数値
5 登録者ID createdId 文字列
6 登録日時 createdAt 文字列
7 最終変更者ID modifiedId 文字列
8 最終変更日時 modifiedAt 文字列

ユーザ

No 論理名 物理名 データ型 
1 ID id 数値
2 顧客ID customerId 数値
3 氏名 name 文字列
4 氏名カナ nameFurigana 文字列
5 住所 address 文字列
6 論理削除 isDelete 数値
7 登録者ID createdId 文字列
8 登録日時 createdAt 文字列
9 最終変更者ID modifiedId 文字列
10 最終変更日時 modifiedAt 文字列

ディレクトリ構成を決める

本投稿では、以下のディレクトリ構成でいきます。

/lambda/custom
ProjectRoot
├── src
|   ├── lib
|   |   ├── collections   … コレクション
|   |   ├── conditions    … 操作条件
|   |   ├── exceptions    … カスタム例外
|   |   ├── factories     … ファクトリ
|   |   ├── models        … モデル(エンティティ)
|   |   ├── repositories  … リポジトリ
|   |   └── services      … サービス
|   └── index.ts
└── test

実装しよう

エンティティの作成

テーブル設計に沿ってエンティティを作成していきます。

まずは、各エンティティ毎の基底インタフェースを作成します。
各エンティティ毎に重複して設計されている以下の4項目を定義します。

/src/lib/models/entity-base.ts
export interface IEntityBase {
  createdId?: string;
  createdAt?: string;
  modifiedId?: string;
  modifiedAt?: string;
}

この基底インタフェースを継承し他のエンティティを作成していきます。

src/lib/models/customer.ts
import { IEntityBase } from './entity-base';

export interface ICustomer extends IEntityBase {
  id: number;
  name?: string;
  address?: string;
  isDelete: number;
}
src/lib/models/user.ts
import { IEntityBase } from './entity-base';

export interface IUser extends IEntityBase {
  id: number;
  customerId: number;
  name?: string;
  nameFurigana?: string;
  address?: string;
  isDelete: number;
}

ここは設計に合わせていくだけなので、シンプルですね。

コレクションの作成

次に作成したエンティティを複数格納しておく為のコレクションクラスを作成します。
ここで作成するコレクションはQuery実行時の戻り値となります。

単純に[]を付けて配列として扱ったものではなく、本投稿ではSetオブジェクトを利用します。

参考 ECMAScript6 ~ MapとSet ~ - Qiita
参考 JavaScriptで集合演算 - Qiita

エンティティ毎にコレクションが必要となる為、基底クラスを作成します。

src/lib/collections/collection-base.ts
import * as AWS from 'aws-sdk';
import { IEntityBase } from '../models/entity-base';

export abstract class CollectionBase<TEntity extends IEntityBase> extends Set<TEntity> {
  Count?: AWS.DynamoDB.Integer;
  ScannedCount?: AWS.DynamoDB.Integer;
  LastEvaluatedKey?: AWS.DynamoDB.DocumentClient.Key;
  ConsumedCapacity?: AWS.DynamoDB.DocumentClient.ConsumedCapacity;
}

ポイントは、ジェネリック(Generics)ですね。
ジェネリックを利用することでエンティティの型に合わせたコレクションを定義します。

また、Query実行結果を返す為のプロパティも合わせて定義します。

QueryOutput - GitHub

では、基底クラスを継承し顧客、ユーザのコレクションクラスを作成します。

src/lib/collections/customer-collection.ts
import { CollectionBase } from './collection-base';
import { ICustomer as Entity } from '../models';

export class CustomerCollection extends CollectionBase<Entity> {
}
src/lib/collections/user-collection.ts
import { CollectionBase } from './collection-base';
import { IUser as Entity } from '../models';

export class UserCollection extends CollectionBase<Entity> {
}

操作条件の作成

サービスを介してリポジトリに渡す操作条件クラスを作成します。
ここで渡す条件は、AWS.DynamoDb.DocumentClientの型定義 - GitHubの以下のインタフェースからTableNameを除いたインタフェースを定義します。

TableNameは抽象プロパティとして、後述するリポジトリで実装します。

GetItemInput - GitHub
PutItemInput - GitHub
QueryInput - GitHub
UpdateItemInput - GitHub

src/lib/conditions/condition-base.ts
export abstract class ConditionBase {

  getItemInput?: {
    Key: AWS.DynamoDB.DocumentClient.Key;
    AttributesToGet?: AWS.DynamoDB.DocumentClient.AttributeNameList;
    ConsistentRead?: AWS.DynamoDB.DocumentClient.ConsistentRead;
    ReturnConsumedCapacity?: AWS.DynamoDB.DocumentClient.ReturnConsumedCapacity;
    ProjectionExpression?: AWS.DynamoDB.DocumentClient.ProjectionExpression;
    ExpressionAttributeNames?: AWS.DynamoDB.DocumentClient.ExpressionAttributeNameMap;
  };

  putItemInput?: {
    Item: AWS.DynamoDB.DocumentClient.PutItemInputAttributeMap;
    Expected?: AWS.DynamoDB.DocumentClient.ExpectedAttributeMap;
    ReturnValues?: AWS.DynamoDB.DocumentClient.ReturnValue;
    ReturnConsumedCapacity?: AWS.DynamoDB.DocumentClient.ReturnConsumedCapacity;
    ReturnItemCollectionMetrics?: AWS.DynamoDB.DocumentClient.ReturnItemCollectionMetrics;
    ConditionalOperator?: AWS.DynamoDB.DocumentClient.ConditionalOperator;
    ConditionExpression?: AWS.DynamoDB.DocumentClient.ConditionExpression;
    ExpressionAttributeNames?: AWS.DynamoDB.DocumentClient.ExpressionAttributeNameMap;
    ExpressionAttributeValues?: AWS.DynamoDB.DocumentClient.ExpressionAttributeValueMap;
  };

  queryInput?: {
    IndexName?: AWS.DynamoDB.DocumentClient.IndexName;
    Select?: AWS.DynamoDB.DocumentClient.Select;
    AttributesToGet?: AWS.DynamoDB.DocumentClient.AttributeNameList;
    Limit?: AWS.DynamoDB.DocumentClient.PositiveIntegerObject;
    ConsistentRead?: AWS.DynamoDB.DocumentClient.ConsistentRead;
    KeyConditions?: AWS.DynamoDB.DocumentClient.KeyConditions;
    QueryFilter?: AWS.DynamoDB.DocumentClient.FilterConditionMap;
    ConditionalOperator?: AWS.DynamoDB.DocumentClient.ConditionalOperator;
    ScanIndexForward?: AWS.DynamoDB.DocumentClient.BooleanObject;
    ExclusiveStartKey?: AWS.DynamoDB.DocumentClient.Key;
    ReturnConsumedCapacity?: AWS.DynamoDB.DocumentClient.ReturnConsumedCapacity;
    ProjectionExpression?: AWS.DynamoDB.DocumentClient.ProjectionExpression;
    FilterExpression?: AWS.DynamoDB.DocumentClient.ConditionExpression;
    KeyConditionExpression?: AWS.DynamoDB.DocumentClient.KeyExpression;
    ExpressionAttributeNames?: AWS.DynamoDB.DocumentClient.ExpressionAttributeNameMap;
    ExpressionAttributeValues?: AWS.DynamoDB.DocumentClient.ExpressionAttributeValueMap;
  };

  updateItemInput?: {
    Key: AWS.DynamoDB.DocumentClient.Key;
    AttributeUpdates?: AWS.DynamoDB.DocumentClient.AttributeUpdates;
    Expected?: AWS.DynamoDB.DocumentClient.ExpectedAttributeMap;
    ConditionalOperator?: AWS.DynamoDB.DocumentClient.ConditionalOperator;
    ReturnValues?: AWS.DynamoDB.DocumentClient.ReturnValue;
    ReturnConsumedCapacity?: AWS.DynamoDB.DocumentClient.ReturnConsumedCapacity;
    ReturnItemCollectionMetrics?: AWS.DynamoDB.DocumentClient.ReturnItemCollectionMetrics;
    UpdateExpression?: AWS.DynamoDB.DocumentClient.UpdateExpression;
    ConditionExpression?: AWS.DynamoDB.DocumentClient.ConditionExpression;
    ExpressionAttributeNames?: AWS.DynamoDB.DocumentClient.ExpressionAttributeNameMap;
    ExpressionAttributeValues?: AWS.DynamoDB.DocumentClient.ExpressionAttributeValueMap;
  };
}

リポジトリの作成

いよいよ、リポジトリを作成し以下の4つの機能を実装します。

  • get
  • query(queryAll)
  • put
  • update

リポジトリクラスでもジェネリックを活用し、汎用的に利用できるよう基底クラスに上記の機能を実装していきます。

リポジトリインタフェースの作成

まずはどのような機能(メソッド)を持つかインタフェースを定義します。

src/lib/repositories/repository-base.ts
import * as AWS from 'aws-sdk';
import { IEntityBase } from '../models/entity-base';
import { CollectionBase } from '../collections/collection-base';
import { ConditionBase } from '../conditions/condition-base';

export interface IRepositoryBase<
  TCondition extends ConditionBase,
  TEntity extends IEntityBase,
  TCollection extends CollectionBase<TEntity>
  > {
  getAsync(condition: TCondition): Promise<TEntity | undefined>;
  queryAsync(condition: TCondition): Promise<TCollection>;
  queryAllAsync(condition: TCondition): Promise<TCollection>;
  putAsync(condition: TCondition): Promise<AWS.DynamoDB.DocumentClient.PutItemOutput>;
  updateAsync(condition: TCondition): Promise<AWS.DynamoDB.DocumentClient.UpdateItemOutput>;
}

各テーブルに合わせて条件・エンティティ・コレクションを汎用化する必要がある為、これら3つを型パラメータへ定義します。

  • 各機能は共通してTConditionを引数として受け取り、Promiseを返す
  • getは、AWS.DynamoDB.DocumentClient.get()の結果をエンティティに変換もしくは未定義として返す
  • query(queryAll)は、AWS.DynamoDB.DocumentClient.query()の結果をコレクションとして返す
  • putは、AWS.DynamoDB.DocumentClient.put()の結果をそのまま返す
  • updateは、AWS.DynamoDB.DocumentClient.update()の結果をそのまま返す

リポジトリ基底クラスの作成

次にインタフェースを継承した基底クラスを作成します。

ここでの実装ポイントは以下の3つです。

  1. 操作条件クラス作成時に除外したTableNameを抽象プロパティとして定義
  2. get,query(queryAll)で取得したAWS.DynamoDB.DocumentClient.AttributeMapをエンティティへマッピングする抽象メソッドを定義
  3. コレクションのインスタンスを作成する抽象メソッドを定義
src/lib/repositories/repository-base.ts
export abstract class RepositoryBase<
  TCondition extends ConditionBase,
  TEntity extends IEntityBase,
  TCollection extends CollectionBase<TEntity>
  > implements IRepositoryBase<ConditionBase, TEntity, TCollection> {
  /**
   * テーブル名
   */
  protected abstract tableName: string;

  /**
   * DBコンテキスト
   */
  protected dbContext: AWS.DynamoDB.DocumentClient;

  /**
   * コンストラクタ
   * @param dbContext DBコンテキスト
   */
  constructor(dbContext: AWS.DynamoDB.DocumentClient) {
    this.dbContext = dbContext;
  }

  /**
   * エンティティ取得
   * @param item エンティティ
   * @returns エンティティ | undefined
   */
  protected abstract getEntity(item?: AWS.DynamoDB.DocumentClient.AttributeMap): TEntity | undefined;

  /**
   * コレクション作成(インスタンス生成のみ)
   */
  protected abstract createCollection(): TCollection;
}
操作条件クラス作成時に除外したTableNameを抽象プロパティとして定義

継承先でオーバライドして指定することで、リポジトリ基底クラスで各機能を汎用的に利用できるようにします。

get,query(queryAll)で取得したAWS.DynamoDB.DocumentClient.AttributeMapをエンティティへマッピングする抽象メソッドを定義

AWS.DynamoDB.DocumentClient.AttributeMapの型定義は{[key: string]: any}となっており、インテリセンスが効かないので、リポジトリ毎に用意したエンティティへ詰め直しを行います。

コレクションのインスタンスを作成する抽象メソッドを定義

これは苦肉の策です。。。
TypeScriptでは、C#のようにnew制約が見つからなかったので、こんな感じの指定ができなかった。

const collection = new TCollection();

getAsyncの実装

ここでは、getAsyncのみ抜粋します。

src/lib/repositories/repository-base.ts
  /**
   * 選択 get
   * @param condition パラメータ
   * @returns TEntity もしくは undefined
   */
  public async getAsync(condition: TCondition): Promise<TEntity | undefined> {
    if (condition.getItemInput === undefined) {
      throw new Exceptions.ArgumentNullException('condition');
    }

    // パラメータ設定
    const params: AWS.DynamoDB.DocumentClient.GetItemInput = {
      TableName: this.tableName,
      Key: condition.getItemInput.Key
    };

    // コピー
    Object.assign(params, condition.getItemInput);

    // データ取得
    const output = await this.dbContext.get(params).promise();

    // エンティティ取得
    return this.getEntity(output.Item);
  }

ポイントは、getメソッドに渡すパラメータを作成していく為に以下を行っています。

  • 抽象プロパティとして定義したTableNameを設定
  • NullableのプロパティをObject.assignでコピー

顧客、ユーザリポジトリの作成

src/lib/repositories/customer-repository.ts
import * as AWS from 'aws-sdk';
import { RepositoryBase } from './repository-base';
import { CustomerCondition as Condition } from '../conditions';
import { ICustomer as Entity } from '../models';
import { CustomerCollection as Collection } from '../collections';

export class CustomerRepository extends RepositoryBase<Condition, Entity, Collection> {
  protected tableName: string = 'customers';

  protected getEntity(item?: AWS.DynamoDB.DocumentClient.AttributeMap): Entity | undefined {
    if (item === undefined) {
      return undefined;
    }

    const entity: Entity = {
      id: item.id,
      isDelete: item.isDelete
    };

    Object.assign(entity, item);

    return entity;
  }

  protected createCollection(): Collection {
    return new Collection();
  }
}
src/lib/repositories/user-repository.ts
import * as AWS from 'aws-sdk';
import { IRepositoryBase, RepositoryBase } from './repository-base';
import { UserCondition as Condition } from '../conditions';
import { IUser as Entity } from '../models';
import { UserCollection as Collection } from '../collections';

export class UserRepository extends RepositoryBase<Condition, Entity, Collection> {
  protected tableName: string = 'users';

  protected getEntity(item?: AWS.DynamoDB.DocumentClient.AttributeMap): Entity | undefined {
    if (item === undefined) {
      return undefined;
    }

    const entity: Entity = {
      id: item.id,
      isDelete: item.isDelete,
      customerId: item.customerId
    };

    Object.assign(entity, item);

    return entity;
  }
  protected createCollection(): Collection {
    return new Collection();
  }
}

各リポジトリ実装時のポイントは、基底クラス同様

  • NullableのプロパティへObject.assignでコピーしてエンティティを作成

サービスの作成

最後にサービスです。

サービスは渡された条件をそのままリポジトリへデリゲートするので、実装自体はシンプルです。
ここでもジェネリックを活用し、すべて基底クラスでまかないます。

src/lib/services/service-base.ts
import { IEntityBase } from '../models/entity-base';
import { CollectionBase } from '../collections/collection-base';
import { RepositoryBase } from '../repositories/repository-base';
import { ConditionBase } from '../conditions/condition-base';

export abstract class ServiceBase<
  TCondition extends ConditionBase,
  TEntity extends IEntityBase,
  TCollection extends CollectionBase<TEntity>,
  TRepository extends RepositoryBase<TCondition, TEntity, TCollection>
  > {

  protected repository: TRepository;

  constructor(repository: TRepository) {
    this.repository = repository;
  }

  public async getAsync(condition: TCondition): Promise<TEntity | undefined> {
    return this.repository.getAsync(condition);
  }

  public async queryAsync(condition: TCondition): Promise<TCollection> {
    return this.repository.queryAsync(condition);
  }

  public async queryAllAsync(condition: TCondition): Promise<TCollection> {
    return this.repository.queryAllAsync(condition);
  }

  public async putAsync(condition: TCondition): Promise<AWS.DynamoDB.DocumentClient.PutItemOutput> {
    return this.repository.putAsync(condition);
  }

  public async updateAsync(condition: TCondition): Promise<AWS.DynamoDB.DocumentClient.UpdateItemOutput> {
    return this.repository.updateAsync(condition);
  }
}

これらも基底クラスを継承し、顧客・ユーザサービスクラスを作成します。

src/lib/services/customer-service.ts
import { ServiceBase } from './service-base';
import { CustomerCondition as Condition } from '../conditions';
import { ICustomer as Entity } from '../models';
import { CustomerCollection as Collection } from '../collections';
import { CustomerRepository as Repository } from '../repositories';

export class CustomerService extends ServiceBase<Condition, Entity, Collection, Repository> {
}
src/lib/services/user-service.ts
import { ServiceBase } from './service-base';
import { UserCondition as Condition } from '../conditions';
import { IUser as Entity } from '../models';
import { UserCollection as Collection } from '../collections';
import { UserRepository as Repository } from '../repositories';

export class UserService extends ServiceBase<Condition, Entity, Collection, Repository> {
}

作成するファイルは増えますが、各サービスの実装はとてもシンプルになりますね。

まとめ

本投稿では、DDDを参考にドメイン層を作成しました。
本投稿での目的は、

  • 無駄な実装(重複)を排除することで
  • より少ないコード量で実装し
  • 単体テストの負担を減らしたい

本来はもっとアプリケーション層から業務ロジックを分離した実装になるかと思います。
(サービスへDynamoDBに渡すパラメータ自体を渡すことは無い・・・はず)
さらにファクトリ、サービスファサードを用意することでアプリケーション層からはより利用しやすくなるかと思います。

私自身がDDDをまだまだ理解出来ていない部分が多いので、この投稿をきっかけに色々ご指摘などいただけると嬉しいです。

今回作成したソースは以下にあります。

domain-layer-for-dynamodb-node - GitHub

Sign up for free and join this conversation.
Sign Up
If you already have a Qiita account log in.