Skip to content
Published on

Clean Architecture + DDD 実践適用ガイド: Spring Bootで実装するドメイン中心設計

Authors
  • Name
    Twitter

Clean Architectureとは?

Robert C. Martin(Uncle Bob)が提唱したClean Architectureは、ビジネスロジックを外部依存性から保護するアーキテクチャパターンです。核心原則は依存性ルール(Dependency Rule):内側のレイヤーは外側のレイヤーを知りません。

[Frameworks & Drivers][Interface Adapters][Use Cases][Entities]
       外部                                                    内部(コア)

DDDとの関係

概念Clean ArchitectureDDD
核心依存性方向の制御ドメインモデリング
焦点アーキテクチャレイヤービジネスドメイン
結果テスト可能なコードビジネス整合性

2つのアプローチは相互補完的です。Clean Architectureがどのように構造化するかを教え、DDDが何をモデリングするかを教えてくれます。

プロジェクト構造

パッケージレイアウト

com.example.order/
├── domain/                     # エンティティ、バリューオブジェクト、ドメインサービス
│   ├── model/
│   │   ├── Order.java
│   │   ├── OrderItem.java
│   │   ├── OrderStatus.java    # Enum
│   │   └── Money.java          # Value Object
│   ├── event/
│   │   └── OrderPlacedEvent.java
│   ├── exception/
│   │   └── InsufficientStockException.java
│   └── repository/
│       └── OrderRepository.java  # Interface (Port)
├── application/                # ユースケース、アプリケーションサービス
│   ├── port/
│   │   ├── in/
│   │   │   ├── PlaceOrderUseCase.java
│   │   │   └── PlaceOrderCommand.java
│   │   └── out/
│   │       ├── LoadProductPort.java
│   │       ├── SaveOrderPort.java
│   │       └── SendNotificationPort.java
│   └── service/
│       └── PlaceOrderService.java
├── adapter/                    # アダプター(インフラストラクチャ)
│   ├── in/
│   │   └── web/
│   │       ├── OrderController.java
│   │       ├── OrderRequest.java
│   │       └── OrderResponse.java
│   └── out/
│       ├── persistence/
│       │   ├── OrderJpaEntity.java
│       │   ├── OrderJpaRepository.java
│       │   └── OrderPersistenceAdapter.java
│       ├── messaging/
│       │   └── KafkaNotificationAdapter.java
│       └── external/
│           └── ProductServiceAdapter.java
└── config/
    └── BeanConfig.java

ドメインレイヤーの実装

Entity: Order

// domain/model/Order.java
package com.example.order.domain.model;

import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.UUID;

public class Order {
    private final String id;
    private final String customerId;
    private final List<OrderItem> items;
    private OrderStatus status;
    private Money totalAmount;
    private final LocalDateTime createdAt;
    private LocalDateTime updatedAt;

    // ファクトリメソッド — 生成ロジックをドメインにカプセル化
    public static Order create(String customerId, List<OrderItem> items) {
        if (items == null || items.isEmpty()) {
            throw new IllegalArgumentException("注文項目が空です");
        }

        Money total = items.stream()
            .map(OrderItem::getSubtotal)
            .reduce(Money.ZERO, Money::add);

        return new Order(
            UUID.randomUUID().toString(),
            customerId,
            new ArrayList<>(items),
            OrderStatus.PLACED,
            total,
            LocalDateTime.now(),
            LocalDateTime.now()
        );
    }

    // ビジネスロジックはエンティティの中に!
    public void confirm() {
        if (this.status != OrderStatus.PLACED) {
            throw new IllegalStateException(
                "PLACED状態からのみ確認できます。現在: " + this.status
            );
        }
        this.status = OrderStatus.CONFIRMED;
        this.updatedAt = LocalDateTime.now();
    }

    public void cancel(String reason) {
        if (this.status == OrderStatus.SHIPPED || this.status == OrderStatus.DELIVERED) {
            throw new IllegalStateException(
                "配送開始後はキャンセルできません"
            );
        }
        this.status = OrderStatus.CANCELLED;
        this.updatedAt = LocalDateTime.now();
    }

