Now liveThe Skillselion MCP - thousands of ranked skills, loaded into your agent mid-task. No install.Get it →
dchuk avatar

Rails Architecture

  • 2 installs
  • Updated February 9, 2026
  • dchuk/rails_ai_agents

Guides modern Rails 8 architecture decisions, choosing between service objects, concerns, and query objects and where to place code.

About

Advises on modern Rails 8 code architecture and design patterns. A developer uses it when deciding where to put code, choosing between patterns, or refactoring for better organization.

  • Choose between service objects, concerns, and query objects
  • Layered design and feature architecture guidance

Rails Architecture by the numbers

  • 2 all-time installs (skills.sh)
  • Ranked #947 of 1,352 Code Review & Quality skills by installs in the Skillselion catalog
  • Data as of Jul 29, 2026 (Skillselion catalog sync)
npx skills add https://github.com/dchuk/rails_ai_agents --skill rails-architecture

Add your badge

Show developers this skill is listed on Skillselion. Paste this into your README.

Listed on Skillselion
Installs2
Last updatedFebruary 9, 2026
Repositorydchuk/rails_ai_agents

What it does

Guides modern Rails 8 architecture decisions, choosing between service objects, concerns, and query objects and where to place code.

Files

SKILL.mdMarkdownGitHub ↗

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 formform_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
ModelUnittest/models/Validations, scopes, methods
ServiceUnittest/services/Business logic, edge cases
QueryUnittest/queries/Query results, correctness
PresenterUnittest/presenters/Formatting, HTML output
ControllerIntegrationtest/controllers/HTTP flow, authorization
ComponentComponenttest/components/Rendering, variants
PolicyUnittest/policies/Authorization rules
SystemE2Etest/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 Stateapproved: trueState-as-records
N+1 QueriesUnoptimized queriesUse .includes()

References

  • See layer-interactions.md for layer communication patterns
  • See service-patterns.md for service object patterns
  • See query-patterns.md for query object patterns
  • See error-handling.md for error handling strategies
  • See testing-strategy.md for comprehensive testing
  • See multi-tenancy.md for multi-tenant patterns
  • See event-tracking.md for domain event patterns
  • See state-records.md for state-as-records patterns

Related skills

This week in AI coding

Five minutes, every Monday - the tools, releases and tactics for developers.

unsubscribe anytime.