8
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

はじめに

fuelPHP から Spring Boot への API リプレイス開発の案件で、ビジネスの核となる API のリプレイスを担当しました。その際、今後の保守性向上のために Clean Architecture を一から学び、比較的規模の大きいリファクタリングを実践しました。

今回はその経験を通じて見えてきた、実務視点での Clean Architecture の本質についてまとめます。

対象読者

  • Spring Boot で REST API を構築したことのある方
  • Clean Architecture や DDD の事前知識は不要です(必要な用語は本文中で説明します)

私が考える Clean Architecture の 3 つの本質

Clean Architecture は Robert C. Martin(通称 Uncle Bob)が提唱したアーキテクチャパターンです。ただし、ここでは原著に忠実に語ることよりも、あくまで私個人が実務で適用する中で、何を大事だと思ったかについてお話ししようと思います。

私が Clean Architecture を導入するにあたって、重要だと感じたポイントは以下の3つです。

  1. 境界の定義:システムの責務を層に分割し、明確な境界を作る
  2. 関心事の分離:各層で「何をしたいか(What)」と「どう実現するか(How)」を分ける
  3. 依存方向の制御:業務ロジックを技術的な実装から守る

それぞれ詳しく見ていきましょう。

本質 1:境界の定義

Clean Architecture では、システムを以下の 4 つの層に分割します。

責務 Spring Boot での例
Domain 層 ビジネスルール・業務ロジックの中核 Entity、Value Object、Domain Service、Domain Repository(インターフェース)
Application 層 ユースケース(業務フロー)の実行 UseCase クラス
Presentation 層 外部との入出力(API そのもののインターフェース) Controller、Request/Response DTO
Infrastructure 層 技術的な実装(DB、外部 API) JpaEntity、Repository 実装、外部 API クライアント

用語の補足

  • Entity(エンティティ): 一意に識別されるオブジェクト(例:ユーザー、注文)
  • Value Object(値オブジェクト): 値そのものを表すオブジェクト(例:金額、住所)
  • Domain Service: Entity に属さない業務ロジック(例:複数の Entity をまたぐバリデーション)
  • Domain Repository(インターフェース): Domain 層が「自分に必要なデータ操作」を定義したインターフェース。実装は Infrastructure 層が担当
  • UseCase(ユースケース): 「ユーザーを登録する」「注文を確定する」といった業務フロー

なぜ境界が必要か?

Spring Boot では Controller → Service → Repository という構成が一般的です。しかし業務ロジックが複雑になると、Service が過剰な責務を負いがちです。

// ❌ Before: Service に責務が集中
@Service
public class OrderService {
    public Order createOrder(OrderRequest request) {
        // 1. バリデーション(本来は Domain の責務)
        if (request.getItems().isEmpty()) {
            throw new ValidationException("商品が選択されていません");
        }
        if (request.getTotalAmount() < 0) {
            throw new ValidationException("金額が不正です");
        }

        // 2. 在庫チェック(外部 API 呼び出しの詳細まで記述)
        for (Item item : request.getItems()) {
            HttpResponse res = httpClient.get("/inventory/" + item.getId());
            if (res.getBody().getStock() < item.getQuantity()) {
                throw new StockException("在庫不足");
            }
        }

        // 3. DB 保存(SQL 構築の詳細まで記述)
        String sql = "INSERT INTO orders ...";
        jdbcTemplate.update(sql, ...);

        return order;
    }
}

この構成の問題点:

  • ① ワークフローの管理、② バリデーション、③ 在庫チェック、④ DB 保存と、1 つのクラスが 4 つも責務を持っている
  • バリデーションや在庫チェックを他の UseCase と共通化できない
  • 実際に外部 API や DB と接続しないとテストできない
  • 1 つのクラスが肥大化すると、複数人で並列に開発しづらくなり、プロジェクトの遅延につながりやすい
