0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

NestJSでDDDを実践する完全ガイド - CQRS・Hexagonal Architectureとともに

Posted at

TL;DR

  • DDDはドメインロジックをEntity中心に設計する手法
  • NestJSのCQRS + Hexagonal Architectureと相性が良い
  • ファクトリメソッド・バリデーション・ドメインイベントが重要
  • Repository Portで依存を逆転させる
  • Mapperで3層(DB・Domain・API)を明確に分離

この記事の対象読者

  • NestJSで開発経験がある
  • DDDという言葉は聞いたことがあるが実装方法がわからない
  • 「どこに何を書けばいいか」で迷っている
  • ビジネスロジックがコントローラーやサービスに散らばっている

環境・前提条件

  • Node.js: v22.x
  • NestJS: v11.x
  • TypeScript: v5.x
  • Prisma: v5.x (ORM)
  • @nestjs/cqrs: ^11.x

背景・課題

NestJSを使い始めて、「サービスクラスに全部書く」スタイルで開発していると、こんな問題に直面しませんか?

  • どこに何を書けばいいかわからない
  • バリデーションがコントローラーとサービスに散らばる
  • ビジネスルールの変更で複数ファイルを修正する必要がある
  • データベーススキーマ変更の影響範囲が大きい

私もDDDの概念は知っていたものの、「DDDの脳みそになる」のに苦労しました。この記事では、実際に動くコードを通して、NestJSでのDDD実践方法を解説します。

DDDとは何か(30秒で)

DDD(Domain-Driven Design)は、ビジネスロジック(ドメイン)を中心に設計する手法です。

// NG: サービスに全部書く
@Injectable()
export class UserService {
  async createUser(input: CreateUserDto) {
    // バリデーションがサービスに
    if (!input.publicUserId || input.publicUserId.length > 16) {
      throw new Error('Invalid user ID');
    }

    // データ保存もサービスに
従来のアプローチアンチパターン
    return this.prisma.user.create({ data: input });
  }
}

DDDのアプローチ:

// OK: Entityにビジネスロジック
export class UserEntity extends AggregateRoot<UserProps> {
  static create(props: CreateUserProps): UserEntity {
    const entity = new UserEntity({ id: randomUUID(), props });
    entity.validate(); // バリデーションはEntityの責務
    entity.addEvent(new UserCreatedDomainEvent({ ... })); // イベント駆動
    return entity;
  }

  validate(): void {
    if (!this.props.publicUserId || this.props.publicUserId.length > 16) {
      throw new InvalidPublicUserIdError();
    }
  }
}

// Handler: 永続化の調整のみ
@CommandHandler(CreateUserCommand)
export class CreateUserCommandHandler {
  async execute(command: CreateUserCommand) {
    const entity = UserEntity.create(command); // ビジネスロジックはEntityに
    await this.repository.insert(entity); // 永続化はRepositoryに
    return new IdResponseDto(entity.id);
  }
}

解決策: NestJSでDDDを実装する

アーキテクチャの全体像

GraphQL Request
    ↓
Resolver(入力バリデーション)
    ↓
CommandBus / QueryBus(CQRS)
    ↓
Handler(アプリケーション層)
    ↓
Entity(ドメイン層 - ビジネスロジック)
    ↓
Repository Port(抽象)
    ↓
Repository(Prisma実装)
    ↓
Database(PostgreSQL)

Step 1: DDD基盤クラスを作成

まずは共通の基盤クラスを作成します。これらは全モジュールで共有します。

1-1. Entity基底クラス

// src/libs/ddd/entity.base.ts
export type AggregateID = string;

export interface BaseEntityProps {
  id: AggregateID;
  createdAt: Date;
  updatedAt: Date;
}

export interface CreateEntityProps<T> {
  id: AggregateID;
  props: T;
  createdAt?: Date;
  updatedAt?: Date;
}

export abstract class Entity<EntityProps> {
  protected abstract _id: AggregateID;
  protected readonly props: EntityProps;
  private readonly _createdAt: Date;
  private _updatedAt: Date;

