ROM (Ruby Object Mapper)

ROM(Ruby Object Mapper)は「関数型プログラミングの原則に基づいたRubyのデータマッピングツールキット」として設計された、従来のActive Recordパターンに代わる新しいアプローチのデータ永続化ライブラリです。「コマンドとクエリの責任分離(CQRS)」と「関数型プログラミング」の概念を採用し、イミュータブルなオブジェクト、純粋関数、明示的な依存関係により、高い保守性と予測可能性を実現します。複数のデータベース(PostgreSQL、MySQL、SQLite、Redis、Elasticsearch等)と統一的なAPIでやり取りでき、複雑なエンタープライズアプリケーションにおけるデータ層の設計を大幅に改善します。

RubyデータベースFunctional ProgrammingPersistenceデータマッパーRepository

ライブラリ

ROM (Ruby Object Mapper)

概要

ROM(Ruby Object Mapper)は「関数型プログラミングの原則に基づいたRubyのデータマッピングツールキット」として設計された、従来のActive Recordパターンに代わる新しいアプローチのデータ永続化ライブラリです。「コマンドとクエリの責任分離(CQRS)」と「関数型プログラミング」の概念を採用し、イミュータブルなオブジェクト、純粋関数、明示的な依存関係により、高い保守性と予測可能性を実現します。複数のデータベース(PostgreSQL、MySQL、SQLite、Redis、Elasticsearch等)と統一的なAPIでやり取りでき、複雑なエンタープライズアプリケーションにおけるデータ層の設計を大幅に改善します。

詳細

ROM 2025年版は、Ruby 3.3の最新機能を完全活用し、より表現力豊かで安全なデータアクセス層を提供します。Repository Pattern、Data Mapper Pattern、Unit of Workパターンの組み合わせにより、ドメイン駆動設計(DDD)との親和性が非常に高く、クリーンアーキテクチャの実装を強力にサポートします。マルチテナント対応、読み書き分離、カスタムデータ型支援、包括的バリデーションシステムなど、現代的なWebアプリケーション開発に必要な機能を包括的に提供。また、関数型プログラミングの利点を活かし、サイドエフェクトを最小限に抑えた予測可能なデータ操作を実現しています。

主な特徴

  • 関数型アプローチ: イミュータブルオブジェクトと純粋関数による予測可能な処理
  • CQRS対応: コマンドとクエリの明確な分離
  • Repository Pattern: データアクセスの抽象化と依存性逆転
  • マルチアダプター: 複数データベースの統一的操作
  • Schema Definition: 型安全なスキーマ定義とバリデーション
  • DDD統合: ドメイン駆動設計との自然な統合

メリット・デメリット

メリット

  • Active Recordの「神クラス」問題を解決する関数型設計
  • イミュータブルオブジェクトによる予測可能性と安全性の向上
  • 複数データベースを同一APIで操作できる高い柔軟性
  • テスタビリティの大幅向上と副作用の明示的管理
  • ドメイン駆動設計やクリーンアーキテクチャとの優れた親和性
  • 関数型プログラミングの利点を活かした保守性の高いコード

デメリット

  • 学習コストが高く、関数型プログラミングの理解が必要
  • Active Recordに慣れた開発者には概念的な転換が困難
  • 設定とセットアップが複雑で、小規模プロジェクトには過剰
  • Railsエコシステムとの統合がActive Recordほど自然ではない
  • 日本語ドキュメントやコミュニティリソースが限定的
  • 豊富なActive Record用プラグインを活用できない

参考ページ

書き方の例

セットアップ

# Gemfile
gem 'rom', '~> 5.3'
gem 'rom-sql', '~> 3.6'
gem 'rom-postgres', '~> 3.0'  # PostgreSQL用
gem 'rom-repository', '~> 5.3'

# 開発・テスト用
gem 'rom-factory', '~> 0.11', group: [:development, :test]

# インストール
bundle install
# config/database.yml (Rails使用時)
default: &default
  adapter: postgresql
  encoding: unicode
  pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>

development:
  <<: *default
  database: myapp_development
  username: postgres
  password: password
  host: localhost

test:
  <<: *default
  database: myapp_test
  username: postgres
  password: password
  host: localhost

production:
  <<: *default
  database: myapp_production
  username: <%= ENV['DATABASE_USER'] %>
  password: <%= ENV['DATABASE_PASSWORD'] %>
  host: <%= ENV['DATABASE_HOST'] %>
# config/rom.rb
require 'rom'
require 'rom-sql'
require 'rom-repository'

# ROM設定
ROM_CONFIG = ROM::Configuration.new(:sql, ENV['DATABASE_URL'])

# スキーマ定義の読み込み
ROM_CONFIG.auto_registration('./app/persistence')

# コンテナの作成
ROM_CONTAINER = ROM.container(ROM_CONFIG)

スキーマとリレーション定義