// ✅ After: 責務を層で分離
// Application 層: UseCase
@Component
public class CreateOrderUseCase {
    private final OrderRepository orderRepository;
    private final InventoryChecker inventoryChecker;

    public Order execute(OrderCommand command) {
        // Domain 層に業務ルールを委譲
        Order order = Order.create(command.getItems(), command.getCustomerId());

        // 在庫チェック(詳細は Infrastructure 層に委譲)
        inventoryChecker.ensureAvailable(order.getItems());

        // 保存(詳細は Infrastructure 層に委譲)
        return orderRepository.save(order);
    }
}

// Domain 層: Entity
public class Order {
    public static Order create(List<Item> items, CustomerId customerId) {
        // バリデーションは Domain が責任を持つ
        if (items.isEmpty()) {
            throw new DomainException("商品が選択されていません");
        }
        // ...
    }
}

改善後の利点:

  • UseCase の責務が「ワークフローの管理」に限定されている
  • バリデーション、在庫チェック、DB 保存を別レイヤーに切り出したので、他の UseCase でも再利用できる
  • OrderRepositoryInventoryChecker をモック化してテストできる
  • 責務ごとにファイルが分かれているので、複数人で並列に開発しやすい

パッケージ構成例

com.example.order/
├── domain/           # Domain 層
│   ├── Order.java
│   ├── OrderItem.java
│   └── OrderRepository.java  # インターフェース
├── application/      # Application 層
│   └── CreateOrderUseCase.java
├── presentation/     # Presentation 層
│   ├── OrderController.java
│   └── OrderRequest.java
└── infrastructure/   # Infrastructure 層
    ├── OrderRepositoryImpl.java
    └── InventoryApiClient.java

本質 2:関心事の分離

何をしたいか(What)」と「どう実現するか(How)」を分けることが、関心事の分離です。

なぜ分離が重要なのでしょうか?

分離しないと起きる問題

  1. 技術の変更に弱くなる: DB を Oracle から NoSQL に変えたら業務ロジックにも修正が必要
  2. 外部仕様の変更に弱くなる: 連携先 API の仕様変更で UseCase を書き換える必要がある
  3. コードが読みにくくなる: 業務フローを理解するために SQL や HTTP の詳細も読む必要がある
  4. テストしにくくなる: 単体テストで DB や外部 API が必要になる

具体例

// ❌ Before: What と How が混在
@Component
public class GetCustomerUseCase {
    private final JdbcTemplate jdbcTemplate;
    private final RestTemplate restTemplate;

    public CustomerDto execute(Long customerId) {
        // DB アクセスの詳細が UseCase に露出
        String sql = "SELECT id, name, email, grade_id FROM customers WHERE id = ?";
        Map<String, Object> row = jdbcTemplate.queryForMap(sql, customerId);

        // 外部 API 呼び出しの詳細も UseCase に露出
        String gradeApiUrl = "https://external-api.com/grades/" + row.get("grade_id");
        HttpHeaders headers = new HttpHeaders();
        headers.set("Authorization", "Bearer " + apiKey);
        HttpEntity<String> entity = new HttpEntity<>(headers);
        ResponseEntity<GradeResponse> response = restTemplate.exchange(
            gradeApiUrl, HttpMethod.GET, entity, GradeResponse.class);

        return new CustomerDto(
            (Long) row.get("id"),
            (String) row.get("name"),
            response.getBody().getGradeName()
        );
    }
}

問題点:

  • CUSTOMERS テーブルが変更されたときに、「グレードを取得する」という無関係なロジックが影響を受ける(同じクラスの同じメソッドにある以上、デグレードにつながる危険性がある)
  • グレード取得 API のインターフェースが変更されたときに、「顧客情報を取得する」という無関係なロジックが影響を受ける(同上)
  • SQL を読み込まないと「何をしているか(顧客情報の取得、グレード情報の取得)」が分からない
  • 実際に外部 API や DB と接続しないとテストできない
