mybook

リポジトリパターン — データアクセスの抽象化

「このコードのテストが書けない」

リナはOrderサービスのテストを書こうとして手が止まった。ビジネスロジックの中で Order.where(status: :pending).includes(:items).order(:created_at) が直接呼ばれていて、データベースなしではテストできない構造になっていた。

テストを実行するたびにデータベースのセットアップとクリーンアップが必要で、1テストスイートの実行に5分以上かかる。しかも、テストが互いに干渉してランダムに失敗する「フレーキーテスト」が多発していた。

# テストできない構造の例
class OrderConfirmationService
  def call(customer_id)
    # ActiveRecordへの直接依存 — データベースなしでテスト不可
    pending_orders = Order.where(
      status: :pending,
      customer_id: customer_id
    ).includes(:order_items, :customer).order(:created_at)
 
    pending_orders.each do |order|
      # ビジネスロジック
      order.update!(status: 'confirmed')
      Stock.where(product_id: order.order_items.pluck(:product_id))
           .each { |s| s.decrement!(:quantity) }
    end
  end
end
 
# このテストはDBが必要
RSpec.describe OrderConfirmationService do
  it '注文を確定する' do
    # DBセットアップが必要
    customer = create(:customer)
    order = create(:order, :pending, customer: customer)
    create(:stock, product: order.order_items.first.product, quantity: 10)
 
    service.call(customer.id)
 
    # DBクエリで結果を確認
    expect(Order.find(order.id).status).to eq('confirmed')
  end
end

DBを使うテストは遅い。100件あれば数分かかる。1000件あればCIが20分になる。

リポジトリパターンとは

リポジトリ(Repository)は、ドメインオブジェクトのコレクションをメモリ上のコレクションのように扱うための抽象化だ。ドメイン層はリポジトリのインターフェースのみを知り、実際のデータアクセス実装(SQL、API、ファイル)を知らない。

Loading diagram...

INFO

リポジトリの重要な考え方: ドメイン層から見ると、リポジトリは「注文のコレクション」に見える。findsavedelete というシンプルな操作のみを提供する。SQLがどう生成されるか、インデックスがどう使われるかは、ドメイン層の関心外だ。

リポジトリのインターフェース設計

まずインターフェース(振る舞いの契約)を定義する。実装の詳細ではなく、「何ができるか」を定義する。

module OrderContext
  # リポジトリのインターフェース — 集約ルートへの操作のみ定義
  module OrderRepositoryInterface
    # IDによる取得 — 見つからない場合はOrderNotFoundを発生
    def find(id)
      raise NotImplementedError, "#{self.class}#find は実装必須"
    end
 
    # 顧客による注文一覧取得
    def find_by_customer(customer_id, status: nil, limit: nil)
      raise NotImplementedError
    end
 
    # 処理待ちの古い注文(タイムアウト候補)
    def find_stale_pending_orders(older_than: 30.minutes.ago)
      raise NotImplementedError
    end
 
    # 保存(新規作成・更新どちらも)
    def save(order)
      raise NotImplementedError
    end
 
    # 削除
    def delete(order)
      raise NotImplementedError
    end
 
    # 存在確認
    def exists?(id)
      raise NotImplementedError
    end
 
    # 件数(集計は軽量クエリで)
    def count_by_customer(customer_id, status: nil)
      raise NotImplementedError
    end
  end
end

ActiveRecord実装(本番用)

本番環境で使用するActiveRecord実装。SQLの詳細はここに閉じ込める。