  constructor({ id, createdAt, updatedAt, props }: CreateEntityProps<EntityProps>) {
    this._id = id;
    this.validateProps(props);
    const now = new Date();
    this._createdAt = createdAt || now;
    this._updatedAt = updatedAt || now;
    this.props = props;
    this.validate();
  }

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

  get createdAt(): Date {
    return this._createdAt;
  }

  get updatedAt(): Date {
    return this._updatedAt;
  }

  // プロパティを不変(immutable)で返す
  public getProps(): EntityProps & BaseEntityProps {
    const propsCopy = {
      id: this._id,
      createdAt: this._createdAt,
      updatedAt: this._updatedAt,
      ...this.props,
    };
    return Object.freeze(propsCopy);
  }

  // 各Entityで実装必須
  public abstract validate(): void;

  private validateProps(props: EntityProps): void {
    if (!props || typeof props !== 'object') {
      throw new Error('Entity props should be an object');
    }
  }
}

1-2. AggregateRoot基底クラス

// src/libs/ddd/aggregate-root.base.ts
import { EventEmitter2 } from '@nestjs/event-emitter';
import { Entity } from './entity.base.js';
import { DomainEvent } from './domain-event.base.js';

export abstract class AggregateRoot<EntityProps> extends Entity<EntityProps> {
  private _domainEvents: DomainEvent[] = [];

  get domainEvents(): DomainEvent[] {
    return this._domainEvents;
  }

  protected addEvent(domainEvent: DomainEvent): void {
    this._domainEvents.push(domainEvent);
  }

  public clearEvents(): void {
    this._domainEvents = [];
  }

  // 永続化後にイベントを発行
  public async publishEvents(
    logger: LoggerPort,
    eventEmitter: EventEmitter2,
  ): Promise<void> {
    await Promise.all(
      this.domainEvents.map(async (event) => {
        logger.debug(`"${event.constructor.name}" event published`);
        return eventEmitter.emitAsync(event.constructor.name, event);
      }),
    );
    this.clearEvents();
  }
}

1-3. DomainEvent基底クラス

// src/libs/ddd/domain-event.base.ts
import { randomUUID } from 'crypto';

type DomainEventMetadata = {
  readonly timestamp: number;
  readonly correlationId: string;
  readonly userId?: string;
};

export type DomainEventProps<T> = Omit<T, 'id' | 'metadata'> & {
  aggregateId: string;
  metadata?: DomainEventMetadata;
};

export abstract class DomainEvent {
  public readonly id: string;
  public readonly aggregateId: string;
  public readonly metadata: DomainEventMetadata;

  constructor(props: DomainEventProps<unknown>) {
    this.id = randomUUID();
    this.aggregateId = props.aggregateId;
    this.metadata = {
      correlationId: props?.metadata?.correlationId || randomUUID(),
      timestamp: props?.metadata?.timestamp || Date.now(),
      userId: props?.metadata?.userId,
    };
  }
}

Step 2: ドメイン層を作成

実際のビジネスロジックを書く部分です。

2-1. Entityを定義

// src/modules/user/domain/user.entity.ts
import { Gender, Roles } from '@prisma/client';
import { randomUUID } from 'crypto';
import { AggregateRoot } from '../../../libs/ddd/aggregate-root.base.js';
import { AggregateID } from '../../../libs/ddd/entity.base.js';
import { UserCreatedDomainEvent } from './events/user-created.domain-event.js';
import { InvalidPublicUserIdError } from './user.errors.js';

export interface UserProps {
  firebaseUid: string;
  publicUserId: string;
  displayName: string;
  birthDate: Date;
  prefectureId: number;
  gender: Gender;
  role: Roles;
  rankId: number;
}

export interface CreateUserProps {
  firebaseUid: string;
  publicUserId: string;
  displayName: string;
  birthDate: Date;
  prefectureId: number;
  gender: Gender;
}

