---
title: 'RoR Styleguide'
url: 'https://wiki.teletypegames.org/development/ror-styleguide'
markdown: 'https://wiki.teletypegames.org/development/ror-styleguide.md'
lang: en
date: '2026-08-23'
description: '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 wr…'
taxonomy:
  tag:
    - howto
---

# 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](https://wiki.teletypegames.org/services/public-site) with the [WarpEngine](https://wiki.teletypegames.org/engines/warp-engine) catalog engine, and [Teletype Orbit](https://wiki.teletypegames.org/services/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 update`s, 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.

'; var prevLevel = 2; for (var i = 0; i < headings.length; i++) { var h = headings\[i\]; var text = h.textContent.trim(); var slug = text.toLowerCase() .replace(/\[áàâä\]/g,'a').replace(/\[éèêë\]/g,'e').replace(/\[íìîï\]/g,'i') .replace(/\[óòôöő\]/g,'o').replace(/\[úùûüű\]/g,'u') .replace(/\[^a-z0-9\]+/g, '-').replace(/^-|-$/g, ''); if (!slug) slug = 'heading-' + i; if (used\[slug\]) { slug += '-' + i; } used\[slug\] = true; h.id = slug; var level = parseInt(h.tagName.charAt(1)); var cls = level > 2 ? ' class="toc-child"' : ''; html += '- [' + text + '](#'%20+%20slug%20+%20')
'; } html += '
'; toc.innerHTML = html; // Scroll-spy: highlight current heading var links = toc.querySelectorAll('a'); var ids = \[\]; for (var j = 0; j < links.length; j++) ids.push(links\[j\].getAttribute('href').slice(1)); function updateActive() { var current = ''; for (var k = 0; k < ids.length; k++) { var el = document.getElementById(ids\[k\]); if (el && el.getBoundingClientRect().top <= 80) current = ids\[k\]; } for (var m = 0; m < links.length; m++) { links\[m\].parentNode.classList.toggle('active', links\[m\].getAttribute('href') === '#' + current); } } window.addEventListener('scroll', updateActive, { passive: true }); updateActive(); })();

---

## Navigation

- Parent: [Development](https://wiki.teletypegames.org/development.md)
- Previous: [RDoc](https://wiki.teletypegames.org/development/rdoc.md)
- Next: [Useful materials](https://wiki.teletypegames.org/development/useful-materials.md)