# app/persistence/relations/users.rb
module Relations
  class Users < ROM::Relation[:sql]
    schema(:users, infer: true) do
      attribute :id, ROM::Types::Integer.meta(primary_key: true)
      attribute :name, ROM::Types::String
      attribute :email, ROM::Types::String
      attribute :age, ROM::Types::Integer
      attribute :is_active, ROM::Types::Bool
      attribute :created_at, ROM::Types::DateTime
      attribute :updated_at, ROM::Types::DateTime
      
      primary_key :id
      
      associations do
        has_many :posts, foreign_key: :user_id
        has_one :profile, foreign_key: :user_id
      end
    end
    
    # クエリメソッド
    def active
      where(is_active: true)
    end
    
    def by_age_range(min_age, max_age)
      where { age >= min_age }.where { age <= max_age }
    end
    
    def search_by_name(pattern)
      where { name.ilike("%#{pattern}%") }
    end
    
    def recent(limit = 20)
      order(:created_at).reverse.limit(limit)
    end
    
    def with_post_count
      select_append { integer::count(posts[:id]).as(:post_count) }
        .left_join(:posts)
        .group(:users[:id])
    end
    
    def adults
      where { age >= 18 }
    end
    
    def by_email(email)
      where(email: email)
    end
  end
end

# app/persistence/relations/posts.rb
module Relations
  class Posts < ROM::Relation[:sql]
    schema(:posts, infer: true) do
      attribute :id, ROM::Types::Integer.meta(primary_key: true)
      attribute :title, ROM::Types::String
      attribute :content, ROM::Types::String
      attribute :excerpt, ROM::Types::String.optional
      attribute :is_published, ROM::Types::Bool
      attribute :published_at, ROM::Types::DateTime.optional
      attribute :user_id, ROM::Types::Integer.meta(foreign_key: true)
      attribute :category_id, ROM::Types::Integer.meta(foreign_key: true).optional
      attribute :view_count, ROM::Types::Integer
      attribute :created_at, ROM::Types::DateTime
      attribute :updated_at, ROM::Types::DateTime
      
      primary_key :id
      
      associations do
        belongs_to :user, foreign_key: :user_id
        belongs_to :category, foreign_key: :category_id
        has_many :comments, foreign_key: :post_id
      end
    end
    
    def published
      where(is_published: true)
    end
    
    def by_user(user_id)
      where(user_id: user_id)
    end
    
    def by_category(category_id)
      where(category_id: category_id)
    end
    
    def popular(limit = 10)
      where(is_published: true)
        .order(:view_count)
        .reverse
        .limit(limit)
    end
    
    def recent_published(limit = 20)
      published
        .order(:published_at)
        .reverse
        .limit(limit)
    end
    
    def search_content(pattern)
      where { title.ilike("%#{pattern}%") | content.ilike("%#{pattern}%") }
    end
    
    def with_author
      combine(:user)
    end
    
    def with_category
      combine(:category)
    end
    
    def with_comments_count
      select_append { integer::count(comments[:id]).as(:comments_count) }
        .left_join(:comments)
        .group(:posts[:id])
    end
  end
end

# app/persistence/relations/categories.rb
module Relations
  class Categories < ROM::Relation[:sql]
    schema(:categories, infer: true) do
      attribute :id, ROM::Types::Integer.meta(primary_key: true)
      attribute :name, ROM::Types::String
      attribute :description, ROM::Types::String.optional
      attribute :color, ROM::Types::String
      attribute :is_active, ROM::Types::Bool
      attribute :sort_order, ROM::Types::Integer
      attribute :created_at, ROM::Types::DateTime
      
      primary_key :id
      
      associations do
        has_many :posts, foreign_key: :category_id
      end
    end
    
    def active
      where(is_active: true)
    end
    
    def ordered
      order(:sort_order, :name)
    end
    
    def with_post_count
      select_append { integer::count(posts[:id]).as(:post_count) }
        .left_join(:posts)
        .group(:categories[:id])
    end
  end
end

リポジトリパターンの実装