    public void addItem(OrderItem item) {
        if (this.status != OrderStatus.PLACED) {
            throw new IllegalStateException("確定前にのみ項目を追加できます");
        }
        this.items.add(item);
        this.totalAmount = this.totalAmount.add(item.getSubtotal());
        this.updatedAt = LocalDateTime.now();
    }

    public List<OrderItem> getItems() {
        return Collections.unmodifiableList(items);
    }

    // private constructor
    private Order(String id, String customerId, List<OrderItem> items,
                  OrderStatus status, Money totalAmount,
                  LocalDateTime createdAt, LocalDateTime updatedAt) {
        this.id = id;
        this.customerId = customerId;
        this.items = items;
        this.status = status;
        this.totalAmount = totalAmount;
        this.createdAt = createdAt;
        this.updatedAt = updatedAt;
    }

    // getters...
    public String getId() { return id; }
    public String getCustomerId() { return customerId; }
    public OrderStatus getStatus() { return status; }
    public Money getTotalAmount() { return totalAmount; }
    public LocalDateTime getCreatedAt() { return createdAt; }
}

Value Object: Money

// domain/model/Money.java
package com.example.order.domain.model;

import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.Objects;

public final class Money {
    public static final Money ZERO = new Money(BigDecimal.ZERO);

    private final BigDecimal amount;

    public Money(BigDecimal amount) {
        if (amount == null) throw new IllegalArgumentException("金額はnullにできません");
        this.amount = amount.setScale(2, RoundingMode.HALF_UP);
    }

    public static Money of(long amount) {
        return new Money(BigDecimal.valueOf(amount));
    }

    public static Money of(String amount) {
        return new Money(new BigDecimal(amount));
    }

    public Money add(Money other) {
        return new Money(this.amount.add(other.amount));
    }

    public Money multiply(int quantity) {
        return new Money(this.amount.multiply(BigDecimal.valueOf(quantity)));
    }

    public boolean isGreaterThan(Money other) {
        return this.amount.compareTo(other.amount) > 0;
    }

    public BigDecimal getAmount() { return amount; }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Money money = (Money) o;
        return amount.compareTo(money.amount) == 0;
    }

    @Override
    public int hashCode() {
        return Objects.hash(amount);
    }

    @Override
    public String toString() {
        return amount.toPlainString() + "円";
    }
}

Domain Event

// domain/event/OrderPlacedEvent.java
package com.example.order.domain.event;

import java.time.LocalDateTime;

public record OrderPlacedEvent(
    String orderId,
    String customerId,
    String totalAmount,
    LocalDateTime occurredAt
) {
    public static OrderPlacedEvent from(String orderId, String customerId, String totalAmount) {
        return new OrderPlacedEvent(orderId, customerId, totalAmount, LocalDateTime.now());
    }
}

アプリケーションレイヤー:ユースケース

Port定義(インターフェース)

// application/port/in/PlaceOrderUseCase.java
package com.example.order.application.port.in;

public interface PlaceOrderUseCase {
    String execute(PlaceOrderCommand command);
}

// application/port/in/PlaceOrderCommand.java
public record PlaceOrderCommand(
    String customerId,
    List<OrderItemCommand> items
) {
    public record OrderItemCommand(
        String productId,
        int quantity
    ) {}
}
// application/port/out/SaveOrderPort.java
package com.example.order.application.port.out;

import com.example.order.domain.model.Order;

public interface SaveOrderPort {
    void save(Order order);
}

// application/port/out/LoadProductPort.java
public interface LoadProductPort {
    ProductInfo load(String productId);

    record ProductInfo(String id, String name, Money price, int stock) {}
}

Service実装

// application/service/PlaceOrderService.java
package com.example.order.application.service;