// ✅ After: What と How を分離
// Application 層: What(何をしたいか)だけを記述
@Component
public class GetCustomerUseCase {
    private final CustomerRepository customerRepository;
    private final GradeService gradeService;

    public CustomerDto execute(CustomerId customerId) {
        // 「顧客を取得する」という意図が明確
        Customer customer = customerRepository.findById(customerId)
            .orElseThrow(() -> new CustomerNotFoundException(customerId));

        // 「グレード情報を取得する」という意図が明確
        Grade grade = gradeService.getGrade(customer.getGradeId());

        return CustomerDto.from(customer, grade);
    }
}

// Domain 層: インターフェース(What を定義)
public interface CustomerRepository {
    Optional<Customer> findById(CustomerId id);
    Customer save(Customer customer);
}

public interface GradeService {
    Grade getGrade(GradeId gradeId);
}

// Infrastructure 層: How(どう実現するか)を実装
@Repository
public class CustomerRepositoryImpl implements CustomerRepository {
    private final JpaCustomerRepository jpaRepository;

    @Override
    public Optional<Customer> findById(CustomerId id) {
        return jpaRepository.findById(id.getValue())
            .map(this::toDomain);
    }
    // ...
}

@Component
public class ExternalGradeService implements GradeService {
    private final RestTemplate restTemplate;

    @Override
    public Grade getGrade(GradeId gradeId) {
        // HTTP 呼び出しの詳細はここに隠蔽
        // ...
    }
}

改善後の利点:

  • CUSTOMERS テーブルやグレード取得 API の仕様が変更されても、UseCase は一切影響を受けない
  • SQL や API クライアントの実装(How)を気にせず、業務フロー(What)だけがひと目で分かる
  • CustomerRepositoryGradeService をモック化してテストできる

本質 3:依存方向の制御

業務ロジック(Domain 層)が技術的な実装(Infrastructure 層)に依存すると、インフラの変更が業務ロジックに波及してしまいます。これを防ぐのが「依存性の逆転(Dependency Inversion)」です。

依存性の逆転とは?

通常の依存関係:

Domain 層 → Infrastructure 層(Domain が Infra に依存)

逆転後の依存関係:

Domain 層 ← Infrastructure 層(Infra が Domain に依存)

どうやって逆転させるのか?

ポイントは「誰がインターフェースを定義するか」です。

  • ❌ 従来: Infrastructure 層がインターフェースを定義 → Domain 層がそれを使う
  • ✅ 逆転: Domain 層がインターフェースを定義 → Infrastructure 層がそれを実装

Domain 層は「自分が何をしたいか」をインターフェースで宣言するだけ。「どう実現するか」は Infrastructure 層に任せます。

なぜ Domain 層がインターフェースを定義できるのか?

Domain 層はビジネスルールを表現する層であり、技術よりも安定しています。「顧客を保存したい」「在庫を確認したい」という業務要件は、DB が MySQL でも PostgreSQL でも変わりません。だからこそ、Domain 層が「自分に必要な操作」を定義する権利を持つのです。

具体例

// ❌ Before: Domain 層が JPA に直接依存
// Domain 層
@Entity  // JPA アノテーション
@Table(name = "customers")  // テーブル名に依存
public class Customer {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(name = "customer_name")  // カラム名に依存
    private String name;

    @OneToMany(mappedBy = "customer", cascade = CascadeType.ALL)
    private List<Order> orders;  // JPA の関連に依存
}

// Application 層
@Service
public class CustomerService {
    private final JpaCustomerRepository jpaRepository;  // JPA に直接依存

    public Customer findById(Long id) {
        return jpaRepository.findById(id).orElseThrow();
    }
}

問題点:

  • Domain の Entity が JPA アノテーションだらけ
  • テーブル構造が変わると Entity の修正が必要
  • JPA から別の ORM や NoSQL に変更するのが困難
// ✅ After: 依存性を逆転
// Domain 層: 純粋なビジネスオブジェクト
public class Customer {
    private final CustomerId id;
    private final String name;
    private final List<Order> orders;