module OrderContext
  class ActiveRecordOrderRepository
    include OrderRepositoryInterface
 
    def find(id)
      record = OrderRecord
        .includes(:order_item_records)
        .find_by(id: id)
 
      raise OrderNotFound, "注文が見つかりません: #{id}" unless record
      reconstruct(record)
    end
 
    def find_by_customer(customer_id, status: nil, limit: nil)
      scope = OrderRecord
        .includes(:order_item_records)
        .where(customer_id: customer_id)
        .order(created_at: :desc)
 
      scope = scope.where(status: status.to_s) if status
      scope = scope.limit(limit) if limit
 
      scope.map { |record| reconstruct(record) }
    end
 
    def find_stale_pending_orders(older_than: 30.minutes.ago)
      OrderRecord
        .includes(:order_item_records)
        .where(status: 'pending')
        .where('created_at < ?', older_than)
        .map { |record| reconstruct(record) }
    end
 
    def save(order)
      ApplicationRecord.transaction do
        record = OrderRecord.find_or_initialize_by(id: order.id)
 
        record.assign_attributes(
          customer_id: order.customer_id,
          status: order.status.to_s,
          delivery_postal_code: order.delivery_address.postal_code,
          delivery_prefecture: order.delivery_address.prefecture,
          delivery_city: order.delivery_address.city,
          delivery_street: order.delivery_address.street,
          delivery_building: order.delivery_address.building,
          delivery_recipient_name: order.delivery_address.recipient_name,
          total_amount_cents: order.total_amount.to_i,
          confirmed_at: order.confirmed_at,
          cancelled_at: order.cancelled_at,
          cancellation_reason: order.cancellation_reason
        )
        record.save!
 
        persist_order_items(record, order.order_items)
      end
    rescue ActiveRecord::RecordInvalid => e
      raise OrderPersistenceError, "注文の保存に失敗: #{e.message}"
    rescue ActiveRecord::StaleObjectError
      raise ConcurrentModificationError, "注文が別のリクエストで更新されました"
    end
 
    def delete(order)
      OrderRecord.find(order.id).destroy!
    end
 
    def exists?(id)
      OrderRecord.exists?(id: id)
    end
 
    def count_by_customer(customer_id, status: nil)
      scope = OrderRecord.where(customer_id: customer_id)
      scope = scope.where(status: status.to_s) if status
      scope.count
    end
 
    private
 
    def reconstruct(record)
      order = Order.new(
        id: record.id,
        customer_id: record.customer_id,
        delivery_address: build_delivery_address(record)
      )
 
      order.__send__(:restore_state,
        status: OrderStatus.from_string(record.status),
        order_items: record.order_item_records.map { |r| build_order_item(r) },
        confirmed_at: record.confirmed_at,
        cancelled_at: record.cancelled_at,
        cancellation_reason: record.cancellation_reason
      )
      order
    end
 
    def build_delivery_address(record)
      DeliveryAddress.new(
        postal_code: record.delivery_postal_code,
        prefecture: record.delivery_prefecture,
        city: record.delivery_city,
        street: record.delivery_street,
        building: record.delivery_building,
        recipient_name: record.delivery_recipient_name
      )
    end
 
    def build_order_item(record)
      OrderItem.new(
        product_id: record.product_id,
        product_name: record.product_name,
        unit_price: SharedKernel::Money.new(
          amount: record.unit_price_cents,
          currency: :jpy
        ),
        quantity: record.quantity
      )
    end
 
    def persist_order_items(order_record, order_items)
      current_ids = order_record.order_item_records.pluck(:product_id)
      new_ids = order_items.map(&:product_id)
 
      # 削除されたアイテムを削除
      order_record.order_item_records
        .where(product_id: current_ids - new_ids)
        .destroy_all
 
      # 追加・更新
      order_items.each do |item|
        order_record.order_item_records
          .find_or_initialize_by(product_id: item.product_id)
          .update!(
            product_name: item.product_name,
            unit_price_cents: item.unit_price.to_i,
            quantity: item.quantity,
            subtotal_cents: item.subtotal.to_i
          )
      end
    end
  end
end

インメモリ実装(テスト用)

データベースなしでテストできる軽量実装。ビジネスロジックのテストに使う。

module OrderContext
  class InMemoryOrderRepository
    include OrderRepositoryInterface
 
    def initialize
      @store = {}
    end
 
    def find(id)
      order = @store[id.to_s]
      raise OrderNotFound, "注文が見つかりません: #{id}" unless order
      deep_copy(order)  # 副作用を防ぐためにコピーを返す
    end
 
    def find_by_customer(customer_id, status: nil, limit: nil)
      results = @store.values
        .select { |o| o.customer_id == customer_id }
        .sort_by { |o| o.created_at || Time.current }
        .reverse
 
      results = results.select { |o| o.status == status } if status
      results = results.first(limit) if limit
      results.map { |o| deep_copy(o) }
    end
 
    def find_stale_pending_orders(older_than: 30.minutes.ago)
      @store.values
        .select { |o| o.pending? && (o.created_at || Time.current) < older_than }
    end
 
    def save(order)
      @store[order.id.to_s] = deep_copy(order)
      order
    end
 
    def delete(order)
      @store.delete(order.id.to_s)
    end
 
    def exists?(id)
      @store.key?(id.to_s)
    end
 
    def count_by_customer(customer_id, status: nil)
      results = @store.values.select { |o| o.customer_id == customer_id }
      results = results.select { |o| o.status == status } if status
      results.count
    end
 
    # テストのためのユーティリティメソッド
    def all
      @store.values
    end
 
    def clear
      @store.clear
    end
 
    def size
      @store.size
    end
 
    private
 
    def deep_copy(order)
      # Marshal を使った深いコピー(ドメインオブジェクトが freeze されている場合も対応)
      Marshal.load(Marshal.dump(order))
    end
  end
