この記事の要点
• クリーンアーキテクチャはビジネスロジックを外部詳細から分離する設計原則
• 依存関係逆転の原則(DIP)が核心で、依存は常に外側から内側へ向く
• 4つのレイヤー: Domain → Application → Infrastructure → Presentation
クリーンアーキテクチャとは
クリーンアーキテクチャは、Robert C. Martin(Uncle Bob)が提唱したソフトウェア設計原則です。ビジネスロジックを外部の詳細(フレームワーク、データベース、UI)から分離し、テスト可能で保守性の高いシステムを構築します。
flowchart TB
subgraph Outer["Frameworks & Drivers<br/>(Web, DB, External Interfaces, UI)"]
subgraph Adapters["Interface Adapters<br/>(Controllers, Gateways, Presenters)"]
subgraph Application["Application Business<br/>(Use Cases)"]
subgraph Enterprise["Enterprise Business"]
Entities["Entities<br/>(ビジネスルール)"]
end
end
end
end
Note["依存関係のルール: 外側 → 内側(内側は外側を知らない)"]
依存関係逆転の原則(DIP)
ポイント: クリーンアーキテクチャの核心は、依存関係逆転の原則です。高レベルモジュールが低レベルモジュールに依存するのではなく、両方が抽象(インターフェース)に依存します。
flowchart TB
subgraph Before["従来の依存関係"]
C1["Controller"] --> S1["Service"] --> R1["Repository<br/>(実装)"]
Note1["高レベルが低レベルに依存"]
end
subgraph After["依存関係逆転後"]
C2["Controller"] --> UC["UseCase"]
RI["Repository<br/>Interface"] --> UC
RI --> Impl["Repository<br/>Impl"]
Note2["抽象に依存(実装の詳細を隠蔽)"]
end
レイヤー構成
ディレクトリ構成
Clean Architectureの典型的なディレクトリ構成は以下の4層に分割されます:
Domain層(エンタープライズビジネスルール)
entities/: User.ts, Order.ts, Product.tsvalue-objects/: Email.ts, Money.ts, Address.tsrepositories/: リポジトリインターフェースservices/: ドメインサービス
Application層(アプリケーションビジネスルール)
use-cases/: CreateUserUseCase.ts, CreateOrderUseCase.ts等dto/: データ転送オブジェクトservices/: NotificationService等
Infrastructure層(フレームワーク・ドライバー)
database/: PrismaUserRepository.ts等の永続化実装external/: StripePaymentGateway.ts等の外部サービス連携
Presentation層(インターフェースアダプター)
http/controllers/: UserController.ts, OrderController.tshttp/middleware/: authMiddleware.tsgraphql/resolvers/: GraphQLリゾルバ
エンティティの実装
// domain/entities/User.ts
import { Email } from '../value-objects/Email';
import { UserId } from '../value-objects/UserId';
import { DomainError } from '../errors/DomainError';
export interface UserProps {
id: UserId;
email: Email;
name: string;
createdAt: Date;
updatedAt: Date;
}
export class User {
private constructor(private readonly props: UserProps) {}
// ファクトリメソッド
static create(input: { email: string; name: string }): User {
const email = Email.create(input.email);
const id = UserId.generate();
const now = new Date();
return new User({
id,
email,
name: input.name,
createdAt: now,
updatedAt: now,
});
}
// 再構築用(DBからの読み込み)
static reconstruct(props: UserProps): User {
return new User(props);
}
// ゲッター
get id(): UserId {
return this.props.id;
}
get email(): Email {
return this.props.email;
}
get name(): string {
return this.props.name;
}
// ビジネスロジック
changeName(newName: string): void {
if (newName.length < 2 || newName.length > 100) {
throw new DomainError('Name must be between 2 and 100 characters');
}
this.props.name = newName;
this.props.updatedAt = new Date();
}
changeEmail(newEmail: string): void {
this.props.email = Email.create(newEmail);
this.props.updatedAt = new Date();
}
}
実践メモ: 値オブジェクトはイミュータブルで、等値性は値で判定します。Email、Money、Addressなどドメインの概念を型で表現することでバグを防げます。
値オブジェクトの実装
// domain/value-objects/Email.ts
import { DomainError } from '../errors/DomainError';
export class Email {
private constructor(private readonly value: string) {}
static create(value: string): Email {
if (!this.isValid(value)) {
throw new DomainError(`Invalid email format: ${value}`);
}
return new Email(value.toLowerCase());
}
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;
}
toString(): string {
return this.value;
}
}
// domain/value-objects/Money.ts
import { DomainError } from '../errors/DomainError';
export class Money {
private constructor(
private readonly amount: number,
private readonly currency: string
) {}
static create(amount: number, currency: string = 'JPY'): Money {
if (amount < 0) {
throw new DomainError('Amount cannot be negative');
}
return new Money(amount, currency);
}
getAmount(): number {
return this.amount;
}
getCurrency(): string {
return this.currency;
}
add(other: Money): Money {
this.ensureSameCurrency(other);
return Money.create(this.amount + other.amount, this.currency);
}
subtract(other: Money): Money {
this.ensureSameCurrency(other);
const result = this.amount - other.amount;
if (result < 0) {
throw new DomainError('Insufficient funds');
}
return Money.create(result, this.currency);
}
multiply(factor: number): Money {
return Money.create(Math.round(this.amount * factor), this.currency);
}
private ensureSameCurrency(other: Money): void {
if (this.currency !== other.currency) {
throw new DomainError('Currency mismatch');
}
}
equals(other: Money): boolean {
return this.amount === other.amount && this.currency === other.currency;
}
}
リポジトリインターフェース
// domain/repositories/IUserRepository.ts
import { User } from '../entities/User';
import { UserId } from '../value-objects/UserId';
import { Email } from '../value-objects/Email';
export interface IUserRepository {
save(user: User): Promise<void>;
findById(id: UserId): Promise<User | null>;
findByEmail(email: Email): Promise<User | null>;
findAll(options?: { limit?: number; offset?: number }): Promise<User[]>;
delete(id: UserId): Promise<void>;
exists(id: UserId): Promise<boolean>;
}
// domain/repositories/IOrderRepository.ts
import { Order } from '../entities/Order';
import { OrderId } from '../value-objects/OrderId';
import { UserId } from '../value-objects/UserId';
export interface IOrderRepository {
save(order: Order): Promise<void>;
findById(id: OrderId): Promise<Order | null>;
findByUserId(userId: UserId): Promise<Order[]>;
findPending(): Promise<Order[]>;
}
ユースケースの実装
// application/use-cases/user/CreateUserUseCase.ts
import { User } from '../../../domain/entities/User';
import { IUserRepository } from '../../../domain/repositories/IUserRepository';
import { Email } from '../../../domain/value-objects/Email';
import { ApplicationError } from '../../errors/ApplicationError';
export interface CreateUserInput {
email: string;
name: string;
}
export interface CreateUserOutput {
id: string;
email: string;
name: string;
createdAt: Date;
}
export class CreateUserUseCase {
constructor(private readonly userRepository: IUserRepository) {}
async execute(input: CreateUserInput): Promise<CreateUserOutput> {
// 重複チェック
const email = Email.create(input.email);
const existingUser = await this.userRepository.findByEmail(email);
if (existingUser) {
throw new ApplicationError('User with this email already exists');
}
// エンティティ作成
const user = User.create({
email: input.email,
name: input.name,
});
// 永続化
await this.userRepository.save(user);
// レスポンス変換
return {
id: user.id.getValue(),
email: user.email.getValue(),
name: user.name,
createdAt: user.createdAt,
};
}
}
// application/use-cases/order/CreateOrderUseCase.ts
import { Order, OrderItem } from '../../../domain/entities/Order';
import { IOrderRepository } from '../../../domain/repositories/IOrderRepository';
import { IUserRepository } from '../../../domain/repositories/IUserRepository';
import { IProductRepository } from '../../../domain/repositories/IProductRepository';
import { IPaymentGateway } from '../../ports/IPaymentGateway';
import { INotificationService } from '../../ports/INotificationService';
import { UserId } from '../../../domain/value-objects/UserId';
import { ApplicationError } from '../../errors/ApplicationError';
export interface CreateOrderInput {
userId: string;
items: Array<{
productId: string;
quantity: number;
}>;
}
export class CreateOrderUseCase {
constructor(
private readonly orderRepository: IOrderRepository,
private readonly userRepository: IUserRepository,
private readonly productRepository: IProductRepository,
private readonly paymentGateway: IPaymentGateway,
private readonly notificationService: INotificationService
) {}
async execute(input: CreateOrderInput): Promise<CreateOrderOutput> {
// ユーザー存在確認
const userId = UserId.create(input.userId);
const user = await this.userRepository.findById(userId);
if (!user) {
throw new ApplicationError('User not found');
}
// 商品情報取得
const orderItems: OrderItem[] = [];
for (const item of input.items) {
const product = await this.productRepository.findById(item.productId);
if (!product) {
throw new ApplicationError(`Product not found: ${item.productId}`);
}
if (product.stock < item.quantity) {
throw new ApplicationError(`Insufficient stock: ${product.name}`);
}
orderItems.push(OrderItem.create(product, item.quantity));
}
// 注文作成
const order = Order.create({
userId,
items: orderItems,
});
// 決済処理
const paymentResult = await this.paymentGateway.charge({
amount: order.totalAmount,
userId: userId.getValue(),
});
if (!paymentResult.success) {
throw new ApplicationError('Payment failed');
}
order.markAsPaid(paymentResult.transactionId);
// 永続化
await this.orderRepository.save(order);
// 通知
await this.notificationService.sendOrderConfirmation(user.email, order);
return this.toOutput(order);
}
}
インフラストラクチャ層の実装
// infrastructure/database/prisma/PrismaUserRepository.ts
import { PrismaClient } from '@prisma/client';
import { IUserRepository } from '../../../domain/repositories/IUserRepository';
import { User } from '../../../domain/entities/User';
import { UserId } from '../../../domain/value-objects/UserId';
import { Email } from '../../../domain/value-objects/Email';
export class PrismaUserRepository implements IUserRepository {
constructor(private readonly prisma: PrismaClient) {}
async save(user: User): Promise<void> {
await this.prisma.user.upsert({
where: { id: user.id.getValue() },
create: {
id: user.id.getValue(),
email: user.email.getValue(),
name: user.name,
createdAt: user.createdAt,
updatedAt: user.updatedAt,
},
update: {
email: user.email.getValue(),
name: user.name,
updatedAt: user.updatedAt,
},
});
}
async findById(id: UserId): Promise<User | null> {
const record = await this.prisma.user.findUnique({
where: { id: id.getValue() },
});
if (!record) return null;
return User.reconstruct({
id: UserId.create(record.id),
email: Email.create(record.email),
name: record.name,
createdAt: record.createdAt,
updatedAt: record.updatedAt,
});
}
async findByEmail(email: Email): Promise<User | null> {
const record = await this.prisma.user.findUnique({
where: { email: email.getValue() },
});
if (!record) return null;
return this.toDomain(record);
}
private toDomain(record: PrismaUser): User {
return User.reconstruct({
id: UserId.create(record.id),
email: Email.create(record.email),
name: record.name,
createdAt: record.createdAt,
updatedAt: record.updatedAt,
});
}
}
依存性注入の設定
// infrastructure/di/container.ts
import { PrismaClient } from '@prisma/client';
import { PrismaUserRepository } from '../database/prisma/PrismaUserRepository';
import { PrismaOrderRepository } from '../database/prisma/PrismaOrderRepository';
import { StripePaymentGateway } from '../external/StripePaymentGateway';
import { SendGridNotificationService } from '../external/SendGridNotificationService';
import { CreateUserUseCase } from '../../application/use-cases/user/CreateUserUseCase';
import { CreateOrderUseCase } from '../../application/use-cases/order/CreateOrderUseCase';
// シンプルなDIコンテナ
export class Container {
private static instance: Container;
private readonly prisma: PrismaClient;
private constructor() {
this.prisma = new PrismaClient();
}
static getInstance(): Container {
if (!Container.instance) {
Container.instance = new Container();
}
return Container.instance;
}
// リポジトリ
getUserRepository(): IUserRepository {
return new PrismaUserRepository(this.prisma);
}
getOrderRepository(): IOrderRepository {
return new PrismaOrderRepository(this.prisma);
}
// 外部サービス
getPaymentGateway(): IPaymentGateway {
return new StripePaymentGateway(process.env.STRIPE_SECRET_KEY!);
}
getNotificationService(): INotificationService {
return new SendGridNotificationService(process.env.SENDGRID_API_KEY!);
}
// ユースケース
getCreateUserUseCase(): CreateUserUseCase {
return new CreateUserUseCase(this.getUserRepository());
}
getCreateOrderUseCase(): CreateOrderUseCase {
return new CreateOrderUseCase(
this.getOrderRepository(),
this.getUserRepository(),
this.getProductRepository(),
this.getPaymentGateway(),
this.getNotificationService()
);
}
}
注意: クリーンアーキテクチャは万能ではありません。小規模プロジェクトではレイヤー分離のオーバーヘッドがメリットを上回る場合があります。プロジェクトの規模と複雑さに応じて適用しましょう。
テストの容易性
// __tests__/application/CreateUserUseCase.test.ts
import { CreateUserUseCase } from '../../src/application/use-cases/user/CreateUserUseCase';
import { InMemoryUserRepository } from '../__mocks__/InMemoryUserRepository';
describe('CreateUserUseCase', () => {
let useCase: CreateUserUseCase;
let userRepository: InMemoryUserRepository;
beforeEach(() => {
userRepository = new InMemoryUserRepository();
useCase = new CreateUserUseCase(userRepository);
});
it('should create a new user', async () => {
const input = {
email: 'test@example.com',
name: 'Test User',
};
const result = await useCase.execute(input);
expect(result.email).toBe(input.email);
expect(result.name).toBe(input.name);
expect(result.id).toBeDefined();
});
it('should throw error for duplicate email', async () => {
const input = {
email: 'test@example.com',
name: 'Test User',
};
await useCase.execute(input);
await expect(useCase.execute(input)).rejects.toThrow(
'User with this email already exists'
);
});
});
まとめ
| レイヤー | 責務 | 依存方向 |
|---|---|---|
| Domain | ビジネスルール、エンティティ | 依存なし |
| Application | ユースケース、アプリケーションロジック | Domain |
| Infrastructure | DB、外部サービス実装 | Domain, Application |
| Presentation | コントローラー、ルーティング | Application |
参考リンク
参考リソース
- Robert C. Martin - The Clean Architecture
- Alistair Cockburn - Hexagonal Architecture
- Martin Fowler - PresentationDomainDataLayering
- Microsoft - Common web application architectures