export class UserEntity extends AggregateRoot<UserProps> {
  protected _id: AggregateID;

  // ファクトリメソッドで生成(コンストラクタを直接呼ばない)
  static create(props: CreateUserProps): UserEntity {
    const id = randomUUID();
    const entity = new UserEntity({
      id,
      props: {
        ...props,
        role: Roles.USER,
        rankId: 1,
      },
    });
    entity.validate();

    // ドメインイベントを追加
    entity.addEvent(
      new UserCreatedDomainEvent({
        aggregateId: id,
        firebaseUid: props.firebaseUid,
        publicUserId: props.publicUserId,
        displayName: props.displayName,
      }),
    );

    return entity;
  }

  // ゲッターでプロパティにアクセス
  get publicUserId(): string {
    return this.props.publicUserId;
  }

  get displayName(): string {
    return this.props.displayName;
  }

  // バリデーションルール(ビジネスロジック)
  validate(): void {
    const PUBLIC_USER_ID_REGEX = /^[a-z0-9_.]+$/;

    if (!this.props.publicUserId || this.props.publicUserId.trim().length === 0) {
      throw new InvalidPublicUserIdError();
    }

    if (this.props.publicUserId.length > 16) {
      throw new InvalidPublicUserIdError();
    }

    if (!PUBLIC_USER_ID_REGEX.test(this.props.publicUserId)) {
      throw new InvalidPublicUserIdError();
    }

    if (!this.props.displayName || this.props.displayName.trim().length === 0) {
      throw new Error('表示名は必須です');
    }
  }
}

重要なポイント:

  1. static create() ファクトリメソッドでインスタンス生成
  2. validate() でビジネスルールを検証
  3. addEvent() でドメインイベントを記録(永続化後に発行)
  4. プロパティは getProps() で不変な形で取得

2-2. ドメインイベントを定義

// src/modules/user/domain/events/user-created.domain-event.ts
import { DomainEvent, DomainEventProps } from '../../../../libs/ddd/domain-event.base.js';

export class UserCreatedDomainEvent extends DomainEvent {
  readonly firebaseUid: string;
  readonly publicUserId: string;
  readonly displayName: string;

  constructor(props: DomainEventProps<UserCreatedDomainEvent>) {
    super(props);
    this.firebaseUid = props.firebaseUid;
    this.publicUserId = props.publicUserId;
    this.displayName = props.displayName;
  }
}

2-3. ドメイン固有の例外を定義

// src/modules/user/domain/user.errors.ts
import { ExceptionBase } from '../../../libs/exceptions/exception.base.js';

export class InvalidPublicUserIdError extends ExceptionBase {
  static readonly message = '公開ユーザーIDが不正です';
  public readonly code = 'USER.INVALID_PUBLIC_USER_ID';

  constructor(cause?: Error, metadata?: unknown) {
    super(InvalidPublicUserIdError.message, cause, metadata);
  }
}

export class UserAlreadyExistsError extends ExceptionBase {
  static readonly message = '既に登録済みのユーザーです';
  public readonly code = 'USER.ALREADY_EXISTS';

  constructor(cause?: Error, metadata?: unknown) {
    super(UserAlreadyExistsError.message, cause, metadata);
  }
}

Step 3: Repository層を作成(依存性逆転の原則)

3-1. Repository Port(抽象インターフェース)

// src/modules/user/database/user.repository.port.ts
import { User } from '@prisma/client';
import { UserEntity } from '../domain/user.entity.js';

export abstract class UserRepositoryPort {
  abstract findByFirebaseUid(firebaseUid: string): Promise<User | null>;
  abstract findByPublicUserId(publicUserId: string): Promise<User | null>;
  abstract insert(entity: UserEntity): Promise<void>;
  abstract insertIfNotExists(entity: UserEntity): Promise<void>;
}

なぜabstract classか?

  • NestJSのDIはクラスベース
  • interfaceではDIトークンとして使えない
  • abstract classならDIトークンと型定義を兼ねる

