mybook

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

「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が必要になる。テストが遅くなる一方だよ」

リポジトリパターンとは

リポジトリパターンは、データストレージへのアクセスを一箇所に集約するパターン。

Loading diagram...

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
end

INFO

インメモリリポジトリを使うと、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 でのデータアクセス最適化

Loading diagram...
# 読み書き分離とキャッシュを隠蔽するリポジトリ
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
end

Golang でのリポジトリパターン

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通知して」を直接呼ぶのではなく、イベントを発行して疎結合にする方法を見ていきます。