レイヤードアーキテクチャ — 責任を分離する
「どこに書けばいいの?」
「アヤカさん、クーポン計算ってどこに書けばいいですか?」
ユウキがSlackにメッセージを送って10分、アヤカがデスクに来た。
「前の会社ではコントローラーに書いてました」
「それが最初の一歩。でも今日はもっといい場所を教える」
アヤカがノートに4つの横線を引いた。
「建物で考えてみよう。1階がロビー(ユーザーが来る場所)、2階がオフィス(仕事する場所)、3階が設計室(ルールを考える場所)、4階が倉庫(物を保管する場所)。それぞれの階は決まった仕事だけをする」
「上の階は下の階を使う。でも下の階は上の階を知らない。そういう一方向の依存がポイント」
レイヤードアーキテクチャの構造
INFO
レイヤードアーキテクチャの鉄則:依存は上から下へ一方向のみ。下の層は上の層を知らない。これにより各層を独立してテスト・変更できる。RDSをDynamoDBに変えても、Domain層は影響を受けない。
なぜ一方向依存が大事なのか。それは変更の影響範囲を限定するためだ。
- Infrastructure層を変更 → その層だけに影響
- Domain層を変更 → Application層に波及(Infrastructureには波及しない)
- Application層を変更 → Presentation層に波及
- Presentation層を変更 → 誰にも波及しない
4つの層を Railsで実装する
1. Presentation Layer(プレゼンテーション層)
HTTPリクエストの受付と、レスポンスの返却のみ。パラメータの整形とバリデーションも担当するが、ビジネスロジックは一切持たない。
# app/controllers/api/v1/orders_controller.rb
module Api
module V1
class OrdersController < ApplicationController
before_action :authenticate_user!
def index
result = OrderListQuery.new.call(
user_id: current_user.id,
page: params[:page]&.to_i || 1
)
render json: result, status: :ok
end
def show
order = OrderDetailQuery.new.call(
order_id: params[:id],
user_id: current_user.id
)
render json: OrderSerializer.new(order), status: :ok
rescue OrderNotFoundError
render json: { error: "注文が見つかりません" }, status: :not_found
end
def create
result = OrderCreationService.new(
user: current_user,
items: order_params[:items],
coupon_code: order_params[:coupon_code]
).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(
:coupon_code,
items: [:product_id, :quantity]
)
end
end
end
endシリアライザーもPresentation層の一部。JSONの形を決めるのはビジネスルールではなくAPIの仕様だ。
# app/serializers/order_serializer.rb
class OrderSerializer
def initialize(order)
@order = order
end
def as_json(*)
{
id: @order.id,
status: @order.status,
total_amount: @order.total_amount,
created_at: @order.created_at.iso8601,
items: @order.order_items.map { |item|
{
product_id: item.product_id,
product_name: item.product.name,
quantity: item.quantity,
unit_price: item.price
}
}
}
end
end2. Application Layer(アプリケーション層)
ビジネスユースケースのオーケストレーション。「注文を作る」という一連のフローを管理する。個別のビジネスルール(割引計算など)はドメイン層に委譲する。
# app/services/order_creation_service.rb
class OrderCreationService
Result = Data.define(:success?, :order, :errors)
def initialize(user:, items:, coupon_code: nil)
@user = user
@items = items
@coupon_code = coupon_code
@order_repo = OrderRepository.new
@product_repo = ProductRepository.new
@coupon_repo = CouponRepository.new
end
def call
ActiveRecord::Base.transaction do
validate_items!
coupon = find_coupon
order = build_order(coupon)
reserve_stocks!
send_notifications(order)
Result.new(success?: true, order: order, errors: [])
end
rescue OrderCreationError => e
Result.new(success?: false, order: nil, errors: [e.message])
rescue ActiveRecord::RecordInvalid => e
Result.new(success?: false, order: nil, errors: e.record.errors.full_messages)
end
private
def validate_items!
raise OrderCreationError, "注文内容が空です" if @items.blank?
@items.each do |item|
product = @product_repo.find(item[:product_id])
qty = item[:quantity].to_i
raise OrderCreationError, "数量は1以上を指定してください" if qty < 1
raise OrderCreationError, "#{product.name}の在庫が不足しています(残り#{product.stock}個)" unless
product.in_stock?(qty)
end
end
def find_coupon
return nil unless @coupon_code.present?
coupon = @coupon_repo.find_by_code(@coupon_code)
raise OrderCreationError, "クーポンが無効です" unless coupon&.active?
coupon
end
def build_order(coupon)
subtotal = calculate_subtotal
total = coupon ? coupon.apply_to(subtotal) : subtotal
order = Order.create!(
user: @user,
total_amount: total,
status: :pending
)
@items.each do |item|
product = @product_repo.find(item[:product_id])
order.order_items.create!(
product: product,
quantity: item[:quantity].to_i,
price: product.price
)
end
order
end
def calculate_subtotal
@items.sum do |item|
product = @product_repo.find(item[:product_id])
product.price * item[:quantity].to_i
end
end
def reserve_stocks!
@items.each do |item|
product = @product_repo.find(item[:product_id])
product.reserve!(item[:quantity].to_i)
end
end
def send_notifications(order)
OrderMailer.confirmation(order).deliver_later
PointGrantJob.perform_later(order.id)
end
end
class OrderCreationError < StandardError; endキャンセルや返金もApplication layerのユースケースとして定義できる:
# app/services/order_cancellation_service.rb
class OrderCancellationService
Result = Data.define(:success?, :errors)
def initialize(order:, reason: nil)
@order = order
@reason = reason
end
def call
ActiveRecord::Base.transaction do
@order.cancel!(reason: @reason)
restore_inventory!
refund_payment!
notify_user!
Result.new(success?: true, errors: [])
end
rescue => e
Result.new(success?: false, errors: [e.message])
end
private
def restore_inventory!
@order.order_items.each do |item|
item.product.increment!(:stock, item.quantity)
end
end
def refund_payment!
return unless @order.payment_captured?
PaymentService.new.refund(order: @order)
end
def notify_user!
OrderMailer.cancellation(@order, reason: @reason).deliver_later
end
end3. Domain Layer(ドメイン層)
ビジネスルールとエンティティ。フレームワークに依存しない純粋なロジック。「何が正しいか」を定義するのがこの層の仕事。
# app/models/order.rb
class Order < ApplicationRecord
belongs_to :user
has_many :order_items, dependent: :destroy
has_one :payment, dependent: :nullify
validates :total_amount, numericality: { greater_than: 0 }
validates :status, inclusion: { in: %w[pending confirmed shipped delivered cancelled] }
enum :status, { pending: "pending", confirmed: "confirmed",
shipped: "shipped", delivered: "delivered",
cancelled: "cancelled" }
# ドメインルール
def total_items_count
order_items.sum(:quantity)
end
def can_be_cancelled?
pending? || confirmed?
end
def payment_captured?
payment&.status == "captured"
end
def estimated_delivery
return nil unless shipped?
shipped_at + 3.business_days
end
end# app/models/coupon.rb
class Coupon < ApplicationRecord
validates :code, presence: true, uniqueness: { case_sensitive: false }
validates :discount_type, inclusion: { in: %w[percentage fixed] }
validates :discount_value, numericality: { greater_than: 0 }
validates :minimum_amount, numericality: { greater_than_or_equal_to: 0 }
def active?
!expired? && remaining_uses > 0
end
def apply_to(amount)
return amount unless active?
case discount_type
when "percentage"
discounted = amount * (1 - discount_value / 100.0)
[discounted.ceil, 0].max
when "fixed"
[amount - discount_value, 0].max
else
amount
end
end
def discount_description
case discount_type
when "percentage" then "#{discount_value.to_i}%OFF"
when "fixed" then "#{discount_value.to_i}円引き"
end
end
private
def expired?
expires_at < Time.current
end
endドメインオブジェクトはフレームワークに依存しないため、Plain Old Ruby Objectとして定義することもある:
# app/domain/discount_calculator.rb
class DiscountCalculator
def initialize(items, coupon: nil)
@items = items
@coupon = coupon
end
def subtotal
@items.sum { |item| item.price * item.quantity }
end
def discount_amount
return 0 unless @coupon&.active?
subtotal - @coupon.apply_to(subtotal)
end
def total
subtotal - discount_amount
end
def tax_amount
(total * 0.1).ceil
end
def total_with_tax
total + tax_amount
end
end4. Infrastructure Layer(インフラ層)
外部システムとの通信。DBアクセス、メール送信、外部API呼び出し。変更の可能性が高い「詳細」を閉じ込める層。
# app/repositories/product_repository.rb
class ProductRepository
def find(id)
Product.find(id)
rescue ActiveRecord::RecordNotFound
raise ProductNotFoundError, "Product##{id} が見つかりません"
end
def find_multiple(ids)
products = Product.where(id: ids).index_by(&:id)
ids.map { |id|
products[id.to_i] || raise(ProductNotFoundError, "Product##{id} が見つかりません")
}
end
def find_available(ids)
Product.active.where(id: ids).where("stock > 0")
end
def save(product)
product.save!
product
rescue ActiveRecord::RecordInvalid => e
raise ProductPersistenceError, e.message
end
end
# app/repositories/coupon_repository.rb
class CouponRepository
def find_by_code(code)
Coupon.find_by(code: code.upcase)
end
def find_active_coupons
Coupon.where("expires_at > ? AND remaining_uses > 0", Time.current)
end
end# app/mailers/order_mailer.rb
class OrderMailer < ApplicationMailer
default from: ENV["MAIL_FROM_ADDRESS"]
def confirmation(order)
@order = order
@user = order.user
@items = order.order_items.includes(:product)
mail(
to: @user.email,
subject: "【注文確認】ご注文ありがとうございます ##{order.id}"
)
end
def cancellation(order, reason: nil)
@order = order
@user = order.user
@reason = reason
mail(
to: @user.email,
subject: "【注文キャンセル】注文 ##{order.id} がキャンセルされました"
)
end
end# app/clients/payment_service_client.rb
class PaymentServiceClient
BASE_URL = ENV["PAYMENT_SERVICE_URL"]
def charge(order_id:, amount:, user_id:)
response = HTTP
.timeout(connect: 5, read: 15)
.auth("Bearer #{ENV['PAYMENT_API_KEY']}")
.post("#{BASE_URL}/charges", json: {
order_id: order_id,
amount: amount,
user_id: user_id,
currency: "JPY"
})
unless response.status.success?
raise PaymentError, "決済失敗: #{response.body}"
end
JSON.parse(response.body, symbolize_names: true)
rescue HTTP::TimeoutError
raise PaymentError, "決済サービスへの接続がタイムアウトしました"
end
endテストの容易さ
レイヤードアーキテクチャの最大の利点はテストのしやすさ。
# spec/services/order_creation_service_spec.rb
RSpec.describe OrderCreationService do
let(:user) { create(:user) }
let(:product) { create(:product, price: 1000, stock: 10) }
describe "#call" do
context "有効な注文の場合" do
let(:items) { [{ product_id: product.id, quantity: 2 }] }
it "注文が作成される" do
result = described_class.new(user: user, items: items).call
expect(result.success?).to be true
expect(result.order).to be_persisted
expect(result.order.total_amount).to eq(2000)
end
it "在庫が減る" do
described_class.new(user: user, items: items).call
expect(product.reload.stock).to eq(8)
end
it "確認メールが送信されるようにキューイングされる" do
expect {
described_class.new(user: user, items: items).call
}.to have_enqueued_mail(OrderMailer, :confirmation)
end
end
context "在庫不足の場合" do
let(:items) { [{ product_id: product.id, quantity: 100 }] }
it "失敗を返す" do
result = described_class.new(user: user, items: items).call
expect(result.success?).to be false
expect(result.errors).to include(/在庫が不足/)
end
it "注文は作成されない" do
expect {
described_class.new(user: user, items: items).call
}.not_to change(Order, :count)
end
end
context "クーポンを使う場合" do
let(:coupon) { create(:coupon, discount_type: "percentage", discount_value: 10, remaining_uses: 1) }
let(:items) { [{ product_id: product.id, quantity: 2 }] }
it "割引が適用される" do
result = described_class.new(user: user, items: items, coupon_code: coupon.code).call
expect(result.success?).to be true
expect(result.order.total_amount).to eq(1800) # 2000 * 0.9
end
end
end
end# spec/domain/discount_calculator_spec.rb
RSpec.describe DiscountCalculator do
let(:item1) { instance_double("OrderItem", price: 1000, quantity: 2) }
let(:item2) { instance_double("OrderItem", price: 500, quantity: 3) }
subject(:calculator) { described_class.new([item1, item2]) }
it "小計を計算する" do
expect(calculator.subtotal).to eq(3500) # 2000 + 1500
end
it "税込み合計を計算する" do
expect(calculator.total_with_tax).to eq(3850) # 3500 * 1.1
end
context "クーポンあり(10%OFF)" do
let(:coupon) { instance_double("Coupon", active?: true) }
subject(:calculator) { described_class.new([item1, item2], coupon: coupon) }
before do
allow(coupon).to receive(:apply_to).with(3500).and_return(3150)
end
it "割引後の合計を返す" do
expect(calculator.total).to eq(3150)
end
end
endドメイン層はActiveRecordにも依存しないため、非常に高速なユニットテストが書ける。
AWSアーキテクチャとの対応
| レイヤー | Railsコード | AWSサービス |
|---|---|---|
| Presentation | controllers/ | ALB + ECS (Web) |
| Application | services/ | ECS (App Server) |
| Domain | models/ | RDS (データ保存) |
| Infrastructure | mailers/, repositories/, clients/ | SQS, SES, S3, 外部API |
実際のAWS構成では、各レイヤーをECSタスクとして分けることもある:
# ECSサービス定義(概念)
web:
image: myapp:latest
command: ["bundle", "exec", "puma", "-C", "config/puma.rb"]
environment:
ROLE: web # Presentation層
worker:
image: myapp:latest
command: ["bundle", "exec", "sidekiq"]
environment:
ROLE: worker # Application層の非同期処理Railsのディレクトリ構成
app/
├── controllers/ # Presentation Layer
│ └── api/v1/
│ ├── orders_controller.rb
│ └── products_controller.rb
├── serializers/ # Presentation Layer(レスポンス形式)
│ └── order_serializer.rb
├── services/ # Application Layer
│ ├── order_creation_service.rb
│ └── order_cancellation_service.rb
├── domain/ # Domain Layer(純粋なビジネスロジック)
│ └── discount_calculator.rb
├── models/ # Domain Layer(ActiveRecord)
│ ├── order.rb
│ └── coupon.rb
├── repositories/ # Infrastructure Layer (DB)
│ ├── order_repository.rb
│ └── product_repository.rb
├── clients/ # Infrastructure Layer (外部API)
│ └── payment_service_client.rb
└── mailers/ # Infrastructure Layer (Email)
└── order_mailer.rb
「なるほど」ユウキがうなずいた。「クーポン計算は……Coupon モデルの apply_to メソッドですね。ドメイン層」
「正解」アヤカが笑った。「どこに書くべきか迷ったとき、『これは何に関するロジックか?』と考える。クーポンのことならクーポンモデル。注文作成のフロー全体ならサービスオブジェクト。外部サービスとの通信ならクライアントクラス」
Golang での同等実装
Golangでは、レイヤー分離をパッケージ構造で表現する。
// domain/order.go
package domain
import (
"errors"
"time"
)
type OrderStatus string
const (
StatusPending OrderStatus = "pending"
StatusConfirmed OrderStatus = "confirmed"
StatusShipped OrderStatus = "shipped"
)
type Order struct {
ID int64
UserID int64
TotalAmount int64
Status OrderStatus
CreatedAt time.Time
Items []OrderItem
}
func (o *Order) Confirm() error {
if o.Status != StatusPending {
return errors.New("注文は pending 状態でなければなりません")
}
o.Status = StatusConfirmed
return nil
}
func (o *Order) CanBeCancelled() bool {
return o.Status == StatusPending || o.Status == StatusConfirmed
}// application/order_service.go
package application
import (
"context"
"myapp/domain"
"myapp/infrastructure"
)
type OrderService struct {
orderRepo infrastructure.OrderRepository
productRepo infrastructure.ProductRepository
mailer infrastructure.Mailer
}
func NewOrderService(
orderRepo infrastructure.OrderRepository,
productRepo infrastructure.ProductRepository,
mailer infrastructure.Mailer,
) *OrderService {
return &OrderService{
orderRepo: orderRepo,
productRepo: productRepo,
mailer: mailer,
}
}
func (s *OrderService) CreateOrder(ctx context.Context, userID int64, items []domain.OrderItem) (*domain.Order, error) {
// 在庫確認
for _, item := range items {
product, err := s.productRepo.FindByID(ctx, item.ProductID)
if err != nil {
return nil, err
}
if product.Stock < item.Quantity {
return nil, fmt.Errorf("%s の在庫が不足しています", product.Name)
}
}
// 注文作成
total := calculateTotal(items)
order := &domain.Order{
UserID: userID,
TotalAmount: total,
Status: domain.StatusPending,
Items: items,
}
if err := s.orderRepo.Save(ctx, order); err != nil {
return nil, err
}
// 非同期通知
go s.mailer.SendConfirmation(order)
return order, nil
}WARNING
レイヤーを増やすと最初は複雑に感じる。10ファイル以下の小さなアプリには過剰設計になることも。「今は小さいが、成長する見込みがある」アプリから導入するのが現実的。目安は、3名以上のチームで3ヶ月以上続くプロジェクト。
まとめ
| レイヤー | 責任 | 変更のきっかけ |
|---|---|---|
| Presentation | HTTPの入出力 | APIの仕様変更 |
| Application | ユースケースの流れ | ビジネスフローの変更 |
| Domain | ビジネスルール | ビジネスの本質的な変更 |
| Infrastructure | 技術的な実装 | DBやメールサービスの変更 |
各レイヤーは独立して変更できる。RDSからDynamoDBに移行しても、サービス層とドメイン層は変更不要。Sendgridから別のメール送信サービスに変えても、ドメイン層に影響しない。
「レイヤーを追加するたびに、変更の理由が明確になる」アヤカが言った。「ドメイン層が変わる理由は、ビジネスルールが変わったとき。それだけ。DBが変わっても、ユーザーの要求が変わっても、ドメイン層には影響しない——そういう設計が、長期的に保守しやすいコードを作る」
次章では、インフラ層のDB部分を担うリポジトリパターンを学びます。ActiveRecordとの関係と、テスタビリティの高いデータアクセス層の作り方を見ていきます。