# app/repositories/user_repository.rb
class UserRepository < ROM::Repository[:users]
  include ROM::Repository::Plugins::CommandAPIDefinition
  
  commands :create, :update, :delete
  
  # リレーション読み込み設定
  def all
    users.to_a
  end
  
  def find(id)
    users.by_pk(id).one!
  end
  
  def find_by_email(email)
    users.by_email(email).one
  end
  
  def active_users
    users.active.to_a
  end
  
  def users_by_age_range(min_age, max_age)
    users.by_age_range(min_age, max_age).to_a
  end
  
  def search_users(name_pattern)
    users.search_by_name(name_pattern).to_a
  end
  
  def recent_users(limit = 20)
    users.recent(limit).to_a
  end
  
  def users_with_post_count
    users.with_post_count.to_a
  end
  
  def adult_users
    users.adults.to_a
  end
  
  # ユーザー作成(バリデーション付き)
  def create_user(attributes)
    changeset = users.changeset(:create, attributes)
    command = users.command(:create)
    
    if changeset.valid?
      command.call(changeset)
    else
      raise ROM::ValidationError, changeset.errors
    end
  end
  
  # ユーザー更新
  def update_user(id, attributes)
    user = find(id)
    changeset = users.changeset(:update, attributes)
    command = users.command(:update)
    
    if changeset.valid?
      command.by_pk(id).call(changeset)
    else
      raise ROM::ValidationError, changeset.errors
    end
  end
  
  # ユーザー削除(関連データも削除)
  def delete_user(id)
    # 関連投稿を先に削除
    posts.command(:delete).where(user_id: id).call
    
    # ユーザー削除
    users.command(:delete).by_pk(id).call
  end
  
  # ユーザー統計
  def user_statistics
    total_users = users.count
    active_users_count = users.active.count
    average_age = users.dataset.avg(:age)
    
    {
      total: total_users,
      active: active_users_count,
      average_age: average_age&.to_f&.round(2) || 0.0
    }
  end
  
  # 複雑なクエリ例
  def users_with_recent_posts(days = 7)
    cutoff_date = Date.current - days.days
    
    users
      .join(:posts)
      .where { posts[:created_at] >= cutoff_date }
      .select(:users[:id], :users[:name], :users[:email])
      .group(:users[:id])
      .to_a
  end
  
  # トランザクション処理
  def transfer_posts(from_user_id, to_user_id)
    ROM_CONTAINER.gateways[:default].transaction do
      from_user = find(from_user_id)
      to_user = find(to_user_id)
      
      raise ArgumentError, "One or both users not found" unless from_user && to_user
      
      posts.command(:update)
        .where(user_id: from_user_id)
        .call(user_id: to_user_id, updated_at: Time.current)
    end
  end
  
  private
  
  def posts
    ROM_CONTAINER.relations[:posts]
  end
end

# app/repositories/post_repository.rb
class PostRepository < ROM::Repository[:posts]
  include ROM::Repository::Plugins::CommandAPIDefinition
  
  commands :create, :update, :delete
  
  def all
    posts.to_a
  end
  
  def find(id)
    posts.by_pk(id).one!
  end
  
  def published_posts
    posts.published.to_a
  end
  
  def posts_by_user(user_id)
    posts.by_user(user_id).to_a
  end
  
  def posts_by_category(category_id)
    posts.by_category(category_id).to_a
  end
  
  def popular_posts(limit = 10)
    posts.popular(limit).to_a
  end
  
  def recent_published_posts(limit = 20)
    posts.recent_published(limit).to_a
  end
  
  def search_posts(search_term)
    posts.search_content(search_term).to_a
  end
  
  def posts_with_author
    posts.combine(:user).to_a
  end
  
  def posts_with_category
    posts.combine(:category).to_a
  end
  
  def posts_with_comments_count
    posts.with_comments_count.to_a
  end
  
  # 投稿作成
  def create_post(attributes)
    # タイムスタンプの自動設定
    attributes_with_timestamps = attributes.merge(
      created_at: Time.current,
      updated_at: Time.current,
      view_count: 0
    )
    
    # 抜粋の自動生成
    if attributes_with_timestamps[:excerpt].blank? && attributes_with_timestamps[:content].present?
      attributes_with_timestamps[:excerpt] = attributes_with_timestamps[:content][0..200]
    end
    
    changeset = posts.changeset(:create, attributes_with_timestamps)
    
    if changeset.valid?
      posts.command(:create).call(changeset)
    else
      raise ROM::ValidationError, changeset.errors
    end
  end
  
  # 投稿更新
  def update_post(id, attributes)
    attributes_with_timestamp = attributes.merge(updated_at: Time.current)
    
    changeset = posts.changeset(:update, attributes_with_timestamp)
    
    if changeset.valid?
      posts.command(:update).by_pk(id).call(changeset)
    else
      raise ROM::ValidationError, changeset.errors
    end
  end
  
  # 投稿公開
  def publish_post(id)
    update_post(id, {
      is_published: true,
      published_at: Time.current
    })
  end
  
  # 投稿非公開
  def unpublish_post(id)
    update_post(id, {
      is_published: false,
      published_at: nil
    })
  end
  
  # 閲覧数増加
  def increment_view_count(id)
    post = find(id)
    update_post(id, view_count: post.view_count + 1)
  end
  
  # 投稿削除
  def delete_post(id)
    posts.command(:delete).by_pk(id).call
  end
  
  # 投稿統計
  def post_statistics
    total_posts = posts.count
    published_posts_count = posts.published.count
    draft_posts_count = total_posts - published_posts_count
    average_view_count = posts.dataset.avg(:view_count)
    
    {
      total: total_posts,
      published: published_posts_count,
      drafts: draft_posts_count,
      average_views: average_view_count&.to_f&.round(2) || 0.0
    }
  end
  
  # 最近の投稿統計
  def recent_post_statistics(days = 7)
    cutoff_date = Date.current - days.days
    
    recent_posts = posts.where { created_at >= cutoff_date }
    recent_published = recent_posts.where(is_published: true)
    
    {
      recent_total: recent_posts.count,
      recent_published: recent_published.count,
      recent_drafts: recent_posts.count - recent_published.count
    }
  end
  
  # ユーザー別投稿数
  def posts_count_by_user
    posts
      .join(:users)
      .select(:users[:id], :users[:name])
      .select_append { integer::count(posts[:id]).as(:post_count) }
      .group(:users[:id])
      .to_a
  end