3-2. Repository実装(Prisma)

// src/modules/user/database/user.repository.ts
import { Inject, Injectable } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { PrismaClient } from '@prisma/client';
import { LOGGER } from '../../../libs/di-tokens.js';
import { LoggerPort } from '../../../libs/ports/logger.port.js';
import { UserEntity } from '../domain/user.entity.js';
import { UserAlreadyExistsError } from '../domain/user.errors.js';
import { UserMapper } from '../user.mapper.js';
import { UserRepositoryPort } from './user.repository.port.js';

@Injectable()
export class UserRepository implements UserRepositoryPort {
  constructor(
    private readonly prisma: PrismaClient,
    private readonly mapper: UserMapper,
    private readonly eventEmitter: EventEmitter2,
    @Inject(LOGGER)
    private readonly logger: LoggerPort,
  ) {}

  async findByFirebaseUid(firebaseUid: string) {
    return this.prisma.user.findUnique({
      where: { firebaseUid },
    });
  }

  async findByPublicUserId(publicUserId: string) {
    return this.prisma.user.findUnique({
      where: { publicUserId },
    });
  }

  async insert(entity: UserEntity): Promise<void> {
    const record = this.mapper.toPersistence(entity);

    await this.prisma.user.create({
      data: {
        id: record.id,
        firebaseUid: record.firebaseUid,
        publicUserId: record.publicUserId,
        role: record.role,
        rankId: record.rankId,
        displayName: record.displayName,
        birthDate: record.birthDate,
        prefectureId: record.prefectureId,
        gender: record.gender,
      },
    });

    // 永続化後にドメインイベントを発行
    await entity.publishEvents(this.logger, this.eventEmitter);
  }

  async insertIfNotExists(entity: UserEntity): Promise<void> {
    const existingByFirebaseUid = await this.findByFirebaseUid(entity.firebaseUid);
    if (existingByFirebaseUid) {
      throw new UserAlreadyExistsError();
    }

    const existingByPublicUserId = await this.findByPublicUserId(entity.publicUserId);
    if (existingByPublicUserId) {
      throw new UserAlreadyExistsError();
    }

    await this.insert(entity);
  }
}

重要なポイント:

  1. mapper.toPersistence() でEntityをDB形式に変換
  2. 永続化後に entity.publishEvents() でドメインイベントを発行
  3. ビジネスルール(重複チェック)をリポジトリに含めない(ドメイン層で行う)

3-3. Mapper(3層変換)

// src/modules/user/user.mapper.ts
import { Injectable } from '@nestjs/common';
import { User } from '@prisma/client';
import { Mapper } from '../../libs/ddd/mapper.interface.js';
import { UserEntity } from './domain/user.entity.js';
import { MeResponseDto } from './dtos/me.response.dto.js';

@Injectable()
export class UserMapper implements Mapper<UserEntity, User, MeResponseDto> {
  // Entity -> DB Record
  toPersistence(entity: UserEntity): User {
    const props = entity.getProps();
    return {
      id: props.id,
      firebaseUid: props.firebaseUid,
      publicUserId: props.publicUserId,
      role: props.role,
      rankId: props.rankId,
      displayName: props.displayName,
      birthDate: props.birthDate,
      prefectureId: props.prefectureId,
      gender: props.gender,
      createdAt: props.createdAt,
      updatedAt: props.updatedAt,
    };
  }

  // DB Record -> Entity
  toDomain(record: User): UserEntity {
    return new UserEntity({
      id: String(record.id),
      createdAt: record.createdAt,
      updatedAt: record.updatedAt,
      props: {
        firebaseUid: record.firebaseUid,
        publicUserId: record.publicUserId,
        role: record.role,
        rankId: record.rankId,
        displayName: record.displayName,
        birthDate: record.birthDate,
        prefectureId: record.prefectureId,
        gender: record.gender,
      },
    });
  }

