TypeScript で DynamoDB のドメイン層を作ろう
はじめに
タイトルの表現が適切か自信無い。
TypeScriptを使い始めて、早数ヶ月なんとなく感覚を掴んできた。(気がする)
最近AWS Lambdaを扱うことが多く、もちろん実行環境はNode.js(JavaScript)を選択しています。
そんな中色々どう作っていこうかと考えていった中で、特にDynamoDBの操作は書いていくと、大体同じ書き方になるし何よりテストを書くのがめんどくさい。
こういうのはアプリケーションから分離して汎用化(外部モジュール化)してしまいたい。
本投稿では汎用化することを目的とし、以下を参考に TypeScript で型定義の恩恵を得つつ、どう作成したのかを書きます。
[ 技術講座 ] Domain-Driven Designのエッセンス -目次-|オブジェクトの広場
DDDについて
参考
ドメイン層を構成する要素を考える
ドメイン層はざっくりと以下の要素で構成します。
- エンティティ(1テーブル1エンティティ)
- リポジトリ(1テーブルを操作する機能を提供する)
- サービス(リポジトリはカプセル化し、リポジトリ操作をアプリケーション層へ提供する)
- 操作条件(リポジトリがテーブル操作するために必要なパラメータ)
- コレクション(同一エンティティをまとめたもの 配列みたいなもの)
リポジトリ(サービスも同様)が持つ機能を決める
使用頻度の高そうな以下の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 | 文字列 |
ディレクトリ構成を決める
本投稿では、以下のディレクトリ構成でいきます。
ProjectRoot
├── src
| ├── lib
| | ├── collections … コレクション
| | ├── conditions … 操作条件
| | ├── exceptions … カスタム例外
| | ├── factories … ファクトリ
| | ├── models … モデル(エンティティ)
| | ├── repositories … リポジトリ
| | └── services … サービス
| └── index.ts
└── test
実装しよう
エンティティの作成
テーブル設計に沿ってエンティティを作成していきます。
まずは、各エンティティ毎の基底インタフェースを作成します。
各エンティティ毎に重複して設計されている以下の4項目を定義します。
export interface IEntityBase {
createdId?: string;
createdAt?: string;
modifiedId?: string;
modifiedAt?: string;
}
この基底インタフェースを継承し他のエンティティを作成していきます。
import { IEntityBase } from './entity-base';
export interface ICustomer extends IEntityBase {
id: number;
name?: string;
address?: string;
isDelete: number;
}
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
エンティティ毎にコレクションが必要となる為、基底クラスを作成します。
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実行結果を返す為のプロパティも合わせて定義します。
では、基底クラスを継承し顧客、ユーザのコレクションクラスを作成します。
import { CollectionBase } from './collection-base';
import { ICustomer as Entity } from '../models';
export class CustomerCollection extends CollectionBase<Entity> {
}
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
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
リポジトリクラスでもジェネリックを活用し、汎用的に利用できるよう基底クラスに上記の機能を実装していきます。
リポジトリインタフェースの作成
まずはどのような機能(メソッド)を持つかインタフェースを定義します。
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つです。
- 操作条件クラス作成時に除外した
TableName
を抽象プロパティとして定義 -
get,query(queryAll)
で取得したAWS.DynamoDB.DocumentClient.AttributeMap
をエンティティへマッピングする抽象メソッドを定義 - コレクションのインスタンスを作成する抽象メソッドを定義
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のみ抜粋します。
/**
* 選択 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
でコピー
顧客、ユーザリポジトリの作成
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();
}
}
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
でコピーしてエンティティを作成
サービスの作成
最後にサービスです。
サービスは渡された条件をそのままリポジトリへデリゲートするので、実装自体はシンプルです。
ここでもジェネリックを活用し、すべて基底クラスでまかないます。
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);
}
}
これらも基底クラスを継承し、顧客・ユーザサービスクラスを作成します。
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> {
}
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をまだまだ理解出来ていない部分が多いので、この投稿をきっかけに色々ご指摘などいただけると嬉しいです。
今回作成したソースは以下にあります。