clean-architecture について
Java 21+ と Spring Boot 3.5+ のアプリケーションで、クリーンアーキテクチャ、ヘキサゴナルアーキテクチャ、DDD の戦術パターンを適用するためのスキルです。フレームワークに依存しないドメインロジック、内向きの依存関係、テスト可能な層を重視します。
主な機能
- 014 層構成と依存ルールの指針
- 02フレームワーク非依存のドメイン設計
- 03ポートとアダプターの設計
- 04DDD のエンティティ、値オブジェクト、イベント
- 05層別テストとアーキテクチャ準拠確認
活用シーン
- 01新しい Spring Boot アプリの構成設計
- 02密結合コードのリファクタリング
- 03ドメインロジックとフレームワークの分離
- 04Spring コンテキストなしでの業務ロジックのテスト
インストール
コマンドをコピーしてターミナルで実行します。
$
npx skills add https:/ / github.com/ giuseppe-trisciuoglio/ developer-kit --skill clean-architectureインストール後、Claude Code で/clean-architectureを入力して読み込まれたか確認します。
同梱ファイルreferences/java-clean-architecture.mdreferences/spring-boot-implementation.md
詳細
Spring Boot 向けのクリーンアーキテクチャ、ヘキサゴナルアーキテクチャ、DDD
概要
このスキルでは、Java 21 以降の Spring Boot 3.5 以降のアプリケーションにおいて、クリーンアーキテクチャ、ヘキサゴナルアーキテクチャ(ポートとアダプター)、およびドメイン駆動設計の戦術パターンを実装するための包括的なガイダンスを提供します。適切なレイヤー分割と依存関係管理を通じて、関心事を明確に分離し、フレームワークに依存しないドメインロジックと、テストしやすいコードベースを実現します。
使用する場面
- 関心事を明確に分離した新しい Spring Boot アプリケーションを設計するとき
- 密結合したコードを、テスト可能なレイヤー化アーキテクチャへリファクタリングするとき
- フレームワークやインフラストラクチャに依存しないドメインロジックを実装するとき
- 実装を差し替え可能にするポートとアダプターを設計するとき
- ドメイン駆動設計の戦術パターン(エンティティ、値オブジェクト、集約)を適用するとき
- Spring コンテキストに依存せずにテスト可能なビジネスロジックを作成するとき
手順
1. 基本概念を理解する
クリーンアーキテクチャのレイヤー(依存関係のルール)
依存関係は内側に向かいます。内側のレイヤーは外側のレイヤーについて何も知りません。
| レイヤー | 責務 | Spring Boot における相当要素 |
|---|---|---|
| ドメイン | エンティティ、値オブジェクト、ドメインイベント、リポジトリーインターフェース | domain/ - Spring アノテーションなし |
| アプリケーション | ユースケース、アプリケーションサービス、DTO、ポート | application/ - @Service、@Transactional |
| インフラストラクチャ | フレームワーク、データベース、外部 API | infrastructure/ - @Repository、@Entity |
| アダプター | コントローラー、プレゼンター、外部ゲートウェイ | adapter/ - @RestController |
ヘキサゴナルアーキテクチャ(ポートとアダプター)
- ドメインコア:フレームワークに依存しない純粋な Java のビジネスロジック
- ポート:契約を定義するインターフェース(駆動側および駆動される側)
- アダプター:具体的な実装(JPA、REST、メッセージング)
ドメイン駆動設計の戦術パターン
- エンティティ:識別子とライフサイクルを持つオブジェクト(例:
Order、Customer) - 値オブジェクト:不変で、属性によって定義されるオブジェクト(例:
Money、Email) - 集約:ルートエンティティを持つ整合性境界
- ドメインイベント:重要なビジネス上の出来事を記録します
- リポジトリー:永続化を抽象化し、インフラストラクチャで実装します
2. パッケージ構造を整理する
次の機能ベースのパッケージ構成に従います。
com.example.order/
├── domain/
│ ├── model/ # Entities, value objects
│ ├── event/ # Domain events
│ ├── repository/ # Repository interfaces (ports)
│ └── exception/ # Domain exceptions
├── application/
│ ├── port/in/ # Driving ports (use case interfaces)
│ ├── port/out/ # Driven ports (external service interfaces)
│ ├── service/ # Application services
│ └── dto/ # Request/response DTOs
├── infrastructure/
│ ├── persistence/ # JPA entities, repository adapters
│ └── external/ # External service adapters
└── adapter/
└── rest/ # REST controllers
3. ドメインレイヤーを実装する(フレームワーク非依存)
ドメインレイヤーは、Spring やその他のフレームワークに一切依存してはなりません。
- 組み込みのバリデーション機能を備えた、不変の値オブジェクトには Java レコードを使用します
- ビジネスロジックはサービスではなくエンティティに配置します(リッチドメインモデル)
- リポジトリーインターフェース(ポート)はドメインレイヤーで定義します
- ID の取り違えを防ぐため、型安全な ID を使用します
- 副作用を疎結合にするため、ドメインイベントを実装します
- 不変条件を保証するため、ファクトリーメソッドを使ってエンティティを作成します
4. アプリケーションレイヤーを実装する
application/port/in/にユースケースインターフェース(駆動ポート)を作成しますapplication/port/out/に外部サービスのインターフェース(駆動されるポート)を作成します@Serviceと@Transactionalを使用してアプリケーションサービスを実装します- ドメインモデルとは分けて、リクエスト/レスポンスには DTO を使用します
- 処理が正常に完了した後、ドメインイベントを発行します
5. インフラストラクチャレイヤーを実装する(アダプター)
infrastructure/persistence/に JPA エンティティを作成します- ドメインエンティティと JPA エンティティの間をマッピングするリポジトリーアダプターを実装します
- ドメインと JPA の変換には、MapStruct または手動のマッパーを使用します
- 差し替え可能な実装には、条件付き Bean を設定します
- インフラストラクチャに関する関心事をドメインロジックから分離します
6. アダプターレイヤーを実装する(REST)
adapter/rest/に REST コントローラーを作成します- 実装クラスではなく、ユースケースインターフェースを注入します
- DTO に Bean Validation を使用します
- 適切な HTTP ステータスコードとレスポンスを返します
- グローバル例外ハンドラーで例外を処理します
7. ベストプラクティスを適用する
- 依存関係のルール:ドメインは Spring やその他のフレームワークに一切依存しません
- 不変の値オブジェクト:値オブジェクトには、組み込みのバリデーションを備えた Java のレコードを使用します
- リッチドメインモデル:ビジネスロジックはサービスではなく、エンティティに配置します
- リポジトリパターン:ドメインでインターフェースを定義し、インフラストラクチャで実装します
- ドメインイベント:副作用を主要な操作から切り離します
- コンストラクターインジェクション:必須の依存関係は
finalフィールドを介して注入します - DTO マッピング:ドメインモデルと API コントラクトを分離します
- トランザクション境界:アプリケーションサービスに
@Transactional を配置します - ファクトリーメソッド:構築時に不変条件を強制するには
Entity.create()を使用します - JPA エンティティの分離:マッパーを使ってドメインエンティティと JPA エンティティを分離します
8. アーキテクチャの準拠を検証する
各レイヤーを実装した後、依存関係のルールが守られていることを確認します。
- ドメインレイヤーの確認:
grep -r "@Service\|@Component\|@Autowired" domain/を実行し、Spring のインポートがないことを確認します - ArchUnit テスト:ドメインレイヤーにインフラストラクチャのインポートがないことを検証する依存関係テストを追加します。
noClasses().that().resideInPackage("..domain..") .should().accessClassesThat().resideInAnyPackage("..spring..", "..infrastructure.."); - エンティティ公開の確認:JPA エンティティがドメインサービスから返されていないことを確認します
- トランザクションの確認:
@Transactional がアプリケーションレイヤーのサービスにのみ付与され、ドメインには付与されていないことを確認します
9. テストを書く
- ドメインテスト:Spring コンテキストを使わない純粋なユニットテストで、実行が高速です
- アプリケーションテスト:Mockito を使用してポートをモック化するユニットテストです
- インフラストラクチャテスト:
@DataJpaTest と Testcontainers を使用する統合テストです - アダプターテスト:
@WebMvcTest を使用するコントローラーテストです
例
例 1:ドメインレイヤー - ドメインイベントを持つエンティティ
// domain/model/Order.java
public class Order {
private final OrderId id;
private final List<OrderItem> items;
private Money total;
private OrderStatus status;
private final List<DomainEvent> domainEvents = new ArrayList<>();
private Order(OrderId id, List<OrderItem> items) {
this.id = id;
this.items = new ArrayList<>(items);
this.status = OrderStatus.PENDING;
calculateTotal();
}
public static Order create(List<OrderItem> items) {
validateItems(items);
Order order = new Order(OrderId.generate(), items);
order.domainEvents.add(new OrderCreatedEvent(order.id, order.total));
return order;
}
public void confirm() {
if (status != OrderStatus.PENDING) {
throw new DomainException("Only pending orders can be confirmed");
}
this.status = OrderStatus.CONFIRMED;
}
public List<DomainEvent> getDomainEvents() {
return List.copyOf(domainEvents);
}
public void clearDomainEvents() {
domainEvents.clear();
}
}
例 2:ドメインレイヤー - バリデーションを備えた値オブジェクト
// domain/model/Money.java (Value Object)
public record Money(BigDecimal amount, Currency currency) {
public Money {
if (amount.compareTo(BigDecimal.ZERO) < 0) {
throw new DomainException("Amount cannot be negative");
}
}
public static Money zero() {
return new Money(BigDecimal.ZERO, Currency.getInstance("EUR"));
}
public Money add(Money other) {
if (!this.currency.equals(other.currency)) {
throw new DomainException("Currency mismatch");
}
return new Money(this.amount.add(other.amount), this.currency);
}
}
例 3:ドメインレイヤー - リポジトリポート
// domain/repository/OrderRepository.java (Port)
public interface OrderRepository {
Order save(Order order);
Optional<Order> findById(OrderId id);
}
例 4:アプリケーションレイヤー - ユースケースとサービス
// application/port/in/CreateOrderUseCase.java
public interface CreateOrderUseCase {
OrderResponse createOrder(CreateOrderRequest request);
}
// application/dto/CreateOrderRequest.java
public record CreateOrderRequest(
@NotNull UUID customerId,
@NotEmpty List<OrderItemRequest> items
) {}
// application/service/OrderService.java
@Service
@RequiredArgsConstructor
@Transactional
public class OrderService implements CreateOrderUseCase {
private final OrderRepository orderRepository;
private final PaymentGateway paymentGateway;
private final DomainEventPublisher eventPublisher;
@Override
public OrderResponse createOrder(CreateOrderRequest request) {
List<OrderItem> items = mapItems(request.items());
Order order = Order.create(items);
PaymentResult payment = paymentGateway.charge(order.getTotal());
if (!payment.successful()) {
throw new PaymentFailedException("Payment failed");
}
order.confirm();
Order saved = orderRepository.save(order);
publishEvents(order);
return OrderMapper.toResponse(saved);
}
private void publishEvents(Order order) {
order.getDomainEvents().forEach(eventPublisher::publish);
order.clearDomainEvents();
}
}
例 5:インフラストラクチャレイヤー - JPA エンティティとアダプター
// infrastructure/persistence/OrderJpaEntity.java
@Entity
@Table(name = "orders")
public class OrderJpaEntity {
@Id
private UUID id;
@Enumerated(EnumType.STRING)
private OrderStatus status;
private BigDecimal totalAmount;
@OneToMany(cascade = CascadeType.ALL, orphanRemoval = true)
private List<OrderItemJpaEntity> items;
}
// infrastructure/persistence/OrderRepositoryAdapter.java
@Component
@RequiredArgsConstructor
public class OrderRepositoryAdapter implements OrderRepository {
private final OrderJpaRepository jpaRepository;
private final OrderJpaMapper mapper;
@Override
public Order save(Order order) {
OrderJpaEntity entity = mapper.toEntity(order);
return mapper.toDomain(jpaRepository.save(entity));
}
@Override
public Optional<Order> findById(OrderId id) {
return jpaRepository.findById(id.value()).map(mapper::toDomain);
}
}
例 6:アダプターレイヤー - REST コントローラー
// adapter/rest/OrderController.java
@RestController
@RequestMapping("/api/orders")
@RequiredArgsConstructor
public class OrderController {
private final CreateOrderUseCase createOrderUseCase;
@PostMapping
public ResponseEntity<OrderResponse> createOrder(
@Valid @RequestBody CreateOrderRequest request) {
OrderResponse response = createOrderUseCase.createOrder(request);
URI location = ServletUriComponentsBuilder
.fromCurrentRequest()
.path("/{id}")
.buildAndExpand(response.id())
.toUri();
return ResponseEntity.created(location).body(response);
}
}
例 7:ドメインテスト(Spring コンテキストなし)
class OrderTest {
@Test
void shouldCreateOrderWithValidItems() {
List<OrderItem> items = List.of(
new OrderItem(new ProductId(UUID.randomUUID()), 2, new Money("10.00", EUR))
);
Order order = Order.create(items);
assertThat(order.getStatus()).isEqualTo(OrderStatus.PENDING);
assertThat(order.getDomainEvents()).hasSize(1);
}
}
例 8:アプリケーションテスト(モックを使ったユニットテスト)
@ExtendWith(MockitoExtension.class)
class OrderServiceTest {
@Mock OrderRepository orderRepository;
@Mock PaymentGateway paymentGateway;
@Mock DomainEventPublisher eventPublisher;
@InjectMocks OrderService orderService;
@Test
void shouldCreateAndConfirmOrder() {
when(paymentGateway.charge(any())).thenReturn(new PaymentResult(true, "tx-123"));
when(orderRepository.save(any())).thenAnswer(i -> i.getArgument(0));
OrderResponse response = orderService.createOrder(createRequest());
assertThat(response.status()).isEqualTo(OrderStatus.CONFIRMED);
verify(eventPublisher).publish(any(OrderCreatedEvent.class));
}
}
ベストプラクティス
- ドメインの純粋性:ドメインレイヤーには Spring アノテーションやフレームワークのインポートを含めず、外側のレイヤーへの依存を一切なくします
- 機能ベースのパッケージ:技術的な役割ではなく、ビジネス機能(
order/、customer/)ごとに構成します。各機能には 4 つのレイヤーすべてを含めます - 不変の値オブジェクト:値オブジェクトには、コンパクトコンストラクター内の組み込みバリデーションを備えた Java のレコードを使用します。設計上、不変になります
- リッチドメインモデル:ビジネスロジックはアプリケーションサービスではなく、エンティティや集約に配置します。サービスは処理を調整し、エンティティはロジックをカプセル化します
- 必ずマッピングする:MapStruct または手動のマッパーを使用して、JPA エンティティをドメインモデルから分離します。JPA エンティティをインフラストラクチャの外部に公開してはいけません
- 疎結合のためのドメインイベント:サービスを直接呼び出すのではなく、
DomainEventPublisherを使用して集約をまたぐ副作用を切り離します - アプリケーションレイヤーのトランザクション境界:
@Transactionalはアプリケーションサービスにのみ配置し、ドメインクラスには配置しません - 不変条件のためのファクトリーメソッド:構築時に不変条件を強制するには、
Entity.create(...)静的メソッドを使用します - ArchUnit による強制:Spring やインフラストラクチャのインポートがドメインレイヤーに入り込まないことを検証する ArchUnit テストをテストスイートに追加します
- 型安全な ID:集約間での ID の混同を防ぐため、生の
UUIDではなくrecord OrderId(UUID value)を使用します
制約と警告
重大な制約
- ドメインレイヤーの純粋性:ドメインクラスに Spring アノテーション(
@Entity、@Autowired、@Component)を追加してはいけません - 依存関係の方向:依存関係は内側にのみ向けます(domain <- application <- infrastructure/adapter)
- フレームワークの分離:フレームワーク固有のコードはすべて、インフラストラクチャレイヤーとアダプターレイヤーに置きます
避けるべきよくある落とし穴
- 貧血ドメインモデル:ゲッター/セッターだけを持ち、ロジックがサービスにあるエンティティ。ビジネスロジックはエンティティに配置します
- フレームワークの漏出:ドメイン層にある
@Entityや@Autowired。ドメイン層はフレームワークに依存しない状態に保ちます - 遅延読み込みの問題:ドメインモデルを通じて JPA エンティティを公開すること。変換にはマッパーを使用します
- 循環依存:ドメイン集約間の循環依存。直接参照ではなく ID を使用します
- ドメインイベントの欠如:集約間の通信にイベントではなく、サービスを直接呼び出すこと
- リポジトリの配置ミス:インフラストラクチャ層にリポジトリインターフェースを定義すること。リポジトリインターフェースはドメイン層に配置します
- DTO の迂回:API でドメインエンティティを直接公開すること。外部との契約には必ず DTO を使用します
パフォーマンスに関する考慮事項
- 遅延読み込みの問題を避けるため、JPA エンティティとドメインモデルを分離します
- クエリ操作には読み取り専用トランザクションを使用します
- 読み取り/書き込みが複雑な場合は、CQRS を検討します
参考資料
references/java-clean-architecture.md- Java 固有のパターン(レコード、sealed クラス、強く型付けされた ID)references/spring-boot-implementation.md- Spring Boot との統合(DI パターン、JPA マッピング、トランザクション管理)
コメント
Markdown 対応