  // Entity -> Response DTO
  toResponse(entity: UserEntity): MeResponseDto {
    const props = entity.getProps();
    return new MeResponseDto({
      id: props.id,
      firebaseUid: props.firebaseUid,
      publicUserId: props.publicUserId,
      role: props.role,
      rankId: props.rankId,
      displayName: props.displayName,
      birthDate: props.birthDate,
      prefectureId: props.prefectureId,
      gender: props.gender,
    });
  }
}

Step 4: CQRS(Command/Query)層を作成

4-1. Command定義

// src/modules/user/commands/create-user/create-user.command.ts
import { Gender } from '@prisma/client';
import { Command, CommandProps } from '../../../../libs/ddd/command.base.js';

export class CreateUserCommand extends Command {
  readonly firebaseUid: string;
  readonly publicUserId: string;
  readonly displayName: string;
  readonly birthDate: Date;
  readonly prefectureId: number;
  readonly gender: Gender;

  constructor(props: CommandProps<CreateUserCommand>) {
    super(props);
    this.firebaseUid = props.firebaseUid;
    this.publicUserId = props.publicUserId;
    this.displayName = props.displayName;
    this.birthDate = props.birthDate;
    this.prefectureId = props.prefectureId;
    this.gender = props.gender;
  }
}

4-2. CommandHandler実装

// src/modules/user/commands/create-user/create-user.command-handler.ts
import { Inject } from '@nestjs/common';
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
import { IdResponseDto } from '../../../../libs/api/id.response.dto.js';
import { UserRepositoryPort } from '../../database/user.repository.port.js';
import { UserEntity } from '../../domain/user.entity.js';
import { USER_REPOSITORY } from '../../user.di-tokens.js';
import { CreateUserCommand } from './create-user.command.js';

@CommandHandler(CreateUserCommand)
export class CreateUserCommandHandler implements ICommandHandler<
  CreateUserCommand,
  IdResponseDto
> {
  constructor(
    @Inject(USER_REPOSITORY)
    private readonly repository: UserRepositoryPort,
  ) {}

  async execute(command: CreateUserCommand): Promise<IdResponseDto> {
    // ファクトリメソッドでEntity生成(バリデーション・イベント含む)
    const entity = UserEntity.create({
      firebaseUid: command.firebaseUid,
      publicUserId: command.publicUserId,
      displayName: command.displayName,
      birthDate: command.birthDate,
      prefectureId: command.prefectureId,
      gender: command.gender,
    });

    // 永続化
    await this.repository.insertIfNotExists(entity);

    return new IdResponseDto(entity.id);
  }
}

CommandHandlerの責務:

  • Commandを受け取る
  • Entityを生成・取得
  • Repositoryで永続化
  • Response DTOを返す

CommandHandlerが持たない責務:

  • バリデーション(Entityの責務)
  • ビジネスルール(Entityの責務)
  • データ変換(Mapperの責務)

Step 5: Resolver(GraphQLエントリポイント)

// src/modules/user/commands/create-user/create-user.resolver.ts
import { UseGuards } from '@nestjs/common';
import { CommandBus } from '@nestjs/cqrs';
import { Args, Mutation, Resolver } from '@nestjs/graphql';
import { IdResponseDto } from '../../../../libs/api/id.response.dto.js';
import { User } from '../../../../libs/decorators/user.decorator.js';
import { GqlFirebaseAuthGuard } from '../../../../libs/guards/gql-firebase-auth.guard.js';
import { FirebaseAuth } from '../../../auth/strategies/firebase-auth.strategy.js';
import { CreateUserInputDto } from '../../dtos/create-user.input.dto.js';
import { CreateUserCommand } from './create-user.command.js';

@Resolver()
export class CreateUserResolver {
  constructor(private readonly commandBus: CommandBus) {}