end

コマンドとバリデーション

# app/persistence/commands/create_user.rb
module Commands
  class CreateUser < ROM::Commands::Create[:sql]
    relation :users
    register_as :create_user
    
    # バリデーションルール
    schema do
      required(:name).filled(:string)
      required(:email).filled(:string, format?: /@/)
      required(:age).filled(:integer, gt?: 0, lt?: 150)
      optional(:is_active).filled(:bool)
    end
    
    # フック処理
    def call(attributes)
      # デフォルト値設定
      attributes_with_defaults = attributes.merge(
        is_active: attributes.fetch(:is_active, true),
        created_at: Time.current,
        updated_at: Time.current
      )
      
      # メール重複チェック
      if users.by_email(attributes_with_defaults[:email]).exist?
        raise ROM::ValidationError, { email: ['already exists'] }
      end
      
      super(attributes_with_defaults)
    end
  end
end

# app/persistence/commands/update_user.rb
module Commands
  class UpdateUser < ROM::Commands::Update[:sql]
    relation :users
    register_as :update_user
    
    schema do
      optional(:name).filled(:string)
      optional(:email).filled(:string, format?: /@/)
      optional(:age).filled(:integer, gt?: 0, lt?: 150)
      optional(:is_active).filled(:bool)
    end
    
    def call(attributes)
      attributes_with_timestamp = attributes.merge(updated_at: Time.current)
      
      # メール重複チェック(自分以外)
      if attributes[:email] && relation.dataset.exclude(id: restriction[:id]).where(email: attributes[:email]).any?
        raise ROM::ValidationError, { email: ['already exists'] }
      end
      
      super(attributes_with_timestamp)
    end
  end
end

# app/persistence/commands/create_post.rb
module Commands
  class CreatePost < ROM::Commands::Create[:sql]
    relation :posts
    register_as :create_post
    
    schema do
      required(:title).filled(:string, min_size?: 1)
      required(:content).filled(:string, min_size?: 10)
      required(:user_id).filled(:integer)
      optional(:category_id).filled(:integer)
      optional(:is_published).filled(:bool)
      optional(:excerpt).filled(:string)
    end
    
    def call(attributes)
      # バリデーション
      user_exists = ROM_CONTAINER.relations[:users].by_pk(attributes[:user_id]).exist?
      raise ROM::ValidationError, { user_id: ['does not exist'] } unless user_exists
      
      if attributes[:category_id]
        category_exists = ROM_CONTAINER.relations[:categories].by_pk(attributes[:category_id]).exist?
        raise ROM::ValidationError, { category_id: ['does not exist'] } unless category_exists
      end
      
      # デフォルト値とタイムスタンプ設定
      attributes_with_defaults = attributes.merge(
        is_published: attributes.fetch(:is_published, false),
        view_count: 0,
        created_at: Time.current,
        updated_at: Time.current
      )
      
      # 抜粋自動生成
      if attributes_with_defaults[:excerpt].blank?
        attributes_with_defaults[:excerpt] = attributes_with_defaults[:content][0..200]
      end
      
      # 公開日時設定
      if attributes_with_defaults[:is_published]
        attributes_with_defaults[:published_at] = Time.current
      end
      
      super(attributes_with_defaults)
    end
  end
end

サービスレイヤーの実装

