EN /HU | Login

RoR Styleguide

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.

1. Where code goes

Every piece of code has one home. If you cannot name the home, the class you need does not exist yet — create it.

  • Controller (app/controllers/) — HTTP only: read params, call a form or a service, render or redirect. No business logic.
  • Form object (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.
  • Service (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.
  • Query object (app/queries/) — anything more complicated than a scope: search, filters, charts, reports. Returns a relation or an array of rows.
  • Presenter (app/presenters/) — formatting for the view, so templates hold no logic.
  • DTO (app/dtos/) — the result a service hands back. A Struct-like value, no validations.
  • Serializer (app/serializers/) — Blueprinter, for the JSON API only. HTML pages use views.
  • Model (app/models/) — associations, validations, scopes.
  • Job (app/jobs/) — asynchronous side effects: mail, aggregation, webhook processing.

Two rules between the layers:

  • A service may call another service. A model never calls a service.
  • If a model callback would send mail or grant a permission, it is in the wrong place. Move it to a service.

2. Controllers

  • Only the seven standard actions exist: index, show, new, create, edit, update, destroy. There is no approve, publish, add_to_cart, redeem.
  • A new interaction means a new controller whose resource is the interaction itself:
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
  • An action is three to six lines. Zero business logic, and zero if on the state of a model.
  • Whitelist every input. Model attributes go through 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.
  • Never call constantize on a parameter. Check it against a whitelist constant first (we already have Support::Flag::FLAGGABLES and Community::Follow::FOLLOWABLES).
  • One private method is the only door to the model. Where a resource is scoped to a user, a publisher or a token, load it through that scope instead of finding it first and checking afterwards.
  • Repeated 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.
  • Services raise their own error classes (Store::CheckoutService::Error, Library::DownloadService::Denied). The controller rescues those, never StandardError.
  • Shared response shaping — 404, 403, the JSON error body — lives once, in the base controller's rescue_from.

3. Models and ActiveRecord

  • State an invariant in validations and callbacks, not in a helper method. A method like invite.accept! can be bypassed a dozen ways (update!, create!, an attribute setter). Only validations and callbacks are guaranteed to run.
  • Convenience methods are welcome. Just never rely on other code calling them.
  • A callback keeps data consistent. It never does business. Generating a slug or a token, mirroring a status, guarding a read-only row: yes. Sending mail, granting an entitlement, calling a webhook: no.
  • 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!.
  • Never touch the outside world before the row is committed. Writing a file in 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.
  • A value set lives in a constant on the model, with an 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.
  • Interaction-specific code does not belong in the core model. A validation that only one form needs, a virtual attribute for one screen, a callback for one use case: that is a form object or a service.
  • Modules and concerns do not slim a model down. They are file organisation; the callbacks and methods still load into the class.
  • 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.
  • Every model an admin page touches declares ransackable_attributes and ransackable_associations explicitly.

4. Names and namespaces

  • Every domain is a Ruby module, and there are no top-level models. Accounts, Store, Library, Studio, Community, Support, Finance, Ops.
  • The module carries the table prefix, so the mapping is automatic:
RUBY
# 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.

  • The engine's words are taken. 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).
  • Also avoid: Process, Set, Data, Method, File, Dir, Range, Signal, Random, Comparable.
  • Namespace aggressively, and namespace services by concept, not by suffix: Software::Publish, Rss::BlogFeed, Store::CheckoutService — not a flat folder of seventeen files ending in _service.rb.
  • Use the same structure everywhere: models, controllers, views, helpers and specs mirror each other. A request spec lives in spec/requests, a model spec in spec/models — even when the file would also run from somewhere else.
  • Namespacing pays twice: it makes the folder readable, and it gives new classes an obvious place to land, which is what stops models from growing fat.

5. The WarpEngine boundary

  • Anything host-specific is an adapter with a default, and the default is today's behaviour. That is how the engine stays usable on its own while the shop plugs its own rules in: storage, access policy, images, CI, token source, subject resolution.
  • Shop-specific code never goes into the engine. The engine announces and configures; the host decides.
  • The engine talks outwards with ActiveSupport::Notifications, not callbacks into the host. The host subscribes (warp_engine.publish, warp_engine.download).
  • Extend an engine model through its load hook (ActiveSupport.run_load_hooks(:warp_engine_software, self)), never by reopening the class.
  • The engine's URLs are taken: /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.
  • If you deliberately shadow an engine route, the route needs a comment saying why and the name of the regression spec that protects it.
  • Write to an engine model the way the engine expects: 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.
  • When a seam replaces old code, delete the old code. After the storage adapter landed, four other places still computed the file base path; two of them were already dead.

6. Configuration

  • All ENV reading happens in one place, config/application.rb, into config.x:
RUBY
config.x.downloads.grant_ttl = 15.minutes
config.x.images.container_path = ENV.fetch("IMAGE_CONTAINER_PATH", "/images")
  • The code reads Rails.configuration.x, never ENV directly. One place shows what the app expects from its environment, and a test can override it.
  • One name per thing. Two env vars pointing at the same wiki, one of which the compose file never sets, is a bug waiting for a Friday.
  • Engine settings go in the engine's own configuration object with defaults, and the host sets them in a single initializer.
  • No monkey patching from an initializer. Reopening ApplicationController in a to_prepare block hides behaviour from the file that declares the class — put it in a concern and include it.

7. Views and styles

  • Tailwind, and a named class for anything that appears more than twice. If text-2xl font-semibold occurs 81 times, that is a heading class asking to be born.
  • Semantic colour tokens, not raw palette names. surface, line, muted, accent — so a theme change is one file, not 134 views.
  • A theme is a token swap. Overriding other blocks with !important is how a stylesheet becomes unmaintainable; a block decides how it looks in a theme, through its own modifier class.
  • One block never styles another block. .sidebar .article {} is out; either the element belongs to the block (.sidebar__article) or the block gets a modifier (.article.is_summary).
  • Allowed pragmatic exceptions: children of compound HTML (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.
  • No queries in views, and no includes either — the controller or service prepares the data. A query inside a loop in a partial is an N+1 with extra steps.
  • No inline 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.
  • A view that needs six instance variables from five services is not a show — it is a worksheet, and it wants a presenter or a query object.

8. JSON API

  • Blueprinter serializers, camelCase keys, and serializers only for the API — never for HTML pages.
  • Annotate public endpoints with apipie (api, param, returns, error), listing the response properties. That is what /api/docs and the swagger page are built from.
  • Write down why an endpoint is public. "A client has nobody to log in as" belongs in the endpoint description, not in a commit message.
  • Our own API is versioned. A response that a client depends on carries a version header, and a service descriptor endpoint tells a client what this deployment offers before it has any credential.
  • Hand-rolling a hash in a controller when the project has serializers is how two names for the same field end up in one response.

9. Tests

  • Unit tests and full-stack tests are what pay. Do not add a controller spec and a view spec for something those two already cover.
  • The Dark House Rule: the app is a dark house, integration tests are the ambient light, unit tests are the spotlights. You do not need surgical lighting everywhere — you need to be sure nothing is growing in a dark corner.
  • 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.
  • Every screen is kissed by at least one full-stack test. One or two paths through it, not every edge case. With Hotwire this is not optional.
  • One command runs everything, with no extra setup, and the test data is created by the tests.
  • Nothing is committed with a failing or pending test. Once the suite is not green it stops being able to tell you whether your change broke something, and the bar keeps dropping.
  • Factories stay minimal, with traits. No god factory that drags half the database in.
  • Keep tests boring: the cleverness belongs in the code under test.

10. Gems and upgrades

  • Pin every gem, and write the reason next to any risky choice. The Gemfile is a good place for a sentence that saves an afternoon.
  • No git branch dependencies. A 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.
  • No :latest container images in CI. A build's toolchain must not change without a commit.
  • Adding a gem means owning it: its behaviour under load, its security updates, its upgrade when Rails moves, and its dependencies. Prefer a low-level library over a mini framework that hooks deep into Rails internals.
  • Do not live on the bleeding edge. Wait for a major release to reach a few patch levels.
  • Max out the tools you already have. Another table before Redis, 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.
  • You pay for monkey patches at upgrade time. Fork, fix with a test, open a pull request.

11. How we work

  • *The plan lives in a `NOTES_.md` in the repo root** (gitignored), not scattered across commit messages: decisions, alternatives, and why. Code may reference it by section.
  • Per-repo comment convention. In 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.
  • Audit publisher actions into a table, not a log file: who did what, when, to which record.
  • Seeds come in two levels: core data that also runs in production, and example data for development that walks the real path (studio, submission, approval, cart, payment webhook), so the seed exercises the domain instead of side-loading rows.
  • New patterns get judged, not adopted. Rewrite a small part in the new style, compare the two, and say out loud what the trade-off is. Consistency is worth more than a marginally better technique adopted halfway.