Awesome-omni-skill rails-architecture

Guides modern Rails 8 code architecture decisions and patterns. Use when deciding where to put code, choosing between patterns (service objects vs concerns vs query objects), designing feature architecture, refactoring for better organization, or when user mentions architecture, code organization, design patterns, or layered design.

install
source · Clone the upstream repo
git clone https://github.com/diegosouzapw/awesome-omni-skill
Claude Code · Install into ~/.claude/skills/
T=$(mktemp -d) && git clone --depth=1 https://github.com/diegosouzapw/awesome-omni-skill "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/data-ai/rails-architecture" ~/.claude/skills/diegosouzapw-awesome-omni-skill-rails-architecture && rm -rf "$T"
manifest: skills/data-ai/rails-architecture/SKILL.md
source content

Modern Rails 8 Architecture Patterns

Project Conventions

  • Testing: Minitest + fixtures (NEVER RSpec or FactoryBot)
  • Components: ViewComponents for reusable UI (partials OK for simple one-offs)
  • Authorization: Pundit policies (deny by default)
  • Jobs: Solid Queue, shallow jobs,
    _later
    /
    _now
    naming
  • Frontend: Hotwire (Turbo + Stimulus) + Tailwind CSS
  • State: State-as-records for business state (booleans only for technical flags)
  • Architecture: Rich models first, service objects for multi-model orchestration
  • Routing: Everything-is-CRUD (new resource over new action)
  • Quality: RuboCop (omakase) + Brakeman

Architecture Decision Tree

Where should this code go?
│
├─ Is it data validation, associations, or simple business logic?
│   └─ → Model (rich models first!)
│
├─ Is it shared behavior across models?
│   └─ → Concern
│
├─ Is it business state tracking (who/when/why)?
│   └─ → State Record (see: state-records pattern)
│
├─ Does it orchestrate 3+ models or call external APIs?
│   └─ → Service Object (with Result pattern)
│
├─ Is it a complex database query (3+ joins, aggregations)?
│   └─ → Query Object
│
├─ Is it view/display formatting?
│   └─ → Presenter (SimpleDelegator)
│
├─ Is it authorization logic?
│   └─ → Pundit Policy
│
├─ Is it reusable UI with logic?
│   └─ → ViewComponent
│
├─ Is it async/background work?
│   └─ → Shallow Job (Solid Queue)
│
├─ Is it a complex form (multi-model, wizard)?
│   └─ → Form Object
│
├─ Is it a transactional email?
│   └─ → Mailer
│
└─ Is it HTTP request/response handling only?
    └─ → Controller (keep it thin!)

Hybrid Philosophy: Models First, Services When Needed

The Rule of Three

  • 1 model affected → Keep logic in the model
  • 2 models affected → Consider a concern or model method
  • 3+ models affected → Extract to a service object

Rich Models (Default)

Models handle validations, associations, scopes, simple derived attributes, and single-model business logic. This is where most code belongs.

class Order < ApplicationRecord
  include Closeable  # State-as-records concern

  belongs_to :user
  has_many :line_items, dependent: :destroy

  validates :total_cents, presence: true, numericality: { greater_than: 0 }

  scope :recent, -> { order(created_at: :desc) }
  scope :pending, -> { where.missing(:closure) }

  def add_item(product, quantity: 1)
    line_items.create!(product: product, quantity: quantity, price_cents: product.price_cents)
    recalculate_total!
  end

  private

  def recalculate_total!
    update!(total_cents: line_items.sum("price_cents * quantity"))
  end
end

Service Objects (When Justified)

Use only when logic spans 3+ models, calls external APIs, or orchestrates complex workflows.

module Orders
  class CheckoutService
    def call(user:, cart:, payment_method_id:)
      order = nil

      ActiveRecord::Base.transaction do
        order = user.orders.create!(total_cents: cart.total_cents)
        cart.items.each { |item| order.add_item(item.product, quantity: item.quantity) }
        Inventory::ReserveService.new.call(order: order)
      end

      Payments::ChargeService.new.call(order: order, payment_method_id: payment_method_id)
      OrderMailer.confirmation(order).deliver_later
      Result.new(success: true, data: order)
    rescue ActiveRecord::RecordInvalid => e
      Result.new(success: false, error: e.message)
    end
  end