# app/services/user_service.rb
class UserService
  include ROM::Repository::Plugins::Helpers
  
  def initialize(container = ROM_CONTAINER)
    @container = container
    @user_repo = UserRepository.new(container)
    @post_repo = PostRepository.new(container)
  end
  
  # ユーザー作成サービス
  def create_user(params)
    # パラメータ検証
    clean_params = sanitize_user_params(params)
    
    @container.gateways[:default].transaction do
      user = @user_repo.create_user(clean_params)
      
      # ウェルカム処理(例)
      # send_welcome_email(user)
      # create_default_profile(user)
      
      user
    end
  rescue ROM::ValidationError => e
    raise ServiceError, "User creation failed: #{e.message}"
  end
  
  # ユーザー更新サービス
  def update_user(id, params)
    clean_params = sanitize_user_params(params)
    
    @container.gateways[:default].transaction do
      user = @user_repo.update_user(id, clean_params)
      
      # 更新後処理
      # update_search_index(user) if user
      
      user
    end
  rescue ROM::ValidationError => e
    raise ServiceError, "User update failed: #{e.message}"
  end
  
  # ユーザー削除サービス(安全削除)
  def delete_user(id)
    @container.gateways[:default].transaction do
      # 削除前チェック
      user = @user_repo.find(id)
      posts_count = @post_repo.posts_by_user(id).length
      
      if posts_count > 0
        # 投稿がある場合の処理選択
        # Option 1: 投稿も削除
        @user_repo.delete_user(id)
        
        # Option 2: エラーを投げる
        # raise ServiceError, "Cannot delete user with existing posts"
        
        # Option 3: 投稿を別ユーザーに移管
        # transfer_posts_to_system_user(id)
        # @user_repo.delete_user(id)
      else
        @user_repo.delete_user(id)
      end
    end
  rescue ROM::TupleCountMismatchError
    raise ServiceError, "User not found"
  end
  
  # 複雑なビジネスロジック例
  def activate_user_with_verification(id, verification_code)
    @container.gateways[:default].transaction do
      user = @user_repo.find(id)
      
      # 検証コードチェック(仮想的な実装)
      unless verify_activation_code(user, verification_code)
        raise ServiceError, "Invalid verification code"
      end
      
      # ユーザーアクティベート
      @user_repo.update_user(id, {
        is_active: true,
        verified_at: Time.current
      })
      
      # アクティベート後処理
      # send_activation_confirmation(user)
      # grant_default_permissions(user)
      
      @user_repo.find(id)
    end
  end
  
  # ユーザー検索サービス
  def search_users(query_params)
    results = @user_repo.all
    
    if query_params[:name_pattern]
      results = @user_repo.search_users(query_params[:name_pattern])
    end
    
    if query_params[:min_age] || query_params[:max_age]
      min_age = query_params[:min_age] || 0
      max_age = query_params[:max_age] || 150
      results = @user_repo.users_by_age_range(min_age, max_age)
    end
    
    if query_params[:active_only]
      results = results.select { |user| user.is_active }
    end
    
    results
  end
  
  # データエクスポートサービス
  def export_user_data(format = :json)
    users_with_stats = @user_repo.users_with_post_count
    
    case format
    when :json
      users_with_stats.map do |user|
        {
          id: user.id,
          name: user.name,
          email: user.email,
          age: user.age,
          is_active: user.is_active,
          post_count: user.post_count || 0,
          created_at: user.created_at.iso8601
        }
      end
    when :csv
      # CSV形式での出力実装
      CSV.generate do |csv|
        csv << ['ID', 'Name', 'Email', 'Age', 'Active', 'Post Count', 'Created At']
        users_with_stats.each do |user|
          csv << [user.id, user.name, user.email, user.age, user.is_active, user.post_count || 0, user.created_at.iso8601]
        end
      end
    else
      raise ServiceError, "Unsupported export format: #{format}"
    end
  end
  
  private
  
  def sanitize_user_params(params)
    {
      name: params[:name]&.strip,
      email: params[:email]&.strip&.downcase,
      age: params[:age]&.to_i,
      is_active: params.key?(:is_active) ? params[:is_active] : true
    }.compact
  end
  
  def verify_activation_code(user, code)
    # 実際の実装では、データベースから検証コードを取得して比較
    true # 簡略化
  end
  
  # カスタム例外
  class ServiceError < StandardError; end
end

