ドメイン駆動設計(DDD)は、複雑なビジネスロジックを持つソフトウェアを効果的に設計・実装するための手法です。本記事では、DDDの戦術的設計パターンをTypeScriptで実装する方法を解説します。
DDDの基本概念
戦略的設計と戦術的設計
flowchart TB
subgraph Strategic["戦略的設計"]
S1["ユビキタス言語<br/>(共通言語の定義)"]
S2["境界づけられたコンテキスト<br/>(システムの分割)"]
S3["コンテキストマップ<br/>(コンテキスト間の関係)"]
S4["サブドメイン<br/>(コア/サポート/汎用)"]
end
subgraph Tactical["戦術的設計"]
T1["エンティティ<br/>(識別子を持つオブジェクト)"]
T2["値オブジェクト<br/>(不変の値)"]
T3["集約<br/>(一貫性の境界)"]
T4["リポジトリ<br/>(永続化の抽象化)"]
T5["ドメインサービス<br/>(エンティティに属さないロジック)"]
T6["ドメインイベント<br/>(状態変化の通知)"]
T7["ファクトリ<br/>(複雑なオブジェクト生成)"]
end
Strategic --> Tactical
レイヤードアーキテクチャ
flowchart TB
subgraph P["Presentation Layer"]
P1["Controllers, API, UI"]
end
subgraph A["Application Layer"]
A1["Use Cases, Application Services, DTOs"]
end
subgraph D["Domain Layer"]
D1["Entities, Value Objects, Aggregates, Domain Services"]
end
subgraph I["Infrastructure Layer"]
I1["Repositories, External Services, DB"]
end
P --> A --> D --> I
値オブジェクト
基本的な値オブジェクト
// domain/value-objects/email.ts
export class Email {
private readonly value: string;
private constructor(value: string) {
this.value = value;
}
static create(value: string): Email {
if (!value || !this.isValid(value)) {
throw new Error('Invalid email format');
}
return new Email(value.toLowerCase().trim());
}
private static isValid(email: string): boolean {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
}
getValue(): string {
return this.value;
}
equals(other: Email): boolean {
return this.value === other.value;
}
getDomain(): string {
return this.value.split('@')[1];
}
toString(): string {
return this.value;
}
}
複合値オブジェクト
// domain/value-objects/money.ts
export type Currency = 'JPY' | 'USD' | 'EUR';
export class Money {
private constructor(
private readonly amount: number,
private readonly currency: Currency
) {
if (amount < 0) {
throw new Error('Amount cannot be negative');
}
}
static create(amount: number, currency: Currency): Money {
return new Money(amount, currency);
}
static zero(currency: Currency): Money {
return new Money(0, currency);
}
getAmount(): number {
return this.amount;
}
getCurrency(): Currency {
return this.currency;
}
add(other: Money): Money {
this.assertSameCurrency(other);
return new Money(this.amount + other.amount, this.currency);
}
subtract(other: Money): Money {
this.assertSameCurrency(other);
const result = this.amount - other.amount;
if (result < 0) {
throw new Error('Result cannot be negative');
}
return new Money(result, this.currency);
}
multiply(factor: number): Money {
if (factor < 0) {
throw new Error('Factor cannot be negative');
}
return new Money(Math.round(this.amount * factor), this.currency);
}
isGreaterThan(other: Money): boolean {
this.assertSameCurrency(other);
return this.amount > other.amount;
}
isLessThan(other: Money): boolean {
this.assertSameCurrency(other);
return this.amount < other.amount;
}
equals(other: Money): boolean {
return this.amount === other.amount && this.currency === other.currency;
}
private assertSameCurrency(other: Money): void {
if (this.currency !== other.currency) {
throw new Error('Currency mismatch');
}
}
format(): string {
const formatter = new Intl.NumberFormat('ja-JP', {
style: 'currency',
currency: this.currency,
});
return formatter.format(this.amount);
}
}
住所値オブジェクト
// domain/value-objects/address.ts
interface AddressProps {
postalCode: string;
prefecture: string;
city: string;
street: string;
building?: string;
}
export class Address {
private constructor(
private readonly postalCode: string,
private readonly prefecture: string,
private readonly city: string,
private readonly street: string,
private readonly building?: string
) {}
static create(props: AddressProps): Address {
this.validatePostalCode(props.postalCode);
this.validatePrefecture(props.prefecture);
return new Address(
props.postalCode,
props.prefecture,
props.city,
props.street,
props.building
);
}
private static validatePostalCode(postalCode: string): void {
if (!/^\d{3}-?\d{4}$/.test(postalCode)) {
throw new Error('Invalid postal code format');
}
}
private static validatePrefecture(prefecture: string): void {
const prefectures = [
'北海道', '青森県', '岩手県', /* ... */
];
if (!prefectures.includes(prefecture)) {
throw new Error('Invalid prefecture');
}
}
getPostalCode(): string {
return this.postalCode;
}
getPrefecture(): string {
return this.prefecture;
}
getFullAddress(): string {
const parts = [
this.postalCode,
this.prefecture,
this.city,
this.street,
];
if (this.building) {
parts.push(this.building);
}
return parts.join(' ');
}
equals(other: Address): boolean {
return (
this.postalCode === other.postalCode &&
this.prefecture === other.prefecture &&
this.city === other.city &&
this.street === other.street &&
this.building === other.building
);
}
}
エンティティ
基底エンティティクラス
// domain/shared/entity.ts
export abstract class Entity<T> {
protected readonly _id: T;
constructor(id: T) {
this._id = id;
}
get id(): T {
return this._id;
}
equals(other: Entity<T>): boolean {
if (other === null || other === undefined) {
return false;
}
if (!(other instanceof Entity)) {
return false;
}
return this._id === other._id;
}
}
ユーザーエンティティ
// domain/entities/user.ts
import { Entity } from '../shared/entity';
import { Email } from '../value-objects/email';
import { UserId } from '../value-objects/user-id';
import { UserCreatedEvent } from '../events/user-created';
interface UserProps {
email: Email;
name: string;
role: UserRole;
status: UserStatus;
createdAt: Date;
updatedAt: Date;
}
type UserRole = 'admin' | 'member' | 'guest';
type UserStatus = 'active' | 'inactive' | 'suspended';
export class User extends Entity<UserId> {
private email: Email;
private name: string;
private role: UserRole;
private status: UserStatus;
private readonly createdAt: Date;
private updatedAt: Date;
private constructor(id: UserId, props: UserProps) {
super(id);
this.email = props.email;
this.name = props.name;
this.role = props.role;
this.status = props.status;
this.createdAt = props.createdAt;
this.updatedAt = props.updatedAt;
}
// ファクトリメソッド
static create(email: Email, name: string): User {
const id = UserId.create();
const now = new Date();
const user = new User(id, {
email,
name,
role: 'member',
status: 'active',
createdAt: now,
updatedAt: now,
});
// ドメインイベント発行
user.addDomainEvent(new UserCreatedEvent(user.id, email));
return user;
}
// 再構築メソッド(リポジトリから復元時)
static reconstruct(id: UserId, props: UserProps): User {
return new User(id, props);
}
// ビジネスロジック
changeName(newName: string): void {
if (!newName || newName.trim().length < 2) {
throw new Error('Name must be at least 2 characters');
}
this.name = newName.trim();
this.updatedAt = new Date();
}
changeEmail(newEmail: Email): void {
if (this.email.equals(newEmail)) {
return;
}
this.email = newEmail;
this.updatedAt = new Date();
}
promote(): void {
if (this.role === 'admin') {
throw new Error('User is already an admin');
}
this.role = 'admin';
this.updatedAt = new Date();
}
suspend(): void {
if (this.status === 'suspended') {
throw new Error('User is already suspended');
}
this.status = 'suspended';
this.updatedAt = new Date();
}
activate(): void {
if (this.status === 'active') {
throw new Error('User is already active');
}
this.status = 'active';
this.updatedAt = new Date();
}
isActive(): boolean {
return this.status === 'active';
}
isAdmin(): boolean {
return this.role === 'admin';
}
// Getter
getEmail(): Email {
return this.email;
}
getName(): string {
return this.name;
}
getRole(): UserRole {
return this.role;
}
getStatus(): UserStatus {
return this.status;
}
getCreatedAt(): Date {
return this.createdAt;
}
}
集約
注文集約
// domain/aggregates/order/order.ts
import { AggregateRoot } from '../../shared/aggregate-root';
import { OrderId } from './order-id';
import { OrderItem } from './order-item';
import { OrderStatus } from './order-status';
import { Money } from '../../value-objects/money';
import { UserId } from '../../value-objects/user-id';
import { OrderCreatedEvent } from '../../events/order-created';
import { OrderCompletedEvent } from '../../events/order-completed';
interface OrderProps {
userId: UserId;
items: OrderItem[];
status: OrderStatus;
shippingAddress: Address;
createdAt: Date;
updatedAt: Date;
}
export class Order extends AggregateRoot<OrderId> {
private userId: UserId;
private items: OrderItem[];
private status: OrderStatus;
private shippingAddress: Address;
private readonly createdAt: Date;
private updatedAt: Date;
private constructor(id: OrderId, props: OrderProps) {
super(id);
this.userId = props.userId;
this.items = props.items;
this.status = props.status;
this.shippingAddress = props.shippingAddress;
this.createdAt = props.createdAt;
this.updatedAt = props.updatedAt;
}
static create(userId: UserId, shippingAddress: Address): Order {
const id = OrderId.create();
const now = new Date();
const order = new Order(id, {
userId,
items: [],
status: OrderStatus.DRAFT,
shippingAddress,
createdAt: now,
updatedAt: now,
});
return order;
}
// 商品追加
addItem(productId: ProductId, quantity: number, unitPrice: Money): void {
this.assertCanModify();
const existingItem = this.items.find(
item => item.getProductId().equals(productId)
);
if (existingItem) {
existingItem.increaseQuantity(quantity);
} else {
this.items.push(
OrderItem.create(productId, quantity, unitPrice)
);
}
this.updatedAt = new Date();
}
// 商品削除
removeItem(productId: ProductId): void {
this.assertCanModify();
const index = this.items.findIndex(
item => item.getProductId().equals(productId)
);
if (index === -1) {
throw new Error('Item not found');
}
this.items.splice(index, 1);
this.updatedAt = new Date();
}
// 注文確定
place(): void {
if (this.items.length === 0) {
throw new Error('Cannot place order with no items');
}
if (this.status !== OrderStatus.DRAFT) {
throw new Error('Order is not in draft status');
}
this.status = OrderStatus.PLACED;
this.updatedAt = new Date();
this.addDomainEvent(new OrderCreatedEvent(
this.id,
this.userId,
this.calculateTotal()
));
}
// 支払い完了
markAsPaid(): void {
if (this.status !== OrderStatus.PLACED) {
throw new Error('Order is not placed');
}
this.status = OrderStatus.PAID;
this.updatedAt = new Date();
}
// 発送
ship(): void {
if (this.status !== OrderStatus.PAID) {
throw new Error('Order is not paid');
}
this.status = OrderStatus.SHIPPED;
this.updatedAt = new Date();
}
// 配送完了
complete(): void {
if (this.status !== OrderStatus.SHIPPED) {
throw new Error('Order is not shipped');
}
this.status = OrderStatus.COMPLETED;
this.updatedAt = new Date();
this.addDomainEvent(new OrderCompletedEvent(this.id));
}
// キャンセル
cancel(): void {
if (this.status === OrderStatus.SHIPPED ||
this.status === OrderStatus.COMPLETED) {
throw new Error('Cannot cancel shipped or completed order');
}
this.status = OrderStatus.CANCELLED;
this.updatedAt = new Date();
}
// 合計金額計算
calculateTotal(): Money {
return this.items.reduce(
(total, item) => total.add(item.calculateSubtotal()),
Money.zero('JPY')
);
}
private assertCanModify(): void {
if (this.status !== OrderStatus.DRAFT) {
throw new Error('Cannot modify non-draft order');
}
}
// Getter
getUserId(): UserId {
return this.userId;
}
getItems(): ReadonlyArray<OrderItem> {
return [...this.items];
}
getStatus(): OrderStatus {
return this.status;
}
getShippingAddress(): Address {
return this.shippingAddress;
}
}
// order-item.ts (集約内のエンティティ)
export class OrderItem {
private constructor(
private readonly productId: ProductId,
private quantity: number,
private readonly unitPrice: Money
) {}
static create(
productId: ProductId,
quantity: number,
unitPrice: Money
): OrderItem {
if (quantity <= 0) {
throw new Error('Quantity must be positive');
}
return new OrderItem(productId, quantity, unitPrice);
}
increaseQuantity(amount: number): void {
if (amount <= 0) {
throw new Error('Amount must be positive');
}
this.quantity += amount;
}
calculateSubtotal(): Money {
return this.unitPrice.multiply(this.quantity);
}
getProductId(): ProductId {
return this.productId;
}
getQuantity(): number {
return this.quantity;
}
getUnitPrice(): Money {
return this.unitPrice;
}
}
// order-status.ts (値オブジェクト)
export enum OrderStatus {
DRAFT = 'DRAFT',
PLACED = 'PLACED',
PAID = 'PAID',
SHIPPED = 'SHIPPED',
COMPLETED = 'COMPLETED',
CANCELLED = 'CANCELLED',
}
リポジトリ
リポジトリインターフェース
// domain/repositories/order-repository.ts
export interface OrderRepository {
save(order: Order): Promise<void>;
findById(id: OrderId): Promise<Order | null>;
findByUserId(userId: UserId): Promise<Order[]>;
delete(id: OrderId): Promise<void>;
nextId(): OrderId;
}
// domain/repositories/user-repository.ts
export interface UserRepository {
save(user: User): Promise<void>;
findById(id: UserId): Promise<User | null>;
findByEmail(email: Email): Promise<User | null>;
exists(email: Email): Promise<boolean>;
delete(id: UserId): Promise<void>;
}
リポジトリ実装
// infrastructure/repositories/prisma-order-repository.ts
import { PrismaClient } from '@prisma/client';
import { OrderRepository } from '../../domain/repositories/order-repository';
import { Order } from '../../domain/aggregates/order/order';
import { OrderId } from '../../domain/aggregates/order/order-id';
import { OrderMapper } from '../mappers/order-mapper';
export class PrismaOrderRepository implements OrderRepository {
constructor(private readonly prisma: PrismaClient) {}
async save(order: Order): Promise<void> {
const data = OrderMapper.toPersistence(order);
await this.prisma.order.upsert({
where: { id: data.id },
create: {
id: data.id,
userId: data.userId,
status: data.status,
shippingAddress: data.shippingAddress,
createdAt: data.createdAt,
updatedAt: data.updatedAt,
items: {
create: data.items.map(item => ({
productId: item.productId,
quantity: item.quantity,
unitPrice: item.unitPrice,
})),
},
},
update: {
status: data.status,
updatedAt: data.updatedAt,
items: {
deleteMany: {},
create: data.items.map(item => ({
productId: item.productId,
quantity: item.quantity,
unitPrice: item.unitPrice,
})),
},
},
});
}
async findById(id: OrderId): Promise<Order | null> {
const data = await this.prisma.order.findUnique({
where: { id: id.getValue() },
include: { items: true },
});
if (!data) return null;
return OrderMapper.toDomain(data);
}
async findByUserId(userId: UserId): Promise<Order[]> {
const orders = await this.prisma.order.findMany({
where: { userId: userId.getValue() },
include: { items: true },
orderBy: { createdAt: 'desc' },
});
return orders.map(OrderMapper.toDomain);
}
async delete(id: OrderId): Promise<void> {
await this.prisma.order.delete({
where: { id: id.getValue() },
});
}
nextId(): OrderId {
return OrderId.create();
}
}
ドメインサービス
// domain/services/order-domain-service.ts
export class OrderDomainService {
constructor(
private readonly orderRepository: OrderRepository,
private readonly productRepository: ProductRepository,
private readonly inventoryService: InventoryService
) {}
async canPlaceOrder(order: Order): Promise<boolean> {
// 在庫チェック
for (const item of order.getItems()) {
const available = await this.inventoryService.checkAvailability(
item.getProductId(),
item.getQuantity()
);
if (!available) {
return false;
}
}
return true;
}
async calculateShippingFee(order: Order): Promise<Money> {
const total = order.calculateTotal();
const address = order.getShippingAddress();
// 送料無料の閾値
if (total.isGreaterThan(Money.create(10000, 'JPY'))) {
return Money.zero('JPY');
}
// 地域別送料
const baseFee = this.getBaseFeeByRegion(address.getPrefecture());
return baseFee;
}
private getBaseFeeByRegion(prefecture: string): Money {
const hokkaido = ['北海道'];
const okinawa = ['沖縄県'];
if (hokkaido.includes(prefecture)) {
return Money.create(1500, 'JPY');
}
if (okinawa.includes(prefecture)) {
return Money.create(2000, 'JPY');
}
return Money.create(800, 'JPY');
}
}
アプリケーションサービス
// application/services/order-application-service.ts
export class OrderApplicationService {
constructor(
private readonly orderRepository: OrderRepository,
private readonly userRepository: UserRepository,
private readonly orderDomainService: OrderDomainService,
private readonly eventPublisher: DomainEventPublisher,
private readonly unitOfWork: UnitOfWork
) {}
async createOrder(command: CreateOrderCommand): Promise<OrderDto> {
return this.unitOfWork.execute(async () => {
// ユーザー存在確認
const user = await this.userRepository.findById(
UserId.create(command.userId)
);
if (!user) {
throw new UserNotFoundError(command.userId);
}
// 注文作成
const order = Order.create(
user.id,
Address.create(command.shippingAddress)
);
// 商品追加
for (const item of command.items) {
order.addItem(
ProductId.create(item.productId),
item.quantity,
Money.create(item.unitPrice, 'JPY')
);
}
// 注文確定可能かチェック
const canPlace = await this.orderDomainService.canPlaceOrder(order);
if (!canPlace) {
throw new InsufficientInventoryError();
}
// 注文確定
order.place();
// 保存
await this.orderRepository.save(order);
// ドメインイベント発行
await this.eventPublisher.publishAll(order.pullDomainEvents());
return OrderDto.fromDomain(order);
});
}
async getOrder(orderId: string): Promise<OrderDto | null> {
const order = await this.orderRepository.findById(
OrderId.create(orderId)
);
if (!order) return null;
return OrderDto.fromDomain(order);
}
async cancelOrder(orderId: string): Promise<void> {
return this.unitOfWork.execute(async () => {
const order = await this.orderRepository.findById(
OrderId.create(orderId)
);
if (!order) {
throw new OrderNotFoundError(orderId);
}
order.cancel();
await this.orderRepository.save(order);
await this.eventPublisher.publishAll(order.pullDomainEvents());
});
}
}
まとめ
DDDは、複雑なビジネスロジックを扱うプロジェクトで効果を発揮します。
戦術的パターンの選択
| パターン | 用途 |
|---|---|
| 値オブジェクト | 不変の値、ルールのカプセル化 |
| エンティティ | 識別子を持つオブジェクト |
| 集約 | 一貫性の境界、トランザクション単位 |
| リポジトリ | 永続化の抽象化 |
| ドメインサービス | エンティティに属さないロジック |
導入のポイント
- ユビキタス言語: ドメインエキスパートと共通言語を作る
- 境界づけられたコンテキスト: 適切な粒度でシステムを分割
- 段階的導入: コアドメインから始める
- テスト: ドメインロジックのユニットテスト
DDDは学習コストが高いですが、適切に適用することで保守性の高いシステムを構築できます。