はじめに
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つです。
- 境界の定義:システムの責務を層に分割し、明確な境界を作る
- 関心事の分離:各層で「何をしたいか(What)」と「どう実現するか(How)」を分ける
- 依存方向の制御:業務ロジックを技術的な実装から守る
それぞれ詳しく見ていきましょう。
本質 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 でも再利用できる
-
OrderRepositoryとInventoryCheckerをモック化してテストできる - 責務ごとにファイルが分かれているので、複数人で並列に開発しやすい
パッケージ構成例
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)」を分けることが、関心事の分離です。
なぜ分離が重要なのでしょうか?
分離しないと起きる問題
- 技術の変更に弱くなる: DB を Oracle から NoSQL に変えたら業務ロジックにも修正が必要
- 外部仕様の変更に弱くなる: 連携先 API の仕様変更で UseCase を書き換える必要がある
- コードが読みにくくなる: 業務フローを理解するために SQL や HTTP の詳細も読む必要がある
- テストしにくくなる: 単体テストで 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)だけがひと目で分かる
-
CustomerRepositoryとGradeServiceをモック化してテストできる
本質 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 などのマッピングライブラリの活用
- テスト戦略: 各層でどのようなテストを書くべきか
最後までお読みいただきありがとうございました。