# app/services/post_service.rb
class PostService
  def initialize(container = ROM_CONTAINER)
    @container = container
    @post_repo = PostRepository.new(container)
    @user_repo = UserRepository.new(container)
  end
  
  # 投稿作成サービス
  def create_post(params)
    clean_params = sanitize_post_params(params)
    
    @container.gateways[:default].transaction do
      # 作成者の存在確認
      author = @user_repo.find(clean_params[:user_id])
      raise ServiceError, "Author not found" unless author
      
      post = @post_repo.create_post(clean_params)
      
      # 作成後処理
      # notify_followers(author, post) if post.is_published
      # update_search_index(post)
      
      post
    end
  rescue ROM::ValidationError => e
    raise ServiceError, "Post creation failed: #{e.message}"
  end
  
  # 投稿公開サービス
  def publish_post(id, publish_at = nil)
    @container.gateways[:default].transaction do
      post = @post_repo.find(id)
      
      # 公開前バリデーション
      validate_post_for_publishing(post)
      
      @post_repo.publish_post(id)
      
      # 公開後処理
      # notify_subscribers(post)
      # update_sitemap
      # social_media_post(post)
      
      @post_repo.find(id)
    end
  rescue ROM::TupleCountMismatchError
    raise ServiceError, "Post not found"
  end
  
  # バルク操作サービス
  def bulk_update_posts(post_ids, attributes)
    @container.gateways[:default].transaction do
      updated_posts = []
      
      post_ids.each do |id|
        updated_post = @post_repo.update_post(id, attributes)
        updated_posts << updated_post if updated_post
      end
      
      updated_posts
    end
  rescue => e
    raise ServiceError, "Bulk update failed: #{e.message}"
  end
  
  # コンテンツ分析サービス
  def analyze_post_performance(days = 30)
    cutoff_date = Date.current - days.days
    
    # 期間内の投稿を取得
    recent_posts = @post_repo.posts.where { created_at >= cutoff_date }.to_a
    
    total_views = recent_posts.sum(&:view_count)
    avg_views = recent_posts.empty? ? 0 : total_views.to_f / recent_posts.length
    
    top_posts = recent_posts
      .select { |post| post.is_published }
      .sort_by(&:view_count)
      .reverse
      .first(10)
    
    {
      period_days: days,
      total_posts: recent_posts.length,
      published_posts: recent_posts.count(&:is_published),
      total_views: total_views,
      average_views: avg_views.round(2),
      top_posts: top_posts.map { |post| 
        { 
          id: post.id, 
          title: post.title, 
          views: post.view_count 
        } 
      }
    }
  end
  
  private
  
  def sanitize_post_params(params)
    {
      title: params[:title]&.strip,
      content: params[:content]&.strip,
      excerpt: params[:excerpt]&.strip,
      user_id: params[:user_id]&.to_i,
      category_id: params[:category_id]&.to_i,
      is_published: params.key?(:is_published) ? params[:is_published] : false
    }.compact
  end
  
  def validate_post_for_publishing(post)
    errors = []
    
    errors << "Title cannot be empty" if post.title.blank?
    errors << "Content too short" if post.content.length < 100
    errors << "Author must be active" unless post.user&.is_active
    
    raise ServiceError, errors.join(", ") if errors.any?
  end
  
  class ServiceError < StandardError; end
end

テストの書き方

# spec/repositories/user_repository_spec.rb
require 'spec_helper'

RSpec.describe UserRepository, type: :repository do
  let(:repo) { described_class.new(ROM_CONTAINER) }
  
  describe '#create_user' do
    it 'creates a user with valid attributes' do
      attributes = {
        name: 'John Doe',
        email: '[email protected]',
        age: 30
      }
      
      user = repo.create_user(attributes)
      
      expect(user.name).to eq('John Doe')
      expect(user.email).to eq('[email protected]')
      expect(user.age).to eq(30)
      expect(user.is_active).to be(true)
    end
    
    it 'raises error for invalid email' do
      attributes = {
        name: 'John Doe',
        email: 'invalid-email',
        age: 30
      }
      
      expect { repo.create_user(attributes) }.to raise_error(ROM::ValidationError)
    end
  end
  
  describe '#search_users' do
    before do
      repo.create_user(name: 'Alice Johnson', email: '[email protected]', age: 25)
      repo.create_user(name: 'Bob Smith', email: '[email protected]', age: 35)
    end
    
    it 'finds users by name pattern' do
      results = repo.search_users('Alice')
      
      expect(results.length).to eq(1)
      expect(results.first.name).to eq('Alice Johnson')
    end
  end
  
  describe '#user_statistics' do
    before do
      repo.create_user(name: 'User 1', email: '[email protected]', age: 25)
      repo.create_user(name: 'User 2', email: '[email protected]', age: 35)
    end
    
    it 'returns correct statistics' do
      stats = repo.user_statistics
      
      expect(stats[:total]).to eq(2)
      expect(stats[:active]).to eq(2)
      expect(stats[:average_age]).to eq(30.0)
    end
  end
end

# spec/services/user_service_spec.rb
require 'spec_helper'

RSpec.describe UserService do
  let(:service) { described_class.new }
  
  describe '#create_user' do
    it 'creates user successfully' do
      params = {
        name: 'Jane Doe',
        email: '[email protected]',
        age: 28
      }
      
      user = service.create_user(params)
      
      expect(user.name).to eq('Jane Doe')
      expect(user.email).to eq('[email protected]')
    end
    
    it 'handles validation errors gracefully' do
      params = {
        name: '',
        email: 'invalid',
        age: -5
      }
      
      expect { service.create_user(params) }.to raise_error(UserService::ServiceError)
    end
  end
  
  describe '#search_users' do
    before do
      service.create_user(name: 'Alice', email: '[email protected]', age: 25)
      service.create_user(name: 'Bob', email: '[email protected]', age: 35)
    end
    
    it 'searches by name pattern' do
      results = service.search_users(name_pattern: 'Ali')
      
      expect(results.length).to eq(1)
      expect(results.first.name).to eq('Alice')
    end
    
    it 'filters by age range' do
      results = service.search_users(min_age: 30, max_age: 40)
      
      expect(results.length).to eq(1)
      expect(results.first.name).to eq('Bob')
    end
  end
end

# spec/factories/user_factory.rb (ROM::Factory使用)
require 'rom-factory'

ROM::Factory.configure do |config|
  config.rom = ROM_CONTAINER