end

テストが劇的に書きやすくなる

インメモリ実装を使うことで、高速で信頼性の高いテストが書ける。

RSpec.describe OrderContext::ConfirmOrderUseCase do
  # データベース不要!インメモリリポジトリを使用
  let(:order_repository) { OrderContext::InMemoryOrderRepository.new }
  let(:event_bus) { instance_double('EventBus', publish: nil) }
 
  let(:use_case) do
    described_class.new(
      order_repository: order_repository,
      event_bus: event_bus
    )
  end
 
  # テストのセットアップ — DBなしで高速
  let(:customer_id) { 'customer-1' }
  let(:order) do
    order = OrderContext::Order.new(
      id: SecureRandom.uuid,
      customer_id: customer_id,
      delivery_address: build_test_address
    )
    order.add_item(
      product_id: 'product-1',
      product_name: 'りんご 1kg',
      unit_price: SharedKernel::Money.new(amount: 500, currency: :jpy),
      quantity: 2
    )
    order_repository.save(order)
    order
  end
 
  describe '注文確定の正常系' do
    it '注文が確定される' do
      result = use_case.call(order_id: order.id, customer_id: customer_id)
 
      expect(result).to be_success
      expect(result.order.status).to eq(OrderContext::OrderStatus::CONFIRMED)
    end
 
    it '確定された注文がリポジトリに保存される' do
      use_case.call(order_id: order.id, customer_id: customer_id)
 
      saved_order = order_repository.find(order.id)
      expect(saved_order.status).to eq(OrderContext::OrderStatus::CONFIRMED)
      expect(saved_order.confirmed_at).not_to be_nil
    end
 
    it 'OrderConfirmedイベントが発行される' do
      use_case.call(order_id: order.id, customer_id: customer_id)
 
      expect(event_bus).to have_received(:publish).with(
        an_instance_of(OrderContext::OrderConfirmed)
      )
    end
  end
 
  describe 'エラー系' do
    context '存在しない注文IDの場合' do
      it 'not_foundで失敗する' do
        result = use_case.call(order_id: 'non-existent', customer_id: customer_id)
 
        expect(result).to be_failure
        expect(result.reason).to eq(:not_found)
      end
    end
 
    context '別顧客の注文を確定しようとした場合' do
      it 'unauthorizedで失敗する' do
        result = use_case.call(order_id: order.id, customer_id: 'other-customer')
 
        expect(result).to be_failure
        expect(result.reason).to eq(:unauthorized)
      end
    end
 
    context '既に確定済みの注文の場合' do
      before { use_case.call(order_id: order.id, customer_id: customer_id) }
 
      it 'invalid_stateで失敗する' do
        result = use_case.call(order_id: order.id, customer_id: customer_id)
 
        expect(result).to be_failure
        expect(result.reason).to eq(:invalid_state)
      end
    end
  end
 
  def build_test_address
    OrderContext::DeliveryAddress.new(
      postal_code: '1500001',
      prefecture: '東京都',
      city: '渋谷区',
      street: '神宮前1-1-1',
      recipient_name: 'テスト 太郎'
    )
  end
end

テストの実行時間: DBありのテストが約300ms/件 → DBなしのテストが約2ms/件。100件のテストで30秒 → 0.2秒。

INFO

インメモリリポジトリはテスト専用ではない。ステージング環境のデータ初期化、プロトタイプ開発、デモ環境にも使える。また、将来的に別のデータベース(MongoDB、DynamoDBなど)に移行する場合も、新しいリポジトリ実装を作るだけで済む。

