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('表示名は必須です');
}
}
}
重要なポイント:
-
static create()ファクトリメソッドでインスタンス生成 -
validate()でビジネスルールを検証 -
addEvent()でドメインイベントを記録(永続化後に発行) - プロパティは
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);
}
}
重要なポイント:
-
mapper.toPersistence()でEntityをDB形式に変換 - 永続化後に
entity.publishEvents()でドメインイベントを発行 - ビジネスルール(重複チェック)をリポジトリに含めない(ドメイン層で行う)
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 {}
重要なポイント:
-
SymbolベースのDIトークンで抽象化 -
UserRepositoryPort(抽象)に依存、UserRepository(実装)を注入 - テスト時はモック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 設計パターン