end

Everything-is-CRUD Routing

Prefer creating a new resource over adding custom actions:

# GOOD: New resource for publishing
resources :posts do
  resource :publication, only: [:create, :destroy]
end
# POST /posts/:post_id/publication → Publications#create
# DELETE /posts/:post_id/publication → Publications#destroy

# BAD: Custom action
resources :posts do
  member do
    post :publish
    post :unpublish
  end
end

Layer Responsibilities

LayerResponsibilityShould NOT contain
ControllerHTTP, params, authorize, renderBusiness logic, queries
ModelData, validations, relations, scopesDisplay logic, HTTP
ConcernShared model/controller behaviorUnrelated cross-cutting logic
ServiceMulti-model orchestration, external APIsHTTP, display logic
QueryComplex database queries, reportsBusiness logic
PresenterView formatting, badgesBusiness logic, queries
PolicyAuthorization rulesBusiness logic
ComponentReusable UI encapsulationBusiness logic
JobAsync delegation (shallow!)Business logic

Project Directory Structure

app/
├── channels/            # Action Cable channels
├── components/          # ViewComponents (UI + logic)
├── controllers/
│   └── concerns/        # Shared controller behavior
├── forms/               # Form objects
├── jobs/                # Background jobs (Solid Queue)
├── mailers/             # Action Mailer classes
├── models/
│   └── concerns/        # Shared model behavior
├── policies/            # Pundit authorization
├── presenters/          # View formatting
├── queries/             # Complex queries
├── services/            # Business logic (use sparingly)
│   └── result.rb        # Shared Result class
└── views/
    └── components/      # ViewComponent templates

When NOT to Abstract

SituationKeep It SimpleDon't Create
Simple CRUD (< 10 lines)Keep in controllerService object
Used only onceInline the codeAbstraction
Simple query with 1-2 conditionsModel scopeQuery object
Basic text formattingHelper methodPresenter
Single model form
form_with model:
Form object
Simple partial without logicPartialViewComponent

When TO Abstract

SignalAction
Same code in 3+ placesExtract to concern/service
Controller action > 15 linesExtract to service
Model > 300 linesExtract concerns
Complex conditionalsExtract to policy/service
Query joins 3+ tablesExtract to query object
Form spans multiple modelsExtract to form object
Partial has > 5 lines of logicUse ViewComponent

Result Object Pattern

All services return a consistent Result:

# app/services/result.rb
class Result
  attr_reader :data, :error, :code

  def initialize(success:, data: nil, error: nil, code: nil)
    @success = success
    @data = data
    @error = error
    @code = code
  end

  def success? = @success
  def failure? = !@success

  def self.success(data = nil) = new(success: true, data: data)
  def self.failure(error, code: nil) = new(success: false, error: error, code: code)
end

Testing Strategy by Layer

LayerTest TypeLocationFocus
ModelUnit
test/models/
Validations, scopes, methods
ServiceUnit
test/services/
Business logic, edge cases
QueryUnit
test/queries/
Query results, correctness
PresenterUnit
test/presenters/
Formatting, HTML output
ControllerIntegration
test/controllers/
HTTP flow, authorization
ComponentComponent
test/components/
Rendering, variants
PolicyUnit
test/policies/
Authorization rules
SystemE2E
test/system/
Critical user paths

Anti-Patterns to Avoid

Anti-PatternProblemSolution
God ModelModel > 500 linesExtract concerns
Fat ControllerLogic in controllersMove to models/services
Premature ServiceService for 3 linesKeep in model
Callback HellComplex model callbacksUse services for orchestration
Boolean State
approved: true
State-as-records
N+1 QueriesUnoptimized queriesUse
.includes()

References