ActiveRecordとの役割分担

リポジトリパターンとActiveRecordの役割分担を明確にする。

責務担当
ビジネスロジックドメインオブジェクト(Order, Stock等)
永続化の抽象化リポジトリインターフェース
SQL・クエリ構築ActiveRecordリポジトリ実装
スキーマ・マイグレーションActiveRecord Migration
DB制約バリデーションActiveRecord Model
ドメインバリデーションドメインオブジェクト
# ActiveRecord モデルは薄く保つ — ORMの責務のみ
class OrderRecord < ApplicationRecord
  self.table_name = 'orders'
 
  belongs_to :customer_record, foreign_key: :customer_id, optional: true
  has_many :order_item_records, foreign_key: :order_id, dependent: :destroy
 
  # DB制約のバリデーション(ドメインバリデーションとは別)
  validates :customer_id, :status, presence: true
  validates :status, inclusion: { in: %w[pending confirmed shipped delivered cancelled] }
 
  # クエリの再利用のためのスコープ
  scope :pending,       -> { where(status: 'pending') }
  scope :confirmed,     -> { where(status: 'confirmed') }
  scope :by_customer,   ->(id) { where(customer_id: id) }
  scope :recent,        -> { order(created_at: :desc) }
  scope :stale_pending, -> { pending.where('created_at < ?', 30.minutes.ago) }
 
  # ビジネスロジックはここには書かない
  # confirmやcancelのメソッドは不要
end

依存性の注入でリポジトリを切り替える

# DIコンテナを使う(production)
module Container
  module_function
 
  def order_repository
    @order_repository ||= case Rails.env
    when 'test'
      OrderContext::InMemoryOrderRepository.new
    else
      OrderContext::ActiveRecordOrderRepository.new
    end
  end
end
 
# Railsコントローラーでの使用
class Api::V1::OrdersController < ApplicationController
  before_action :authenticate_customer!
 
  def confirm
    command = OrderContext::Commands::ConfirmOrderCommand.new(
      order_id: params[:id],
      customer_id: current_customer.id
    )
 
    use_case = OrderContext::ConfirmOrderUseCase.new(
      order_repository: Container.order_repository,
      event_bus: EventBus
    )
 
    result = use_case.call(command)
 
    if result.success?
      render json: { order: OrderPresenter.new(result.order).to_h }, status: :ok
    else
      render json: { error: result.message }, status: error_status(result.reason)
    end
  end
end

AWS環境での考慮事項

本番環境(AWS ECS + RDS Aurora)でのリポジトリの工夫。

Loading diagram...
class ActiveRecordOrderRepository
  # 読み取りはレプリカを使用(負荷分散)
  def find(id)
    ActiveRecord::Base.connected_to(role: :reading) do
      record = OrderRecord.includes(:order_item_records).find_by(id: id)
      raise OrderNotFound unless record
      reconstruct(record)
    end
  end
 
  def find_by_customer(customer_id, status: nil, limit: nil)
    ActiveRecord::Base.connected_to(role: :reading) do
      scope = OrderRecord.includes(:order_item_records)
                         .by_customer(customer_id)
                         .recent
      scope = scope.where(status: status.to_s) if status
      scope = scope.limit(limit) if limit
      scope.map { |r| reconstruct(r) }
    end
  end
 
  # 書き込みはプライマリのみ
  def save(order)
    ActiveRecord::Base.connected_to(role: :writing) do
      # ...
    end
  end
end

Golangでの実装例

Railsとは異なるアプローチでリポジトリを実装する場合の比較。

// interfaces/order_repository.go
type OrderRepository interface {
	FindByID(ctx context.Context, id string) (*Order, error)
	FindByCustomer(ctx context.Context, customerID string, status *OrderStatus) ([]*Order, error)
	Save(ctx context.Context, order *Order) error
	Delete(ctx context.Context, id string) error
}
 
// infrastructure/postgres_order_repository.go
type PostgresOrderRepository struct {
	db *pgxpool.Pool
}
 