end

module Factories
  extend ROM::Factory::DSL
  
  define(:user) do |f|
    f.sequence(:name) { |n| "User #{n}" }
    f.sequence(:email) { |n| "user#{n}@example.com" }
    f.age { rand(18..80) }
    f.is_active { true }
    f.created_at { Time.current }
    f.updated_at { Time.current }
  end
  
  define(:post) do |f|
    f.sequence(:title) { |n| "Post #{n}" }
    f.content { "This is the content of the post." * 10 }
    f.association(:user)
    f.is_published { false }
    f.view_count { rand(0..1000) }
    f.created_at { Time.current }
    f.updated_at { Time.current }
  end
end

# テストでのファクトリー使用例
RSpec.describe 'Integration test' do
  it 'creates users and posts' do
    user = Factories[:user]
    post = Factories[:post, user: user, title: 'Custom Title']
    
    expect(user.name).to start_with('User')
    expect(post.title).to eq('Custom Title')
    expect(post.user_id).to eq(user.id)
  end
end

エラーハンドリングと例外処理

# app/errors/rom_errors.rb
module ROMErrors
  class BaseError < StandardError
    attr_reader :details
    
    def initialize(message, details = {})
      super(message)
      @details = details
    end
  end
  
  class ValidationError < BaseError; end
  class NotFoundError < BaseError; end
  class DuplicateError < BaseError; end
  class TransactionError < BaseError; end
  class ConfigurationError < BaseError; end
end

# app/services/safe_user_service.rb
class SafeUserService
  include ROMErrors
  
  def initialize(container = ROM_CONTAINER)
    @container = container
    @user_repo = UserRepository.new(container)
  end
  
  # 安全なユーザー作成
  def create_user_safely(params)
    # 入力検証
    validate_user_input(params)
    
    @container.gateways[:default].transaction do
      @user_repo.create_user(params)
    end
  rescue ROM::ValidationError => e
    raise ValidationError.new("User validation failed", e.errors)
  rescue ROM::ConstraintError => e
    if e.message.include?('email')
      raise DuplicateError.new("Email already exists", { email: params[:email] })
    else
      raise ValidationError.new("Data constraint violation", { error: e.message })
    end
  rescue => e
    raise TransactionError.new("User creation failed", { original_error: e.message })
  end
  
  # 安全なユーザー取得
  def get_user_safely(id)
    @user_repo.find(id)
  rescue ROM::TupleCountMismatchError
    raise NotFoundError.new("User not found", { user_id: id })
  rescue => e
    raise BaseError.new("Failed to retrieve user", { user_id: id, error: e.message })
  end
  
  # 安全なユーザー更新
  def update_user_safely(id, params)
    validate_user_input(params, updating: true)
    
    @container.gateways[:default].transaction do
      # ユーザー存在確認
      user = get_user_safely(id)
      
      # メール重複チェック
      if params[:email] && params[:email] != user.email
        existing_user = @user_repo.find_by_email(params[:email])
        if existing_user && existing_user.id != id
          raise DuplicateError.new("Email already exists", { email: params[:email] })
        end
      end
      
      @user_repo.update_user(id, params)
    end
  rescue NotFoundError, DuplicateError
    raise
  rescue ROM::ValidationError => e
    raise ValidationError.new("User validation failed", e.errors)
  rescue => e
    raise TransactionError.new("User update failed", { user_id: id, error: e.message })
  end
  
  # 安全なユーザー削除
  def delete_user_safely(id)
    @container.gateways[:default].transaction do
      user = get_user_safely(id)
      
      # 削除前チェック
      posts_count = @container.relations[:posts].where(user_id: id).count
      if posts_count > 0
        raise ValidationError.new(
          "Cannot delete user with existing posts", 
          { user_id: id, posts_count: posts_count }
        )
      end
      
      @user_repo.delete_user(id)
      true
    end
  rescue NotFoundError, ValidationError
    raise
  rescue => e
    raise TransactionError.new("User deletion failed", { user_id: id, error: e.message })
  end
  
  # バルク操作の安全実行
  def bulk_create_users_safely(users_data)
    results = { success: [], failed: [] }
    
    @container.gateways[:default].transaction do
      users_data.each_with_index do |user_params, index|
        begin
          user = create_user_safely(user_params)
          results[:success] << { index: index, user: user }
        rescue BaseError => e
          results[:failed] << { 
            index: index, 
            params: user_params, 
            error: e.message, 
            details: e.details 
          }
        end
      end
      
      # 失敗が多い場合はロールバック
      if results[:failed].length > users_data.length * 0.5
        raise TransactionError.new(
          "Bulk operation failed: too many errors", 
          { success_count: results[:success].length, failed_count: results[:failed].length }
        )
      end
    end
    
    results
  rescue TransactionError
    raise
  rescue => e
    raise TransactionError.new("Bulk operation failed", { error: e.message })
  end
  
  # データベース整合性チェック
  def perform_data_integrity_check
    issues = []
    
    begin
      # 孤立したデータのチェック
      orphaned_posts = @container.relations[:posts]
        .left_join(:users, id: :user_id)
        .where(users[:id] => nil)
      
      if orphaned_posts.count > 0
        issues << {
          type: :orphaned_data,
          table: :posts,
          count: orphaned_posts.count,
          message: "Posts without valid user references found"
        }
      end
      
      # 重複メールのチェック
      duplicate_emails = @container.relations[:users]
        .dataset
        .group(:email)
        .having { count.function.* > 1 }
        .select(:email)
        .count
      
      if duplicate_emails > 0
        issues << {
          type: :duplicate_data,
          table: :users,
          count: duplicate_emails,
          message: "Duplicate email addresses found"
        }
      end
      
      # 無効データのチェック
      invalid_users = @container.relations[:users]
        .where { (age < 0) | (age > 150) }
        .count
      
      if invalid_users > 0
        issues << {
          type: :invalid_data,
          table: :users,
          count: invalid_users,
          message: "Users with invalid age values found"
        }
      end
      
    rescue => e
      raise BaseError.new("Integrity check failed", { error: e.message })
    end
    
    {
      healthy: issues.empty?,
      issues: issues,
      checked_at: Time.current
    }
  end
  
  # データクリーンアップ
  def cleanup_invalid_data
    cleanup_results = {}
    
    @container.gateways[:default].transaction do
      # 孤立投稿の削除
      orphaned_posts_count = @container.relations[:posts]
        .left_join(:users, id: :user_id)
        .where(users[:id] => nil)
        .delete
      
      cleanup_results[:orphaned_posts_removed] = orphaned_posts_count
      
      # 無効ユーザーデータの修正
      invalid_users_fixed = @container.relations[:users]
        .where { (age < 0) | (age > 150) }
        .update(age: 0)  # 無効な年齢を0にリセット
      
      cleanup_results[:invalid_users_fixed] = invalid_users_fixed
      
      cleanup_results[:cleaned_at] = Time.current
    end
    
    cleanup_results
  rescue => e
    raise TransactionError.new("Data cleanup failed", { error: e.message })
  end
  
  private
  
  def validate_user_input(params, updating: false)
    errors = []
    
    if !updating || params.key?(:name)
      name = params[:name]
      errors << "Name cannot be empty" if name.nil? || name.strip.empty?
    end
    
    if !updating || params.key?(:email)
      email = params[:email]
      if email.nil? || email.strip.empty?
        errors << "Email cannot be empty"
      elsif !email.match?(/\A[^@\s]+@[^@\s]+\z/)
        errors << "Invalid email format"
      end
    end
    
    if !updating || params.key?(:age)
      age = params[:age]
      if age.nil?
        errors << "Age cannot be empty"
      elsif age < 0 || age > 150
        errors << "Age must be between 0 and 150"
      end
    end
    
    raise ValidationError.new("Input validation failed", { errors: errors }) if errors.any?
  end
