Skip to content

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

日本語
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.
원문 렌더가 준비되기 전까지 텍스트 가이드로 표시합니다.

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;

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;

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;

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;

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;

@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;

@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;

@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名以下)

**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、小規模プロジェクトなどビジネスロジックが単純な場合、オーバーエンジニアリングになります。

クイズ

Q1: 「Clean Architecture + DDD 実践適用ガイド: Spring

Bootで実装するドメイン中心設計」の主なトピックは何ですか?

Clean ArchitectureとDomain-Driven DesignをSpring

Bootプロジェクトに実践的に適用する方法を解説します。レイヤー分離、ドメインモデリング、ポート/アダプターパターンまでコードとともに実装します。

Robert C. Martin(Uncle Bob)が提唱したClean

Architectureは、ビジネスロジックを外部依存性から保護するアーキテクチャパターンです。核心原則は依存性ルール(Dependency

Rule):内側のレイヤーは外側のレイヤーを知りません。 DDDとの関係

2つのアプローチは相互補完的です。Clean

Architectureがどのように構造化するかを教え、DDDが何をモデリングするかを教えてくれます。

Entity: Order Value Object: Money Domain Event

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

Web Adapter(Controller) Persistence Adapter

현재 단락 (1/397)

Robert C. Martin(Uncle Bob)が提唱したClean Architectureは、**ビジネスロジックを外部依存性から保護する**アーキテクチャパターンです。核心原則は**依存性...

작성 글자: 0원문 글자: 11,455작성 단락: 0/397