These are the rules our Rails code follows. They come from two places: the book Growing Rails Applications in Practice (makandra), and the two applications we already run on them — the public site with the WarpEngine catalog engine, and Teletype Orbit. Nothing here is a style preference. If you write Rails here, you follow these.
One sentence to hold on to: large applications are large. We cannot make that go away. What we can do is keep the code organised so that twice as many models do not mean twice as many problems.
Every piece of code has one home. If you cannot name the home, the class you need does not exist yet — create it.
app/controllers/) — HTTP only: read params, call a form or a service, render or redirect. No business logic.app/forms/) — any form that writes to more than one model, or that has no model behind it at all (sign-in, checkout, an upload). Plain ActiveModel::Model with validations.app/services/) — one use case, one public method with a speaking name (place, issue, revoke, publish). Transactions, decisions and side effects live here. Never name the method call.app/queries/) — anything more complicated than a scope: search, filters, charts, reports. Returns a relation or an array of rows.app/presenters/) — formatting for the view, so templates hold no logic.app/dtos/) — the result a service hands back. A Struct-like value, no validations.app/serializers/) — Blueprinter, for the JSON API only. HTML pages use views.app/models/) — associations, validations, scopes.app/jobs/) — asynchronous side effects: mail, aggregation, webhook processing.Two rules between the layers:
index, show, new, create, edit, update, destroy. There is no approve, publish, add_to_cart, redeem.| Instead of | Write |
|---|---|
CartsController#add_item |
Store::CartItemsController#create |
SubmissionsController#approve |
Moderation::ApprovalsController#create |
ProductsController#publish |
Studio::PublicationsController#create |
ReviewsController#upvote |
Community::ReviewVotesController#create |
SessionsController#logout |
Accounts::SessionsController#destroy |
if on the state of a model.permit. Service inputs that are not model attributes belong in a form object — "it is only two fields" is how the next controller from hell starts.constantize on a parameter. Check it against a whitelist constant first (we already have Support::Flag::FLAGGABLES and Community::Follow::FOLLOWABLES).before_action bodies belong in the base controller, ideally as a small class macro. Five copies of the same "find the publisher and authorise" pair is five chances to drift.Store::CheckoutService::Error, Library::DownloadService::Denied). The controller rescues those, never StandardError.rescue_from.invite.accept! can be bypassed a dozen ways (update!, create!, an attribute setter). Only validations and callbacks are guaranteed to run.update_column, update_columns and update_all skip your own contract. Use them only for housekeeping — a last_used_at touch, a counter, a batch sweep. Anything a person would call a fact (who owns this, is it listed, which image is the default) goes through save/update!.before_save, or saving another record in before_validation, leaves orphans behind when the save fails. Use after_commit, or do it in a service inside a transaction.inclusion validation next to it. A constant without the validation is a suggestion, not a rule — and never copy the values into an ActiveAdmin filter, reference the constant.default_scope is for the soft-delete filter (where(deleted_at: nil)) and nothing else. Never put order in a default_scope — it leaks into every association and aggregate, and you will end up writing unscope(:order) to get out of it.ransackable_attributes and ransackable_associations explicitly.Accounts, Store, Library, Studio, Community, Support, Finance, Ops.# app/models/store.rb
module Store
def self.table_name_prefix = "store_"
end
# Store::Product -> store_products
This is not decoration: WarpEngine's own tables (softwares, releases, images) sit unprefixed in the same database, so the prefix is what structurally prevents a collision.
Software, Release, ReleaseAsset, Download, SoftwareImage, ExternalLink, PlatformLink, ApplicationToken, Pipeline never mean anything else in our code. Where the shop's idea differs, use a different word: the thing you sell is a Store::Product, a forum thread is a Community::Topic (Thread is a Ruby class), a content report is a Support::Flag (a "report" is the sales report).Process, Set, Data, Method, File, Dir, Range, Signal, Random, Comparable.Software::Publish, Rss::BlogFeed, Store::CheckoutService — not a flat folder of seventeen files ending in _service.rb.spec/requests, a model spec in spec/models — even when the file would also run from somewhere else.ActiveSupport::Notifications, not callbacks into the host. The host subscribes (warp_engine.publish, warp_engine.download).ActiveSupport.run_load_hooks(:warp_engine_software, self)), never by reopening the class./api/software*, /api/builds, /api/download, /api/service, /api/auth/*, /api/ci/*, /build/*, /file/*. Our own JSON API is versioned (/api/v1), so it cannot collide. mount WarpEngine::Engine is always the last line of routes.rb.update!, so its validations and callbacks run. The engine is a versioned dependency — a column write that works today silently skips the callback it gains in the next release.ENV reading happens in one place, config/application.rb, into config.x:config.x.downloads.grant_ttl = 15.minutes
config.x.images.container_path = ENV.fetch("IMAGE_CONTAINER_PATH", "/images")
Rails.configuration.x, never ENV directly. One place shows what the app expects from its environment, and a test can override it.ApplicationController in a to_prepare block hides behaviour from the file that declares the class — put it in a concern and include it.text-2xl font-semibold occurs 81 times, that is a heading class asking to be born.surface, line, muted, accent — so a theme change is one file, not 134 views.!important is how a stylesheet becomes unmaintainable; a block decides how it looks in a theme, through its own modifier class..sidebar .article {} is out; either the element belongs to the block (.sidebar__article) or the block gets a modifier (.article.is_summary).table, dl, ul), :before/:after, pseudo-selectors, media queries, markdown or WYSIWYG content inside one container (.wiki-content), and markup generated by a third-party library.includes either — the controller or service prepares the data. A query inside a loop in a partial is an N+1 with extra steps.style: attributes in ActiveAdmin pages when the project already has an admin stylesheet. Repeated markup gets a class; a long case for icons gets a presenter.show — it is a worksheet, and it wants a presenter or a query object.api, param, returns, error), listing the response properties. That is what /api/docs and the swagger page are built from.spec/models for every invariant: the state machine, the last-owner rule, the read-only row, the uniqueness that matters. These are the cheapest and longest-lived tests you will write.spec/services is the centre of gravity — every branch, every failure path.spec/requests for authorisation, status codes and redirects. The download gate has a mandatory regression spec.branch: master gem means anything can arrive between two bundle updates, and the lockfile names a revision rather than a version. If a release does not exist yet, pin a ref: and bump it deliberately.:latest container images in CI. A build's toolchain must not change without a commit.LIKE before a search server, a cron job before a queue — and hide the choice behind a small API (Article::Search.find) so swapping it later touches one class.teletype-orbit the "why" is written next to non-obvious code; in the teletypegames monorepo explanatory comments are not used and the why goes into the commit. Both are deliberate — follow the repo you are in.