import com.example.order.application.port.in.*;
import com.example.order.application.port.out.*;
import com.example.order.domain.model.*;
import com.example.order.domain.exception.InsufficientStockException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@Transactional
public class PlaceOrderService implements PlaceOrderUseCase {

    private final SaveOrderPort saveOrderPort;
    private final LoadProductPort loadProductPort;
    private final SendNotificationPort sendNotificationPort;

    public PlaceOrderService(
        SaveOrderPort saveOrderPort,
        LoadProductPort loadProductPort,
        SendNotificationPort sendNotificationPort
    ) {
        this.saveOrderPort = saveOrderPort;
        this.loadProductPort = loadProductPort;
        this.sendNotificationPort = sendNotificationPort;
    }

    @Override
    public String execute(PlaceOrderCommand command) {
        // 1. 商品情報取得と在庫確認
        List<OrderItem> orderItems = command.items().stream()
            .map(item -> {
                var product = loadProductPort.load(item.productId());
                if (product.stock() < item.quantity()) {
                    throw new InsufficientStockException(
                        product.name() + " 在庫不足: 残り" + product.stock() + "個"
                    );
                }
                return new OrderItem(
                    item.productId(),
                    product.name(),
                    product.price(),
                    item.quantity()
                );
            })
            .toList();

        // 2. 注文作成(ドメインロジック)
        Order order = Order.create(command.customerId(), orderItems);

        // 3. 保存
        saveOrderPort.save(order);

        // 4. 通知送信
        sendNotificationPort.sendOrderConfirmation(
            command.customerId(), order.getId()
        );

        return order.getId();
    }
}

アダプターレイヤー:外部連携

Web Adapter(Controller)

// adapter/in/web/OrderController.java
package com.example.order.adapter.in.web;

import com.example.order.application.port.in.*;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/api/v1/orders")
public class OrderController {

    private final PlaceOrderUseCase placeOrderUseCase;

    public OrderController(PlaceOrderUseCase placeOrderUseCase) {
        this.placeOrderUseCase = placeOrderUseCase;
    }

    @PostMapping
    public ResponseEntity<OrderResponse> placeOrder(@RequestBody OrderRequest request) {
        var command = new PlaceOrderCommand(
            request.customerId(),
            request.items().stream()
                .map(i -> new PlaceOrderCommand.OrderItemCommand(i.productId(), i.quantity()))
                .toList()
        );

        String orderId = placeOrderUseCase.execute(command);

        return ResponseEntity.ok(new OrderResponse(orderId, "注文が受け付けられました"));
    }
}

record OrderRequest(String customerId, List<OrderItemRequest> items) {
    record OrderItemRequest(String productId, int quantity) {}
}

record OrderResponse(String orderId, String message) {}

Persistence Adapter

// adapter/out/persistence/OrderPersistenceAdapter.java
package com.example.order.adapter.out.persistence;

import com.example.order.application.port.out.SaveOrderPort;
import com.example.order.domain.model.Order;
import org.springframework.stereotype.Component;

@Component
public class OrderPersistenceAdapter implements SaveOrderPort {

    private final OrderJpaRepository jpaRepository;

    public OrderPersistenceAdapter(OrderJpaRepository jpaRepository) {
        this.jpaRepository = jpaRepository;
    }

    @Override
    public void save(Order order) {
        OrderJpaEntity entity = OrderJpaEntity.fromDomain(order);
        jpaRepository.save(entity);
    }
}

// adapter/out/persistence/OrderJpaEntity.java
@Entity
@Table(name = "orders")
public class OrderJpaEntity {
    @Id
    private String id;

    @Column(name = "customer_id", nullable = false)
    private String customerId;

    @Column(nullable = false)
    @Enumerated(EnumType.STRING)
    private OrderStatus status;

    @Column(name = "total_amount", nullable = false)
    private BigDecimal totalAmount;

    @OneToMany(cascade = CascadeType.ALL, mappedBy = "order")
    private List<OrderItemJpaEntity> items;