  @Mutation(() => IdResponseDto, { description: 'ユーザーを作成' })
  @UseGuards(GqlFirebaseAuthGuard)
  async createUser(
    @User() auth: FirebaseAuth,
    @Args('input') input: CreateUserInputDto,
  ): Promise<IdResponseDto> {
    return this.commandBus.execute(
      new CreateUserCommand({
        firebaseUid: auth.firebaseUid,
        publicUserId: input.publicUserId,
        displayName: input.displayName,
        birthDate: input.birthDate,
        prefectureId: input.prefectureId,
        gender: input.gender,
      }),
    );
  }
}

Step 6: モジュール定義(DI設定)

// src/modules/user/user.di-tokens.ts
export const USER_REPOSITORY = Symbol('USER_REPOSITORY');
// src/modules/user/user.module.ts
import { Module } from '@nestjs/common';
import { CqrsModule } from '@nestjs/cqrs';
import { PrismaClient } from '@prisma/client';
import { LOGGER } from '../../libs/di-tokens.js';
import { NestLoggerAdapter } from '../../libs/adapters/nest-logger.adapter.js';
import { CreateUserResolver } from './commands/create-user/create-user.resolver.js';
import { CreateUserCommandHandler } from './commands/create-user/create-user.command-handler.js';
import { UserMapper } from './user.mapper.js';
import { UserRepository } from './database/user.repository.js';
import { USER_REPOSITORY } from './user.di-tokens.js';

const commandHandlers = [CreateUserCommandHandler];
const resolvers = [CreateUserResolver];

@Module({
  imports: [CqrsModule],
  providers: [
    ...commandHandlers,
    ...resolvers,
    UserMapper,
    {
      provide: USER_REPOSITORY,
      useClass: UserRepository,
    },
    {
      provide: LOGGER,
      useClass: NestLoggerAdapter,
    },
    PrismaClient,
  ],
})
export class UserModule {}

重要なポイント:

  1. SymbolベースのDIトークンで抽象化
  2. UserRepositoryPort(抽象)に依存、UserRepository(実装)を注入
  3. テスト時はモックRepositoryに差し替え可能

Before / After

Before: サービス層にロジック集中(アンチパターン)

// NG: バリデーション・永続化・イベントが全部サービスに
@Injectable()
export class UserService {
  async createUser(input: CreateUserDto) {
    // バリデーション
    if (!input.publicUserId || input.publicUserId.length > 16) {
      throw new BadRequestException('Invalid user ID');
    }

    // 重複チェック
    const existing = await this.prisma.user.findUnique({
      where: { publicUserId: input.publicUserId },
    });
    if (existing) {
      throw new ConflictException('User already exists');
    }

    // データ保存
    const user = await this.prisma.user.create({ data: input });

    // イベント送信
    await this.eventEmitter.emit('user.created', user);

    return user;
  }
}

問題点:

  • バリデーションルールがサービスに散らばる
  • テストでPrismaをモックする必要がある
  • ビジネスルールの変更でサービスを修正
  • データベーススキーマ変更の影響が大きい

After: DDD + CQRS + Hexagonal Architecture

// OK: 各層に責務を分離

// Entity: バリデーション・ビジネスロジック
export class UserEntity extends AggregateRoot<UserProps> {
  static create(props: CreateUserProps): UserEntity {
    const entity = new UserEntity({ id: randomUUID(), props });
    entity.validate(); // バリデーションはEntityの責務
    entity.addEvent(new UserCreatedDomainEvent({ ... }));
    return entity;
  }

  validate(): void {
    if (!this.props.publicUserId || this.props.publicUserId.length > 16) {
      throw new InvalidPublicUserIdError();
    }
  }
}

// Repository: 永続化の詳細を隠蔽
@Injectable()
export class UserRepository implements UserRepositoryPort {
  async insert(entity: UserEntity): Promise<void> {
    const record = this.mapper.toPersistence(entity);
    await this.prisma.user.create({ data: record });
    await entity.publishEvents(this.logger, this.eventEmitter); // イベント発行
  }
}

