リポジトリパターン — データアクセスの抽象化
「このコードのテストが書けない」
リナは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
endDBを使うテストは遅い。100件あれば数分かかる。1000件あればCIが20分になる。
リポジトリパターンとは
リポジトリ(Repository)は、ドメインオブジェクトのコレクションをメモリ上のコレクションのように扱うための抽象化だ。ドメイン層はリポジトリのインターフェースのみを知り、実際のデータアクセス実装(SQL、API、ファイル)を知らない。
INFO
リポジトリの重要な考え方: ドメイン層から見ると、リポジトリは「注文のコレクション」に見える。find、save、delete というシンプルな操作のみを提供する。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
endActiveRecord実装(本番用)
本番環境で使用する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
endAWS環境での考慮事項
本番環境(AWS ECS + RDS Aurora)でのリポジトリの工夫。
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
endGolangでの実装例
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を直接使う
次の章では、エンティティに属さないビジネスロジックを担う「ドメインサービス」を学ぶ。割引計算のロジックはどこに書くべきかという問題を解決する。