    // ドメイン → JPA変換
    public static OrderJpaEntity fromDomain(Order order) {
        var entity = new OrderJpaEntity();
        entity.id = order.getId();
        entity.customerId = order.getCustomerId();
        entity.status = order.getStatus();
        entity.totalAmount = order.getTotalAmount().getAmount();
        entity.items = order.getItems().stream()
            .map(item -> OrderItemJpaEntity.fromDomain(item, entity))
            .toList();
        return entity;
    }

    // JPA → ドメイン変換
    public Order toDomain() {
        // Mapperでドメインオブジェクトを復元
        // ...
    }
}

テスト戦略

ドメイン単体テスト(外部依存性なし!)

@Test
void 注文作成_成功() {
    var items = List.of(
        new OrderItem("P001", "ノートPC", Money.of(1500000), 1),
        new OrderItem("P002", "マウス", Money.of(50000), 2)
    );

    Order order = Order.create("C001", items);

    assertThat(order.getStatus()).isEqualTo(OrderStatus.PLACED);
    assertThat(order.getTotalAmount()).isEqualTo(Money.of(1600000));
    assertThat(order.getItems()).hasSize(2);
}

@Test
void 配送後_キャンセル不可() {
    Order order = createShippedOrder();

    assertThatThrownBy(() -> order.cancel("気が変わった"))
        .isInstanceOf(IllegalStateException.class)
        .hasMessageContaining("配送開始後はキャンセルできません");
}

ユースケーステスト(モッキング)

@ExtendWith(MockitoExtension.class)
class PlaceOrderServiceTest {

    @Mock SaveOrderPort saveOrderPort;
    @Mock LoadProductPort loadProductPort;
    @Mock SendNotificationPort sendNotificationPort;
    @InjectMocks PlaceOrderService service;

    @Test
    void 正常な注文処理() {
        given(loadProductPort.load("P001"))
            .willReturn(new LoadProductPort.ProductInfo(
                "P001", "ノートPC", Money.of(1500000), 10
            ));

        var command = new PlaceOrderCommand("C001",
            List.of(new PlaceOrderCommand.OrderItemCommand("P001", 1))
        );

        String orderId = service.execute(command);

        assertThat(orderId).isNotNull();
        then(saveOrderPort).should().save(any(Order.class));
        then(sendNotificationPort).should().sendOrderConfirmation(eq("C001"), any());
    }
}

メリット・デメリットと適用基準

適している場合

  • ビジネスロジックが複雑なドメイン
  • 長期的にメンテナンスするプロジェクト
  • 複数の外部システムとの連携が必要な場合
  • テストカバレッジが重要なプロジェクト

過度な場合

  • シンプルなCRUDアプリケーション
  • プロトタイプ / PoC
  • 小規模プロジェクト(2〜3名以下)

確認クイズ(6問)

Q1. Clean Architectureの依存性ルール(Dependency Rule)とは?

内側のレイヤーは外側のレイヤーを知りません。依存性は常に外側から内側に向かいます。

Q2. PortとAdapterの役割の違いは?

Portはインターフェース(契約)であり、Adapterはそのインターフェースの具体的な実装です。Portはアプリケーションレイヤーに、Adapterはインフラストラクチャレイヤーに位置します。

Q3. Value Objectの特徴は?

不変(immutable)、同等性は値で比較(equals)、ビジネスルールをカプセル化。例:Money、Address、Email

Q4. ドメインレイヤーにSpringアノテーションを使うべきでない理由は?

ドメインレイヤーはフレームワークから独立している必要があります。Springに依存するとドメインロジックがインフラに結合します。

Q5. JPA EntityとDomain Entityを分離する理由は?

JPA EntityはORMフレームワークの制約を受けますが、Domain Entityは純粋なビジネスロジックのみを含むべきです。分離することで、ドメインが永続化技術から独立します。

Q6. Clean Architecture + DDDが過度な場合は?

シンプルなCRUD、プロトタイプ/PoC、小規模プロジェクトなどビジネスロジックが単純な場合、オーバーエンジニアリングになります。