- Authors
- Name
Clean Architectureとは?
Robert C. Martin(Uncle Bob)が提唱したClean Architectureは、ビジネスロジックを外部依存性から保護するアーキテクチャパターンです。核心原則は依存性ルール(Dependency Rule):内側のレイヤーは外側のレイヤーを知りません。
[Frameworks & Drivers] → [Interface Adapters] → [Use Cases] → [Entities]
外部 内部(コア)
DDDとの関係
| 概念 | Clean Architecture | DDD |
|---|---|---|
| 核心 | 依存性方向の制御 | ドメインモデリング |
| 焦点 | アーキテクチャレイヤー | ビジネスドメイン |
| 結果 | テスト可能なコード | ビジネス整合性 |
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、小規模プロジェクトなどビジネスロジックが単純な場合、オーバーエンジニアリングになります。