mybook

レイヤードアーキテクチャ — 責任を分離する

「どこに書けばいいの?」

「アヤカさん、クーポン計算ってどこに書けばいいですか?」

ユウキがSlackにメッセージを送って10分、アヤカがデスクに来た。

「前の会社ではコントローラーに書いてました」

「それが最初の一歩。でも今日はもっといい場所を教える」

アヤカがノートに4つの横線を引いた。

「建物で考えてみよう。1階がロビー(ユーザーが来る場所)、2階がオフィス(仕事する場所)、3階が設計室(ルールを考える場所)、4階が倉庫(物を保管する場所)。それぞれの階は決まった仕事だけをする」

「上の階は下の階を使う。でも下の階は上の階を知らない。そういう一方向の依存がポイント」

レイヤードアーキテクチャの構造

Loading diagram...

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
end

2. 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
end

3. 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
end

4. 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アーキテクチャとの対応

Loading diagram...
レイヤーRailsコードAWSサービス
Presentationcontrollers/ALB + ECS (Web)
Applicationservices/ECS (App Server)
Domainmodels/RDS (データ保存)
Infrastructuremailers/, 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ヶ月以上続くプロジェクト。

まとめ

レイヤー責任変更のきっかけ
PresentationHTTPの入出力APIの仕様変更
Applicationユースケースの流れビジネスフローの変更
Domainビジネスルールビジネスの本質的な変更
Infrastructure技術的な実装DBやメールサービスの変更

各レイヤーは独立して変更できる。RDSからDynamoDBに移行しても、サービス層とドメイン層は変更不要。Sendgridから別のメール送信サービスに変えても、ドメイン層に影響しない。

「レイヤーを追加するたびに、変更の理由が明確になる」アヤカが言った。「ドメイン層が変わる理由は、ビジネスルールが変わったとき。それだけ。DBが変わっても、ユーザーの要求が変わっても、ドメイン層には影響しない——そういう設計が、長期的に保守しやすいコードを作る」


次章では、インフラ層のDB部分を担うリポジトリパターンを学びます。ActiveRecordとの関係と、テスタビリティの高いデータアクセス層の作り方を見ていきます。