architecture

クリーンアーキテクチャ入門 - 依存関係逆転とレイヤー設計

2025.12.02

クリーンアーキテクチャとは

クリーンアーキテクチャは、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
InfrastructureDB、外部サービス実装Domain, Application
Presentationコントローラー、ルーティングApplication

参考リンク

← 一覧に戻る