クリーンアーキテクチャとは
クリーンアーキテクチャは、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
レイヤー構成
ディレクトリ構造
src/
├── domain/ # エンタープライズビジネスルール
│ ├── entities/ # エンティティ
│ │ ├── User.ts
│ │ ├── Order.ts
│ │ └── Product.ts
│ ├── value-objects/ # 値オブジェクト
│ │ ├── Email.ts
│ │ ├── Money.ts
│ │ └── Address.ts
│ ├── repositories/ # リポジトリインターフェース
│ │ ├── IUserRepository.ts
│ │ └── IOrderRepository.ts
│ ├── services/ # ドメインサービス
│ │ └── PricingService.ts
│ └── errors/ # ドメインエラー
│ └── DomainError.ts
├── application/ # アプリケーションビジネスルール
│ ├── use-cases/ # ユースケース
│ │ ├── user/
│ │ │ ├── CreateUserUseCase.ts
│ │ │ └── GetUserByIdUseCase.ts
│ │ └── order/
│ │ ├── CreateOrderUseCase.ts
│ │ └── CancelOrderUseCase.ts
│ ├── dto/ # データ転送オブジェクト
│ │ ├── CreateUserDTO.ts
│ │ └── OrderResponseDTO.ts
│ └── services/ # アプリケーションサービス
│ └── NotificationService.ts
├── infrastructure/ # フレームワーク・ドライバー
│ ├── database/
│ │ ├── prisma/
│ │ │ └── PrismaUserRepository.ts
│ │ └── drizzle/
│ │ └── DrizzleOrderRepository.ts
│ ├── external/
│ │ ├── StripePaymentGateway.ts
│ │ └── SendGridEmailService.ts
│ └── config/
│ └── database.ts
└── presentation/ # インターフェースアダプター
├── http/
│ ├── controllers/
│ │ ├── UserController.ts
│ │ └── OrderController.ts
│ ├── middleware/
│ │ └── authMiddleware.ts
│ └── routes/
│ └── index.ts
└── graphql/
├── resolvers/
└── schema/
エンティティの実装
// 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();
}
}
値オブジェクトの実装
// 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 |