end

# 使用例とエラーハンドリング
class UserController
  def initialize
    @user_service = SafeUserService.new
  end
  
  def create
    user = @user_service.create_user_safely(params)
    render json: { success: true, user: user }
  rescue ROMErrors::ValidationError => e
    render json: { 
      success: false, 
      error: "Validation failed", 
      details: e.details 
    }, status: 422
  rescue ROMErrors::DuplicateError => e
    render json: { 
      success: false, 
      error: "Duplicate data", 
      details: e.details 
    }, status: 409
  rescue ROMErrors::TransactionError => e
    render json: { 
      success: false, 
      error: "Transaction failed", 
      details: e.details 
    }, status: 500
  rescue => e
    render json: { 
      success: false, 
      error: "Unexpected error", 
      message: e.message 
    }, status: 500
  end
  
  def update
    user = @user_service.update_user_safely(params[:id], params)
    render json: { success: true, user: user }
  rescue ROMErrors::NotFoundError => e
    render json: { 
      success: false, 
      error: "User not found", 
      details: e.details 
    }, status: 404
  rescue ROMErrors::ValidationError, ROMErrors::DuplicateError => e
    render json: { 
      success: false, 
      error: e.message, 
      details: e.details 
    }, status: 422
  rescue => e
    render json: { 
      success: false, 
      error: "Update failed", 
      message: e.message 
    }, status: 500
  end
  
  def health_check
    integrity_result = @user_service.perform_data_integrity_check
    
    if integrity_result[:healthy]
      render json: { 
        status: "healthy", 
        checked_at: integrity_result[:checked_at] 
      }
    else
      render json: { 
        status: "issues_found", 
        issues: integrity_result[:issues], 
        checked_at: integrity_result[:checked_at] 
      }, status: 200
    end
  rescue => e
    render json: { 
      status: "check_failed", 
      error: e.message 
    }, status: 500
  end
end