    // JPA アノテーションなし!ビジネスロジックに集中
    public void addOrder(Order order) {
        if (this.orders.size() >= 10) {
            throw new DomainException("注文上限に達しています");
        }
        this.orders.add(order);
    }
}

// Domain 層: 「自分が何をしたいか」をインターフェースで定義
public interface CustomerRepository {
    Optional<Customer> findById(CustomerId id);
    Customer save(Customer customer);
    List<Customer> findByGrade(GradeId gradeId);
}

// Infrastructure 層: JPA Entity(技術的な詳細)
@Entity
@Table(name = "customers")
public class CustomerJpaEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(name = "customer_name")
    private String name;
    // ...
}

// Infrastructure 層: Domain のインターフェースを実装
@Repository
public class CustomerRepositoryImpl implements CustomerRepository {
    private final JpaCustomerRepository jpaRepository;

    @Override
    public Optional<Customer> findById(CustomerId id) {
        return jpaRepository.findById(id.getValue())
            .map(this::toDomain);  // JPA Entity → Domain への変換
    }

    @Override
    public Customer save(Customer customer) {
        CustomerJpaEntity entity = toJpaEntity(customer);  // Domain → JPA Entity への変換
        return toDomain(jpaRepository.save(entity));
    }

    private Customer toDomain(CustomerJpaEntity entity) {
        // 変換ロジック
    }

    private CustomerJpaEntity toJpaEntity(Customer customer) {
        // 変換ロジック
    }
}

改善後の利点:

  • Domain の Entity はビジネスロジックに集中できる(JPA の知識不要)
  • テーブル構造が変わっても Domain 層は無傷(Infrastructure 層の変換ロジックだけ修正)
  • JPA → MyBatis、NoSQL への移行も Domain 層に影響なし

ポイント: Domain が王様、Infrastructure がしもべ

Infrastructure 層は Domain 層の要求に応える「しもべ」です。JPA Entity と Domain Entity の変換は Infrastructure 層が頑張って吸収します。この変換コストは Clean Architecture のトレードオフですが、業務ロジックを技術的変更から守る価値があります。

まとめ

Clean Architecture の本質を 3 つに整理しました。

本質 要点
境界の定義 システムを Domain / Application / Presentation / Infrastructure の 4 層に分割し、各層の責務を明確にする
関心事の分離 「何をしたいか(What)」と「どう実現するか(How)」を分け、UseCase は業務フローだけを記述する
依存方向の制御 Domain 層がインターフェースを定義し、Infrastructure 層がそれを実装する(依存性の逆転)

これらを実践することで:

  • コードを読めば業務フローが理解できる
  • 技術的な変更(DB 移行、外部 API 変更)から業務ロジックを守れる
  • テストしやすい構造になる

Clean Architecture のトレードオフ

ただし、Clean Architecture にはコストもあります。

  • 変換コストの増大: Domain Entity と JPA Entity の変換など、層をまたぐたびに変換処理が必要
  • コード量の増加: 同じデータを表すクラスが複数存在することになる
  • 学習コスト: チームメンバー全員がこの考え方を理解する必要がある

すべてのプロジェクトに Clean Architecture が必要というわけではありません。

小規模なプロジェクトや、変更頻度が低いシステムでは、従来の Controller → Service → Repository で十分な場合もあります。「業務ロジックが複雑」「技術的な変更が予想される」「長期的に保守する」といった条件が揃ったときに、Clean Architecture の価値が発揮されます。

続編で書きたいこと

本記事では触れられなかったトピックを、後日まとめたいと思います。

  • ドメインモデルは貧血であるべきか?: Entity にロジックを持たせるか、Domain Service に切り出すかの判断基準
  • 変換コストとどう向き合うか: MapStruct などのマッピングライブラリの活用
  • テスト戦略: 各層でどのようなテストを書くべきか

最後までお読みいただきありがとうございました。

8
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
8
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?