リポジトリパターン — データアクセスを抽象化する
「SQLが散らばっている」
「ユウキ、このコードを見て。何が気になる?」
アヤカがコードレビューのコメントを見せた。
# 様々なファイルに散らばったクエリ
# controllers/orders_controller.rb
@orders = Order.where(user: current_user)
.where("created_at > ?", 1.month.ago)
.includes(:products)
# services/report_service.rb
orders = Order.where(status: "delivered")
.where("total_amount > ?", 10000)
.joins(:user)
# jobs/cleanup_job.rb
old_orders = Order.where(status: "pending")
.where("created_at < ?", 7.days.ago)
# services/analytics_service.rb
popular = Order.group(:user_id)
.select("user_id, COUNT(*) as count")
.where("created_at > ?", 30.days.ago)
.order("count DESC")「同じようなクエリが色々なところにある……?」
「そう。これをクエリの散在という。問題は、Orderテーブルにカラムを追加したとき、全部のファイルを探して直さないといけない。どこに書いてあるかもわからない」
「あと、このコードのテストを書こうとすると全部DBが必要になる。テストが遅くなる一方だよ」
リポジトリパターンとは
リポジトリパターンは、データストレージへのアクセスを一箇所に集約するパターン。
INFO
リポジトリは「データの本棚」。本の中身(ドメインロジック)は知らなくていい。「どこに保管するか」「どうやって取り出すか」だけを管理する。サービス層はDBがPostgreSQLでもDynamoDBでも気にしなくていい。
Rails でのリポジトリ実装
基本的なリポジトリ
# app/repositories/order_repository.rb
class OrderRepository
# 単一件取得
def find(id)
Order.find(id)
rescue ActiveRecord::RecordNotFound
raise OrderNotFoundError, "Order##{id} が見つかりません"
end
# ユーザーの注文一覧
def find_by_user(user, status: nil, since: nil, page: 1, per: 20)
scope = Order.where(user: user)
scope = scope.where(status: status) if status.present?
scope = scope.where("created_at > ?", since) if since
scope.includes(:order_items, :products)
.order(created_at: :desc)
.page(page).per(per)
end
# 最近のペンディング注文(クリーンアップ用)
def recent_pending(since: 7.days.ago)
Order.where(status: :pending)
.where("created_at > ?", since)
.order(created_at: :asc)
end
# 古いペンディング注文(自動キャンセル用)
def stale_pending(older_than: 7.days.ago)
Order.where(status: :pending)
.where("created_at < ?", older_than)
end
# 高額注文(VIP判定用)
def high_value(threshold: 50_000)
Order.where("total_amount >= ?", threshold)
.where(status: :delivered)
.includes(:user)
end
# 直近N件
def recent(limit: 10)
Order.order(created_at: :desc).limit(limit).includes(:user)
end
# 保存
def save(order)
order.save!
order
rescue ActiveRecord::RecordInvalid => e
raise OrderPersistenceError, e.message
end
# 更新
def update(order, attributes)
order.update!(attributes)
order
rescue ActiveRecord::RecordInvalid => e
raise OrderPersistenceError, e.message
end
# 複数保存(トランザクション)
def save_all(orders)
ActiveRecord::Base.transaction do
orders.each { |o| save(o) }
end
end
# 件数
def count_by_user(user)
Order.where(user: user).count
end
endクエリオブジェクトとの組み合わせ
複雑な検索条件はクエリオブジェクトに切り出す。
# app/queries/order_search_query.rb
class OrderSearchQuery
attr_reader :relation
def initialize(relation = Order.all)
@relation = relation
end
def call(params)
result = @relation.includes(:user, order_items: :product)
result = filter_by_status(result, params[:status])
result = filter_by_user(result, params[:user_id])
result = filter_by_date_range(result, params[:from], params[:to])
result = filter_by_amount(result, params[:min_amount], params[:max_amount])
result = apply_sort(result, params[:sort])
result = apply_pagination(result, params[:page], params[:per])
result
end
private
def filter_by_status(scope, status)
status.present? ? scope.where(status: status) : scope
end
def filter_by_user(scope, user_id)
user_id.present? ? scope.where(user_id: user_id) : scope
end
def filter_by_date_range(scope, from, to)
scope = scope.where("orders.created_at >= ?", from.to_date.beginning_of_day) if from.present?
scope = scope.where("orders.created_at <= ?", to.to_date.end_of_day) if to.present?
scope
end
def filter_by_amount(scope, min, max)
scope = scope.where("total_amount >= ?", min.to_f) if min.present?
scope = scope.where("total_amount <= ?", max.to_f) if max.present?
scope
end
def apply_sort(scope, sort)
case sort
when "amount_desc" then scope.order(total_amount: :desc)
when "amount_asc" then scope.order(total_amount: :asc)
when "newest" then scope.order(created_at: :desc)
when "oldest" then scope.order(created_at: :asc)
else scope.order(created_at: :desc)
end
end
def apply_pagination(scope, page, per)
scope.page(page || 1).per(per || 20)
end
end
# リポジトリでクエリオブジェクトを使う
class OrderRepository
def search(params)
OrderSearchQuery.new.call(params)
end
endテスタブルなリポジトリ
リポジトリパターンの真価はテスト時に現れる。
# インターフェースの定義(Rubyなのでドキュメントとして)
# app/repositories/concerns/order_repository_interface.rb
module OrderRepositoryInterface
def find(id) = raise NotImplementedError
def find_by_user(user, **options) = raise NotImplementedError
def save(order) = raise NotImplementedError
def count_by_user(user) = raise NotImplementedError
end# テスト用のインメモリリポジトリ
# spec/support/in_memory_order_repository.rb
class InMemoryOrderRepository
include OrderRepositoryInterface
attr_reader :store
def initialize
@store = {}
@next_id = 1
end
def find(id)
@store[id.to_i] || raise(OrderNotFoundError, "Order##{id} が見つかりません")
end
def find_by_user(user, status: nil, page: 1, per: 20, **)
results = @store.values.select { |o| o.user_id == user.id }
results = results.select { |o| o.status == status.to_s } if status
results.sort_by { |o| -o.created_at.to_i }[(page - 1) * per, per] || []
end
def stale_pending(older_than: 7.days.ago)
@store.values.select { |o|
o.status == "pending" && o.created_at < older_than
}
end
def save(order)
if order.id.nil?
order.id = @next_id
@next_id += 1
end
@store[order.id] = order
order
end
def count_by_user(user)
@store.values.count { |o| o.user_id == user.id }
end
def all
@store.values
end
def count
@store.size
end
def clear
@store.clear
@next_id = 1
end
end# spec/services/order_creation_service_spec.rb
RSpec.describe OrderCreationService do
let(:user) { build_stubbed(:user, id: 1, points: 0) }
let(:product) { create(:product, price: 1000, stock: 10) }
# DBを使わないテスト(高速)
let(:order_repo) { InMemoryOrderRepository.new }
let(:service) do
OrderCreationService.new(
user: user,
items: items,
order_repository: order_repo
)
end
context "有効な注文" do
let(:items) { [{ product_id: product.id, quantity: 2 }] }
it "注文が保存される" do
result = service.call
expect(order_repo.count).to eq(1)
end
it "合計金額が正しい" do
result = service.call
expect(result.order.total_amount).to eq(2000)
end
end
context "存在しない商品ID" do
let(:items) { [{ product_id: 99999, quantity: 1 }] }
it "エラーを返す" do
result = service.call
expect(result.success?).to be false
end
end
endINFO
インメモリリポジトリを使うと、DBへの接続なしにビジネスロジックをテストできる。テストスイートの速度が10〜100倍速くなることも珍しくない。CI/CDのフィードバックが速くなり、開発速度が上がる。
ActiveRecord スコープとの住み分け
「リポジトリを作ると、モデルのスコープはどうなりますか?」
「いい質問。スコープはなくならない。使い分けがある」
# モデルのスコープ:条件の断片(再利用可能な部品)
class Order < ApplicationRecord
scope :pending, -> { where(status: :pending) }
scope :confirmed, -> { where(status: :confirmed) }
scope :delivered, -> { where(status: :delivered) }
scope :recent, -> { where("created_at > ?", 1.month.ago) }
scope :high_value, ->(threshold) { where("total_amount >= ?", threshold) }
scope :with_items, -> { includes(:order_items, :products) }
end
# リポジトリ:スコープを組み合わせてビジネスの意味を持たせる
class OrderRepository
# ビジネスの言語でクエリを表現する
def cleanup_targets
Order.pending.where("created_at < ?", 7.days.ago)
end
def vip_candidates
Order.delivered.high_value(50_000).with_items.includes(:user)
end
def monthly_revenue_data(year, month)
Order.delivered
.where(created_at: Time.new(year, month).all_month)
.group("DATE(created_at)")
.sum(:total_amount)
end
def top_customers(limit: 10)
Order.delivered
.group(:user_id)
.select("user_id, SUM(total_amount) as total_spent")
.order("total_spent DESC")
.limit(limit)
end
end| 場所 | 責任 | 例 |
|---|---|---|
| スコープ | 条件の断片(再利用部品) | where(status: :pending) |
| リポジトリ | ビジネスの問い | 「クリーンアップ対象の注文」 |
| クエリオブジェクト | 複雑な動的検索 | 検索フォームの条件 |
AWS でのデータアクセス最適化
# 読み書き分離とキャッシュを隠蔽するリポジトリ
class OrderRepository
CACHE_TTL = 5.minutes
def find(id)
cache_key = "order:#{id}:v2"
Rails.cache.fetch(cache_key, expires_in: CACHE_TTL) do
# 読み取りはレプリカから
Order.connected_to(role: :reading) do
Order.includes(:order_items, :products, :user).find(id)
end
end
rescue ActiveRecord::RecordNotFound
raise OrderNotFoundError, "Order##{id} が見つかりません"
end
def save(order)
# 書き込みはプライマリへ
Order.connected_to(role: :writing) do
order.save!
end
# キャッシュを無効化
invalidate_cache(order.id)
invalidate_user_cache(order.user_id)
order
end
def update(order, attributes)
Order.connected_to(role: :writing) do
order.update!(attributes)
end
invalidate_cache(order.id)
order
end
private
def invalidate_cache(order_id)
Rails.cache.delete("order:#{order_id}:v2")
end
def invalidate_user_cache(user_id)
Rails.cache.delete_matched("order_list:#{user_id}:*")
end
end呼び出し元のサービスは、読み書き分離やキャッシュを一切知らなくていい。
複数データソースのリポジトリ
将来、注文データをS3に移したり、Elasticsearchで検索したいときも、リポジトリ内部の変更だけで対応できる:
# app/repositories/order_search_repository.rb
class OrderSearchRepository
def search(query:, user_id: nil, page: 1)
es_query = build_es_query(query, user_id)
response = ElasticsearchClient.search(
index: "orders",
body: es_query
)
order_ids = response["hits"]["hits"].map { |hit| hit["_id"].to_i }
Order.where(id: order_ids)
.index_by(&:id)
.values_at(*order_ids)
.compact
end
private
def build_es_query(query, user_id)
must_conditions = [
{ match: { "product_names": query } }
]
must_conditions << { term: { user_id: user_id } } if user_id
{
query: { bool: { must: must_conditions } },
size: 20
}
end
end# サービス層は「どこから検索するか」を知らなくていい
class OrderService
def initialize(order_repo: OrderRepository.new, search_repo: OrderSearchRepository.new)
@order_repo = order_repo
@search_repo = search_repo
end
def search(query, user_id: nil)
if query.present?
@search_repo.search(query: query, user_id: user_id)
else
@order_repo.find_by_user(User.find(user_id))
end
end
endGolang でのリポジトリパターン
Golangでは、インターフェースで明示的に契約を定義する。
// domain/repositories.go
package domain
import "context"
// リポジトリインターフェース(ドメイン層で定義)
type OrderRepository interface {
FindByID(ctx context.Context, id int64) (*Order, error)
FindByUserID(ctx context.Context, userID int64) ([]*Order, error)
Save(ctx context.Context, order *Order) error
Update(ctx context.Context, order *Order) error
Delete(ctx context.Context, id int64) error
}// infrastructure/postgres_order_repository.go
package infrastructure
import (
"context"
"database/sql"
"myapp/domain"
)
type PostgresOrderRepository struct {
db *sql.DB
}
func NewPostgresOrderRepository(db *sql.DB) domain.OrderRepository {
return &PostgresOrderRepository{db: db}
}
func (r *PostgresOrderRepository) FindByID(ctx context.Context, id int64) (*domain.Order, error) {
query := `
SELECT o.id, o.user_id, o.total_amount, o.status, o.created_at
FROM orders o
WHERE o.id = $1
`
row := r.db.QueryRowContext(ctx, query, id)
var order domain.Order
err := row.Scan(&order.ID, &order.UserID, &order.TotalAmount, &order.Status, &order.CreatedAt)
if err == sql.ErrNoRows {
return nil, domain.ErrOrderNotFound
}
if err != nil {
return nil, fmt.Errorf("注文の取得に失敗しました: %w", err)
}
return &order, nil
}
func (r *PostgresOrderRepository) Save(ctx context.Context, order *domain.Order) error {
query := `
INSERT INTO orders (user_id, total_amount, status, created_at)
VALUES ($1, $2, $3, NOW())
RETURNING id
`
err := r.db.QueryRowContext(ctx, query,
order.UserID, order.TotalAmount, order.Status,
).Scan(&order.ID)
if err != nil {
return fmt.Errorf("注文の保存に失敗しました: %w", err)
}
return nil
}// infrastructure/memory_order_repository.go (テスト用)
package infrastructure
import (
"context"
"myapp/domain"
"sync"
)
type MemoryOrderRepository struct {
mu sync.RWMutex
orders map[int64]*domain.Order
nextID int64
}
func NewMemoryOrderRepository() domain.OrderRepository {
return &MemoryOrderRepository{
orders: make(map[int64]*domain.Order),
nextID: 1,
}
}
func (r *MemoryOrderRepository) FindByID(ctx context.Context, id int64) (*domain.Order, error) {
r.mu.RLock()
defer r.mu.RUnlock()
order, ok := r.orders[id]
if !ok {
return nil, domain.ErrOrderNotFound
}
return order, nil
}
func (r *MemoryOrderRepository) Save(ctx context.Context, order *domain.Order) error {
r.mu.Lock()
defer r.mu.Unlock()
order.ID = r.nextID
r.orders[r.nextID] = order
r.nextID++
return nil
}WARNING
リポジトリパターンはオーバーエンジニアリングになりやすい。全モデルにリポジトリを作る必要はない。まず普通のActiveRecordで始め、クエリが3箇所以上に散らばってきたタイミングで導入する。テストが書きにくいと感じたら導入のサインだ。
Railsのディレクトリ構成
app/
├── repositories/
│ ├── order_repository.rb
│ ├── product_repository.rb
│ └── coupon_repository.rb
└── queries/
├── order_search_query.rb
└── product_search_query.rb
spec/
└── support/
├── in_memory_order_repository.rb
└── in_memory_product_repository.rb
「これを導入するのは、いつが正しいタイミングですか?」
「クエリが3箇所以上に散らばったとき、が私の判断基準」アヤカが言った。「あと、テストが書きにくいと感じたとき。DBとのやり取りをモックしたくなったら、リポジトリパターンのサインだよ」
まとめ
リポジトリパターンをまとめると:
- 集約: データアクセスロジックを一箇所に
- 抽象化: サービス層はDBの存在を知らなくていい
- テスト: インメモリ実装でDB不要のテスト
- 変更への強さ: DBをDynamoDBに変えても、サービス層は無変更
- 可読性: ビジネスの言語でクエリを命名できる
| 前 | 後 |
|---|---|
| クエリが10箇所に散在 | リポジトリの1メソッドに集約 |
| テストにDBが必要 | InMemoryで高速テスト |
| SQLの変更が怖い | リポジトリ内のみ変更 |
| 「どこに書いたっけ?」 | リポジトリを見れば全部ある |
次章では、イベント駆動アーキテクチャを学びます。注文が確定したとき「メールを送って、ポイントを付与して、Slack通知して」を直接呼ぶのではなく、イベントを発行して疎結合にする方法を見ていきます。