レイヤードアーキテクチャ — 層で分ける
コードが「スパゲッティ」になる瞬間
プロジェクト開始から2週間が経った。カオリがコードレビューをしていると、気になるPRを見つけた。
# 問題のあるコード(実際にレビューで見つかった)
class OrdersController < ApplicationController
def create
# コントローラでSQLを直接書いている
product = Product.where('stock_count > 0 AND price < ?', params[:max_price]).first
# ビジネスロジックがコントローラに書かれている
if product.stock_count >= params[:quantity].to_i
order = Order.new(
user_id: current_user.id,
product_id: product.id,
total: product.price * params[:quantity].to_i
)
# メール送信もここで直接
OrderMailer.confirmation(order).deliver_now
render json: order
else
render json: { error: '在庫不足' }, status: 422
end
end
end「これだと、このロジックは再利用できない。APIからも同じことをしたいとき、また書き直すの?」
カオリはコードに赤いコメントを追加した。「責務を分離しましょう」
INFO
レイヤードアーキテクチャ(層状アーキテクチャ)は、システムを水平な「層」に分割し、各層が特定の責務を持つ設計パターンです。依存関係は上から下(上位層 → 下位層)の一方向のみが原則です。
4層の基本構造
レイヤードアーキテクチャの最も一般的な構成は4層だ。
各層の責務は明確に分かれている。
| 層 | 責務 | Railsでの対応 |
|---|---|---|
| プレゼンテーション | HTTPリクエスト/レスポンスの処理 | Controllers, Serializers |
| アプリケーション | ユースケースのオーケストレーション | Service Objects, Jobs |
| ドメイン | ビジネスルール・エンティティ | Models, Value Objects |
| インフラ | 永続化・外部連携 | Repositories, API Clients |
Railsでの実装
カオリはチームにリファクタリング後のコードを示した。
プレゼンテーション層
# app/controllers/api/v1/orders_controller.rb
module Api
module V1
class OrdersController < ApplicationController
def create
result = CreateOrderUseCase.new(
user: current_user,
params: order_params
).call
if result.success?
render json: OrderSerializer.new(result.order), status: :created
else
render json: { errors: result.errors }, status: :unprocessable_entity
end
end
private
def order_params
params.require(:order).permit(:product_id, :quantity, :coupon_code)
end
end
end
endコントローラは「受け取って、委譲して、返す」だけ。ビジネスロジックは一行もない。
アプリケーション層
# app/use_cases/create_order_use_case.rb
class CreateOrderUseCase
Result = Struct.new(:success?, :order, :errors, keyword_init: true)
def initialize(user:, params:)
@user = user
@params = params
@order_repository = OrderRepository.new
@inventory_service = InventoryService.new
@payment_service = PaymentService.new
end
def call
product = ProductRepository.new.find(@params[:product_id])
quantity = @params[:quantity].to_i
# バリデーション
unless @inventory_service.available?(product, quantity)
return Result.new(success?: false, errors: ['在庫が不足しています'])
end
# ビジネス処理のオーケストレーション
order = nil
ActiveRecord::Base.transaction do
order = @order_repository.create(
user: @user,
product: product,
quantity: quantity
)
@inventory_service.reserve(product, quantity)
end
# 副作用(メール送信)はトランザクション外で
OrderMailer.confirmation(order).deliver_later
Result.new(success?: true, order: order)
rescue => e
Result.new(success?: false, errors: [e.message])
end
endドメイン層
# app/models/order.rb
class Order < ApplicationRecord
belongs_to :user
belongs_to :product
validates :quantity, numericality: { greater_than: 0 }
validates :status, inclusion: { in: %w[pending confirmed shipped delivered cancelled] }
# ビジネスルール:ドメインに閉じたロジック
def cancellable?
status.in?(%w[pending confirmed])
end
def total_price
quantity * product.price
end
def cancel!
raise "キャンセル不可の状態です" unless cancellable?
update!(status: 'cancelled')
end
end
# app/models/product.rb
class Product < ApplicationRecord
validates :name, presence: true
validates :price, numericality: { greater_than: 0 }
validates :stock_count, numericality: { greater_than_or_equal_to: 0 }
def in_stock?(quantity = 1)
stock_count >= quantity
end
endインフラストラクチャ層
# app/repositories/order_repository.rb
class OrderRepository
def find(id)
Order.find(id)
end
def create(user:, product:, quantity:)
Order.create!(
user: user,
product: product,
quantity: quantity,
status: 'pending',
unit_price: product.price
)
end
def find_by_user(user, page: 1, per: 20)
Order.where(user: user)
.includes(:product)
.order(created_at: :desc)
.page(page)
.per(per)
end
endリポジトリパターンにより、SQLの詳細がドメイン層から隠蔽される。
層の「ルール」と「違反」
依存の方向
重要な原則: 依存は上から下(プレゼンテーション → アプリケーション → ドメイン → インフラ)の一方向のみ。
# NG: ドメイン層からプレゼンテーション層に依存
class Order < ApplicationRecord
def to_json_response # これはシリアライザの責務
{ id: id, status: status, formatted_price: "¥#{total_price}" }
end
end
# OK: ドメインはドメインの責務のみ
class Order < ApplicationRecord
def total_price
quantity * unit_price
end
end
# シリアライザ(プレゼンテーション層)が変換を担当
class OrderSerializer
def as_json
{ id: order.id, status: order.status, price: "¥#{order.total_price}" }
end
end「Fat Model, Skinny Controller」の罠
Railsコミュニティでは「Fat Model, Skinny Controller」というスローガンがある。コントローラを薄くするのは正しいが、全てをモデルに押し込めるのは誤りだ。
# NG: ドメインモデルにビジネスフローのロジックが入る
class Order < ApplicationRecord
def self.create_with_inventory_check_and_payment(user, product, quantity, payment_info)
# これはService/UseCaseの責務
transaction do
# 在庫確認、注文作成、決済処理...
end
end
end
# OK: モデルはエンティティのルールのみ
class Order < ApplicationRecord
def cancellable? # ← これがドメインロジック
status.in?(%w[pending confirmed])
end
endWARNING
「全てをモデルに書く」と「スパゲッティコード」という二項対立ではありません。適切な層(Service Object, Use Case)を使い、責務を分散させましょう。
AWSでの層ごとの関心事
レイヤードアーキテクチャは、インフラレベルでも対応する分離を生む。
# インフラの関心事分離の例
# 各層の責務に対応したAWSサービス選定
presentation:
service: ALB + WAF
concern: HTTPルーティング、DDoS保護、SSL終端
application:
service: ECS Fargate (Rails)
concern: ビジネスロジックの実行、スケーリング
domain:
# コードレベルの分離(AWSサービスなし)
infrastructure:
database: RDS PostgreSQL # 永続化
cache: ElastiCache Redis # キャッシュ
email: Amazon SES # 通知
storage: S3 # ファイル
queue: SQS # 非同期処理テスト戦略
層の分離はテストを格段に書きやすくする。
# ドメイン層のテスト(外部依存なし、高速)
RSpec.describe Order do
describe '#cancellable?' do
it 'pending の注文はキャンセル可能' do
order = build(:order, status: 'pending')
expect(order.cancellable?).to be true
end
it 'shipped の注文はキャンセル不可' do
order = build(:order, status: 'shipped')
expect(order.cancellable?).to be false
end
end
end
# アプリケーション層のテスト(ドメインモデルは実物、外部サービスはモック)
RSpec.describe CreateOrderUseCase do
let(:user) { create(:user) }
let(:product) { create(:product, stock_count: 10, price: 1000) }
it '在庫がある場合、注文を作成できる' do
result = CreateOrderUseCase.new(
user: user,
params: { product_id: product.id, quantity: 2 }
).call
expect(result.success?).to be true
expect(result.order.quantity).to eq 2
expect(product.reload.stock_count).to eq 8
end
it '在庫不足の場合、エラーを返す' do
result = CreateOrderUseCase.new(
user: user,
params: { product_id: product.id, quantity: 100 }
).call
expect(result.success?).to be false
expect(result.errors).to include('在庫が不足しています')
end
endレイヤードアーキテクチャの限界
カオリは正直に限界も伝えた。
「レイヤードアーキテクチャで責務は分離できる。でも、依然として全ての層がデータベースに強く依存している」
# 問題: ドメイン層がRails(ActiveRecord)に依存している
class Order < ApplicationRecord # ActiveRecord::Baseに依存
# ドメインロジックとDBの実装が混在
endこれを解決するのが次の章で学ぶ「クリーンアーキテクチャ」だ。依存の方向をより厳密に制御し、ドメインコードを完全に外部依存から解放する。
「まず全員がService ObjectとRepository を使うルールを守れるようになってから、次のステップを考えよう」
カオリは満足した。チームのコードレビューで指摘することが減り、新機能の追加が格段に速くなった気がした。
次章では、依存の方向をさらに厳密に制御する「クリーンアーキテクチャ」へ進む。