func (r *PostgresOrderRepository) FindByID(ctx context.Context, id string) (*Order, error) {
	row := r.db.QueryRow(ctx, `
		SELECT id, customer_id, status, confirmed_at, cancelled_at
		FROM orders
		WHERE id = $1
	`, id)
 
	var record orderRecord
	if err := row.Scan(
		&record.ID,
		&record.CustomerID,
		&record.Status,
		&record.ConfirmedAt,
		&record.CancelledAt,
	); err != nil {
		if errors.Is(err, pgx.ErrNoRows) {
			return nil, ErrOrderNotFound
		}
		return nil, fmt.Errorf("FindByID: %w", err)
	}
 
	return reconstructOrder(record), nil
}
 
// infrastructure/inmemory_order_repository.go
type InMemoryOrderRepository struct {
	mu    sync.RWMutex
	store map[string]*Order
}
 
func (r *InMemoryOrderRepository) FindByID(_ context.Context, id string) (*Order, error) {
	r.mu.RLock()
	defer r.mu.RUnlock()
 
	order, ok := r.store[id]
	if !ok {
		return nil, ErrOrderNotFound
	}
	return order.Clone(), nil // コピーを返す
}

WARNING

リポジトリパターンの罠: ActiveRecordをそのまま返すリポジトリは「なんちゃってリポジトリ」だ。def find(id); Order.find(id); end というだけでは、ActiveRecordへの依存を隠しているだけで、ドメインとインフラの分離を達成していない。ドメインオブジェクトに変換して返すことが重要だ。

パフォーマンスの考慮: CQRS的アプローチ

複雑な集計クエリや読み取り専用の一覧表示には、ドメインオブジェクトへの変換は不要だ。

class OrderQueryService
  # CQRSのQuery側: 読み取り専用、高速なSQL直接実行
  def monthly_summary(customer_id:, year:, month:)
    OrderRecord
      .where(customer_id: customer_id)
      .where(status: 'confirmed')
      .where(
        confirmed_at: Date.new(year, month, 1).beginning_of_month..
                      Date.new(year, month, 1).end_of_month
      )
      .group("DATE(confirmed_at)")
      .select("DATE(confirmed_at) as date, COUNT(*) as order_count, SUM(total_amount_cents) as total_cents")
      .map do |row|
        {
          date: row.date,
          order_count: row.order_count,
          total_amount: SharedKernel::Money.new(amount: row.total_cents, currency: :jpy)
        }
      end
  end
 
  def recent_orders_for_display(customer_id:, limit: 10)
    # ドメインオブジェクトへの変換は不要(表示用なのでDTOで十分)
    OrderRecord
      .where(customer_id: customer_id)
      .includes(:order_item_records)
      .recent
      .limit(limit)
      .map do |record|
        OrderDisplayDto.new(
          id: record.id,
          status: record.status,
          total_amount: record.total_amount_cents,
          item_count: record.order_item_records.sum(:quantity),
          created_at: record.created_at
        )
      end
  end
end
 
OrderDisplayDto = Struct.new(
  :id, :status, :total_amount, :item_count, :created_at,
  keyword_init: true
)

リナの学び

「テストが書きやすくなっただけじゃない」

リポジトリパターンを導入した後、思わぬ副産物があった。データベースの変更が容易になった。

「もし将来、注文データをPostgreSQLからDynamoDBに移行する必要が出てきたとする。リポジトリを導入する前は、コードベース全体にActiveRecordの呼び出しが散在していて、移行は悪夢だった。今は DynamoDBOrderRepository を実装すれば、コントローラーの依存注入を変えるだけで移行できる。ドメインコードは一切触らない」

リポジトリパターンの本当の価値は「テストしやすさ」だけでなく「技術的決定の先送り」にあった。今はPostgreSQLを使う。でも将来変えなければならなくなっても、変更は最小限で済む。

まとめ

  • リポジトリ = ドメインオブジェクトの永続化を抽象化するインターフェース
  • 2つの実装 = ActiveRecord実装(本番)とInMemory実装(テスト)
  • 依存の方向 = ドメイン層はインターフェースのみに依存(実装を知らない)
  • テスト効果 = DBなしで高速テスト、CIが劇的に高速化
  • パフォーマンス = 集計・一覧表示はCQRS的にActiveRecordを直接使う

次の章では、エンティティに属さないビジネスロジックを担う「ドメインサービス」を学ぶ。割引計算のロジックはどこに書くべきかという問題を解決する。