// Handler: オーケストレーション
@CommandHandler(CreateUserCommand)
export class CreateUserCommandHandler {
  async execute(command: CreateUserCommand) {
    const entity = UserEntity.create(command); // ビジネスロジック
    await this.repository.insert(entity); // 永続化
    return new IdResponseDto(entity.id);
  }
}

改善点:

  • バリデーションルールがEntityに集約
  • Repositoryをモックしてテスト可能
  • ビジネスルール変更はEntityのみ修正
  • データベーススキーマ変更の影響はRepository・Mapperに限定

ハマりポイント・注意点

1. Entityを直接newしない

// NG: コンストラクタを直接呼ぶ
const user = new UserEntity({ id: '...', props: { ... } });

// OK: ファクトリメソッドを使う
const user = UserEntity.create({ publicUserId: 'test', ... });

理由: ファクトリメソッドでバリデーション・イベント追加を一括管理できる。

2. ドメインイベントは永続化後に発行

// NG: 永続化前にイベント発行
entity.publishEvents(logger, eventEmitter);
await repository.insert(entity);

// OK: 永続化後にイベント発行
await repository.insert(entity);
// Repositoryの中で entity.publishEvents() を呼ぶ

理由: トランザクション失敗時にイベントだけ発行される事態を防ぐ。

3. Repository Portはinterfaceではなくabstract class

// NG: interfaceではDIできない
export interface UserRepositoryPort { ... }

// OK: abstract classならDIトークンとして使える
export abstract class UserRepositoryPort { ... }

理由: NestJSのDIはクラスベース。interfaceは実行時に消える。

4. Mapperは必ず3層(DB・Domain・API)を分離

// Entity -> DB
toPersistence(entity: UserEntity): User

// DB -> Entity
toDomain(record: User): UserEntity

// Entity -> DTO
toResponse(entity: UserEntity): UserResponseDto

理由: DBスキーマ変更の影響をMapperに限定できる。

5. バリデーションはEntityとDTOで二段階

// DTO: 入力の型チェック(GraphQL・REST層)
export class CreateUserInputDto {
  @IsString()
  @Length(1, 16)
  publicUserId: string;
}

// Entity: ビジネスルール検証(ドメイン層)
validate(): void {
  if (!/^[a-z0-9_.]+$/.test(this.props.publicUserId)) {
    throw new InvalidPublicUserIdError();
  }
}

理由: DTOは型安全性、Entityはビジネスルール。責務が異なる。

6. anyは絶対に使わない

// NG
const user: any = await repository.findById(id);

// OK
const user: User | null = await repository.findById(id);
if (!user) {
  throw new UserNotFoundError();
}

理由: DDDは型で制約を表現する。型を失うとDDDの価値が半減。

まとめ

NestJSでDDDを実践する手順をまとめます。

実装チェックリスト

  • DDD基盤クラス(Entity, AggregateRoot, DomainEvent)を作成
  • Entityにビジネスロジック・バリデーションを集約
  • ファクトリメソッドでEntity生成
  • Repository Portで依存を逆転
  • Mapperで3層(DB・Domain・API)を分離
  • CommandHandler/QueryHandlerでオーケストレーション
  • ドメインイベントを永続化後に発行
  • Symbolベースのトークンで依存注入

DDDの本質

DDDは「どこに何を書くか」を明確にする設計手法です。

レイヤー 責務 具体例
Domain ビジネスロジック Entity, ValueObject, DomainEvent
Application オーケストレーション CommandHandler, QueryHandler
Infrastructure 永続化・外部連携 Repository, Mapper
Presentation 入力バリデーション Resolver, Controller, DTO

このルールを守れば、「サービス層に全部書く」問題から解放されます。

次のステップ

  • ValueObjectを導入してEntityをさらに型安全に
  • Domain Serviceで複数Entityにまたがるビジネスロジックを管理
  • Saga Patternで複雑なトランザクションを管理
  • Event Sourcingでドメインイベントを永続化

参考


タグ: NestJS DDD CQRS TypeScript 設計パターン

0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?