Open1

ドメイン駆動設計でコントローラにドメインの知識を漏れ出さないようにする (まだ中途半端)

こばちきこばちき

マイクロサービスをオニオンアーキテクチャの考え方で記述していると、ドメインモデルからリソースモデルへの変換をコントローラで行うように記述していた。
しかし、コントローラでドメインモデルが出てこない書き方を思いついた。今更と言われるかもしれませんが、思いついたことを記述しておきます。ただ、これでもまだインフラストラクチャ層にドメインモデルが出てくるので、解決にはなっていない。

インフラストラクチャ層→アプリケーション層→ドメイン層
外側から内側へしかアクセスできず、層を飛び越えてアクセスはしてはいけないという規約があります。

ドメイン層

export class PlaceId extends PrimitiveValueObject<string> {
  constructor(value: string) {
    super(value);
  }
}

type Props = {
  code: string;
  name: string;
};

export class Place extends Entity<PlaceId, Props> {
  private constructor(id: PlaceId, props: Props) {
    super(id, props);
  }

  static create(id: PlaceId, props: Props): Place {
    return new Place(id, props);
  }
}

ドメイン層のエンティティは簡略化しています。

アプリケーション層

「場所IDから場所を取得する」というユースケースを実装する

ユースケースのリクエストクラス

export class FindPlaceByIdRequest {
  constructor(private _id: string) {}

  get id(): string {
    return this._id;
  }
}

ユースケースのレスポンスクラス

type Props = {
  place?: Place;
  message?: string;
};

export class FindPlaceByIdResponse {
  private constructor(private _error: boolean, private props: Props) {}

  get error(): boolean {
    return this._error;
  }

  get place(): Place | undefined {
    return this.props.place;
  }

  get message(): string | undefined {
    return this.props.message;
  }

  static success(place: Place): FindPlaceByIdResponse {
    return new FindPlaceByIdResponse(false, { place });
  }

  static fail(message: string): FindPlaceByIdResponse {
    return new FindPlaceByIdResponse(true, { message });
  }
}

ユースケースの実装クラス

export interface FindPlaceByUsecase {
  execute(request: FindPlaceByIdRequest): Promise<FindPlaceByIdResponse>;
}

@Injectable()
export class FindPlaceByIdInteractor implements FindPlaceByIdUsecase {
  constructor(@Inject(PlaceToken.PlaceRepository) private readonly placeRepository: PlaceRepository) {}

  async execute(request: FindPlaceByIdRequest): Promise<FindPlaceByIdResponse> {
    const id = new PlaceId(request.id);
    const place = await this.placeRepository.findById(id);
    if (place == null) {
      return FindPlaceByIdResponse.fail(`場所が存在しません`);
    }
    return FindPlaceByIdResponse.success(place);
  }
}

インフラストラクチャ層

REST APIの場所リソースのモデル

export class PlaceResourceModel {
  private constructor(id: string, code: string, name: string) {}

  static convert(domain: Place): PlaceResourceModel {
    return new PlaceResourceModel(domain.id.value, domain.code, domain.name);
  }
}

場所コントローラ

@Controller('places')
export class PlaceController {
  constructor(@Inject(PlaceToken.FindPlaceByIdUsecase) private readonly findPlaceByIdUsecase) {}

  @Get(':id')
  async findOne(@Param('id') id: string): Promise<PlaceResourceModel> {
    const request = new FindPlaceByIdRequest(id);
    const response = await this.findPlaceByIdUsecase.execute(request);
    if (request.error || request.place == null) {
      throw new NotFoundException(request.message);
    }
    return PlaceResourceModel.convert(response.place); // ここにドメインモデルが現れている。ドメイン知識が漏れ出している。
  }
}

場所モジュール

@Module({
  providers: [
    {
      provide: PlaceToken.PlaceRepository,
      useClass: SqlPlaceRequest,
    },
    {
      provide: PlaceToken.FindPlaceByIdUsecase,
      useClass: FindPlaceByInteractor,
    },
  ],
  controllers: [PlaceController],
})
export class PlaceModule {}

上記のPlaceController.findOnereturn PlaceResourceModel.convert(response.place);のようにドメイン知識がインフラストラクチャ層に漏れ出している。

