クリーンアーキテクチャ — 依存の方向を制御する
「なぜドメインがDBに依存しているの?」
カオリは夜遅く、技術書を読んでいた。ロバート・C・マーティン(Uncle Bob)の言葉が目に止まった。
The architecture of a system should tell you what the system does, not what framework it uses.
「システムのアーキテクチャは、何をするかを語るべきであって、どのフレームワークを使うかを語るべきではない」
レイヤードアーキテクチャを導入したばかりなのに、すでに限界が見えてきた。ドメインモデルは ApplicationRecord を継承しており、データベース(ActiveRecord)に依存している。「もしデータベースをPostgreSQLからMongoDBに変えたい場合、ドメインコードも全部変わってしまう」
クリーンアーキテクチャは、この問題を「依存の方向を逆転させる」ことで解決する。
INFO
クリーンアーキテクチャは、依存関係が常に「内側」(ドメイン)に向かうように設計します。外側の詳細(DB、フレームワーク、UI)がドメインに依存するのであって、その逆はありません。
同心円モデル
クリーンアーキテクチャは4つの同心円で表現される。
依存の黄金律: 依存は常に外側から内側へ。内側のコードは外側の存在を知らない。
各層の役割
エンティティ層(最内層)
最も重要で、最も変化しない。純粋なRubyオブジェクト。フレームワーク非依存。
# app/domain/entities/order.rb
module Domain
module Entities
class Order
attr_reader :id, :user_id, :product_id, :quantity, :status, :unit_price
VALID_STATUSES = %w[pending confirmed shipped delivered cancelled].freeze
def initialize(id:, user_id:, product_id:, quantity:, status:, unit_price:)
@id = id
@user_id = user_id
@product_id = product_id
@quantity = quantity
@status = status
@unit_price = unit_price
validate!
end
def total_price
quantity * unit_price
end
def cancellable?
status.in?(%w[pending confirmed])
end
def cancel
raise Domain::Errors::BusinessRuleViolation, "キャンセル不可の状態" unless cancellable?
# 新しい状態のエンティティを返す(イミュータブル設計)
self.class.new(**to_h.merge(status: 'cancelled'))
end
private
def validate!
raise ArgumentError, "数量は1以上" unless quantity.positive?
raise ArgumentError, "無効なステータス" unless VALID_STATUSES.include?(status)
end
def to_h
{ id:, user_id:, product_id:, quantity:, status:, unit_price: }
end
end
end
endこのエンティティは ActiveRecord を継承していない。データベースの存在を知らない。
ユースケース層
アプリケーション固有のビジネスロジック。エンティティを組み合わせてユースケースを実現する。
# app/use_cases/create_order.rb
module UseCases
class CreateOrder
# 依存逆転の原則:具体的な実装ではなくインターフェース(抽象)に依存する
def initialize(
order_repository:, # IOrderRepository インターフェース
product_repository:, # IProductRepository インターフェース
inventory_service:, # IInventoryService インターフェース
event_publisher: # IEventPublisher インターフェース
)
@order_repository = order_repository
@product_repository = product_repository
@inventory_service = inventory_service
@event_publisher = event_publisher
end
Result = Struct.new(:order, :error, keyword_init: true) do
def success? = error.nil?
end
def call(user_id:, product_id:, quantity:)
product = @product_repository.find(product_id)
return Result.new(error: '商品が見つかりません') unless product
return Result.new(error: '在庫不足') unless @inventory_service.available?(product, quantity)
order = Domain::Entities::Order.new(
id: nil,
user_id: user_id,
product_id: product_id,
quantity: quantity,
status: 'pending',
unit_price: product.price
)
saved_order = @order_repository.save(order)
@inventory_service.reserve(product, quantity)
@event_publisher.publish('order.created', order_id: saved_order.id)
Result.new(order: saved_order)
rescue Domain::Errors::BusinessRuleViolation => e
Result.new(error: e.message)
end
end
endユースケース層は「何をするか」を定義するが、「どのDBを使うか」は知らない。
インターフェースアダプター層
外側と内側を橋渡しする。Railsのコントローラ、リポジトリの実装がここに入る。
# app/adapters/repositories/active_record_order_repository.rb
module Adapters
module Repositories
class ActiveRecordOrderRepository
# ここでActiveRecordモデル(外側)とドメインエンティティ(内側)を変換する
def find(id)
record = OrderRecord.find_by(id: id)
return nil unless record
to_entity(record)
end
def save(order)
record = if order.id
OrderRecord.find(order.id)
else
OrderRecord.new
end
record.assign_attributes(
user_id: order.user_id,
product_id: order.product_id,
quantity: order.quantity,
status: order.status,
unit_price: order.unit_price
)
record.save!
to_entity(record)
end
private
def to_entity(record)
Domain::Entities::Order.new(
id: record.id,
user_id: record.user_id,
product_id: record.product_id,
quantity: record.quantity,
status: record.status,
unit_price: record.unit_price
)
end
end
end
end
# ActiveRecordモデルは単なるデータ永続化の道具(ビジネスロジックなし)
class OrderRecord < ApplicationRecord
self.table_name = 'orders'
endフレームワーク層(最外層)
Railsのコントローラは、依存を組み立てて(DIコンテナ)ユースケースを呼び出す。
# app/controllers/api/v1/orders_controller.rb
module Api
module V1
class OrdersController < ApplicationController
def create
# 依存を組み立てる(本来はDIコンテナが担当)
use_case = UseCases::CreateOrder.new(
order_repository: Adapters::Repositories::ActiveRecordOrderRepository.new,
product_repository: Adapters::Repositories::ActiveRecordProductRepository.new,
inventory_service: Adapters::Services::InventoryService.new,
event_publisher: Adapters::Events::SnsEventPublisher.new
)
result = use_case.call(
user_id: current_user.id,
product_id: params[:product_id],
quantity: params[:quantity].to_i
)
if result.success?
render json: present_order(result.order), status: :created
else
render json: { error: result.error }, status: :unprocessable_entity
end
end
private
def present_order(order)
{
id: order.id,
status: order.status,
total_price: order.total_price,
quantity: order.quantity
}
end
end
end
end依存逆転の原則(DIP)
クリーンアーキテクチャの核心は依存逆転の原則だ。
# NG: ユースケースが具体的な実装に依存
class CreateOrder
def initialize
@repository = ActiveRecordOrderRepository.new # 具体的な実装に依存
end
end
# OK: 抽象(インターフェース)に依存(Rubyではduck typingで実現)
class CreateOrder
def initialize(order_repository:)
@order_repository = order_repository # 何でもいい。saveとfindに応答できれば
end
end
# テスト時はインメモリの実装を注入できる
class InMemoryOrderRepository
def initialize
@store = {}
end
def save(order)
@store[order.object_id] = order
order
end
def find(id)
@store[id]
end
endテストの強力さ
クリーンアーキテクチャの最大のメリットはテストだ。
# ドメインエンティティのテスト(DB不要、高速、完全独立)
RSpec.describe Domain::Entities::Order do
subject(:order) do
described_class.new(
id: 1, user_id: 1, product_id: 1,
quantity: 2, status: 'pending', unit_price: 1000
)
end
it 'total_price を計算できる' do
expect(order.total_price).to eq 2000
end
it 'pending 状態はキャンセル可能' do
expect(order.cancellable?).to be true
end
end
# ユースケースのテスト(インメモリリポジトリを使用、DB不要)
RSpec.describe UseCases::CreateOrder do
let(:order_repository) { InMemoryOrderRepository.new }
let(:product_repository) { InMemoryProductRepository.new }
let(:inventory_service) { FakeInventoryService.new(available: true) }
let(:event_publisher) { FakeEventPublisher.new }
subject(:use_case) do
described_class.new(
order_repository: order_repository,
product_repository: product_repository,
inventory_service: inventory_service,
event_publisher: event_publisher
)
end
before do
product_repository.add(id: 1, price: 1000, name: 'テスト商品')
end
it '注文を作成し、イベントを発行する' do
result = use_case.call(user_id: 1, product_id: 1, quantity: 2)
expect(result.success?).to be true
expect(result.order.total_price).to eq 2000
expect(event_publisher.events).to include('order.created')
end
endテストがDBなしで動く。テストスイート全体が30秒以内で完了する。
AWSでの展開
クリーンアーキテクチャにより、SNSをEventBridgeに変えても、ユースケース層のコードは変わらない。アダプター(実装)だけを差し替えればいい。
# 本番環境:SNSを使うアダプター
Adapters::Events::SnsEventPublisher.new(topic_arn: ENV['SNS_TOPIC_ARN'])
# テスト環境:メモリに記録するフェイク
FakeEventPublisher.new
# 将来:EventBridgeに変えてもユースケースは無変更
Adapters::Events::EventBridgePublisher.new(event_bus: ENV['EVENT_BUS_NAME'])クリーンアーキテクチャのコスト
カオリは正直に伝えた。「この設計にはコストがある」
WARNING
クリーンアーキテクチャは、初期の実装コストが高いです。ファイル数が増え、コードを追う時間も長くなります。チームが十分に理解していないと、逆に複雑さだけが増す結果になります。
コスト:
- ファイル数が増える(Entity, UseCase, Repository, Adapter...)
- 学習曲線が急(チーム全員の理解が必要)
- CRUD単純な機能でもオーバーエンジニアリングになりやすい
ベネフィット:
- ドメインロジックが外部依存から完全独立
- テストが高速・容易
- フレームワークやDBを差し替えられる
- 長期的な保守性が高い
「今の私たちに、このレベルの厳密さが必要か?」カオリは問いかけた。「少し緩やかなアプローチも見てみよう」
次章で学ぶヘキサゴナルアーキテクチャは、同じ思想をより実用的な形で実現する。