アプリケーション層のFindPlaceByIdInteractor.executeで変換しようと以下のようなコードを書くと今度はアプリケーション層内にインフラストラクチャ層のリソースモデルが侵食してしまう。

  async execute(request: FindPlaceByIdRequest): Promise<FindPlaceByIdResponse> {
    const id = new PlaceId(request.id);
    const place = await this.placeRepository.findById(id);
    if (place == null) {
      return FindPlaceByIdResponse.fail(`場所が存在しません`);
    }
    return FindPlaceByIdResponse.success(PlaceResourceModel.convert(place)); // インフラストラクチャ層のモデルが侵食している
  }

しかもこの場合、レスポンスにもPlaceResourceModelを定義しないといけなくなり、やはりインフラストラクチャ層が侵食してくる。

type Props = {
  place?: PlaceResourceModel; // インフラストラクチャ層のクラス
  message?: string;
};

export class FindPlaceByIdResponse {
  private constructor(private _error: boolean, private props: Props) {}
...

インフラストラクチャ層にドメイン層の知識が漏れ出さないようにするには、ドメインモデルからリソースモデルへの変換をアプリケーション層で行えばいいと考えました。
コントローラからアプリケーション層内で取得したドメインモデルをリソースモデルに変換する関数を渡すようにしました。

FindPlaceByIdRequestインスタンス作成時に変換クラスを追加で渡すようにします。定義時には変換先のクラスはわからないため、ジェネリクス型でリクエスト型を定義する。

export class FindPlaceByIdRequest<T> {
  constructor(private _id: string, private _convert: (place: Place) => T) {}

  get id(): string {
    return this._id;
  }

  get convert(): (place: Place) => T {
    return this._convert;
  }
}

FindPlaceByIdResponseの結果はコントローラのリソースモデルのインスタンスを持たせるようにします。こちらも定義時にはリソースモデルの型がわからないので、ジェネリクス型で定義しておきます。

type Props<T> = {
  place?: T;
  message?: string;
};

export class FindPlaceByIdResponse<T> {
  private constructor(private _error: boolean, private props: Props) {}

  get error(): boolean {
    return this._error;
  }

  get place(): T | undefined {
    return this.props.place;
  }

  get message(): string | undefined {
    return this.props.message;
  }

  static success(place: T): FindPlaceByIdResponse {
    return new FindPlaceByIdResponse(false, { place });
  }

  static fail(message: string): FindPlaceByIdResponse {
    return new FindPlaceByIdResponse(true, { message });
  }
}

FindPlaceByIdInteractor.execute内部で取得したドメインモデルをリクエストで渡した変換関数を通してリソースモデルに変換したインスタンスをレスポンスに設定し、そのレスポンスをコントローラに戻します。

  async execute(request: FindPlaceByIdRequest<T>): Promise<FindPlaceByIdResponse<T>> {
    const id = new PlaceId(request.id);
    const place = await this.placeRepository.findById(id);
    if (place == null) {
      return FindPlaceByIdResponse.fail(`場所が存在しません`);
    }
    return FindPlaceByIdResponse.success(request.convert(place));
  }

PlaceControllerコントローラではそれらのユースケースを実行します。

async findOne(@Param('id') id: string): Promise<PlaceResourceModel> {
  const request = new FindPlaceByIdRequest<PlaceRequestModel>(id, PlaceResourceModel.convert);
  const response = await this.findPlaceByIdUsecase.execute(request);
  if (response.error || response.place == null) {
    throw new NotFoundException(response.message);
  }
  return response.place;
}

いずれにしてもPlaceResourceModelはインフラストラクチャ層のリソースモデルで、このクラスのconvert(Place)はドメインモデルを引数に取っているため、まだドメインの知識が漏れています。
アプリケーション層用のモデルを用意して、アプリケーション層では、ドメイン層からアプリケーション層のモデルへの変換をし、インフラストラクチャ層では、アプリケーション層からインフラストラクチャ層のモデルへの変換をする。こうすることにより、漏れがなくなる。
しかし、層を跨ぐたびにモデルの変換を繰り返すのは実装と実行のコストがかかるため、ひとまずドメインモデルからリソースモデルへ一足飛びにモデルの変換をするのは目を瞑ることにする。