TITLE=RoR Styleguide ROUTE=/development/ror-styleguide LOCALE=en MODIFIED=1792409344 URL=https://wiki.teletypegames.org/development/ror-styleguide L=1 #68 L=2 #22 L=3 #70 q│ These are the rules our Rails code follows. They come from two places: the q│ book iGrowing Rails Applications in Practiceq (makandra), and the two q│ applications we already run on them — the 1public site with the 2WarpEngine q│ catalog engine, and 3Teletype Orbit. Nothing here is a style preference. If q│ you write Rails here, you follow these. One sentence to hold on to: blarge applications are large0. 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. 11. Where code goes 1══════════════════ Every piece of code has one home. If you cannot name the home, the class you need does not exist yet — create it. • bController0 (capp/controllers/0) — HTTP only: read params, call a form or a service, render or redirect. No business logic. • bForm object0 (capp/forms/0) — any form that writes to more than one model, or that has no model behind it at all (sign-in, checkout, an upload). Plain cActiveModel::Model0 with validations. • bService0 (capp/services/0) — one use case, one public method with a speaking name (cplace0, cissue0, crevoke0, cpublish0). Transactions, decisions and side effects live here. Never name the method ccall0. • bQuery object0 (capp/queries/0) — anything more complicated than a scope: search, filters, charts, reports. Returns a relation or an array of rows. • bPresenter0 (capp/presenters/0) — formatting for the view, so templates hold no logic. • bDTO0 (capp/dtos/0) — the result a service hands back. A cStruct0-like value, no validations. • bSerializer0 (capp/serializers/0) — Blueprinter, for the JSON API only. HTML pages use views. • bModel0 (capp/models/0) — associations, validations, scopes. • bJob0 (capp/jobs/0) — asynchronous side effects: mail, aggregation, webhook processing. Two rules between the layers: • A service may call another service. bA 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. 12. Controllers 1══════════════ • bOnly the seven standard actions exist0: cindex0, cshow0, cnew0, ccreate0, cedit0, cupdate0, cdestroy0. There is no capprove0, cpublish0, cadd_to_cart0, credeem0. • A new interaction means a bnew controller whose resource is the interaction bitself0: t┌───────────────────────────────┬─────────────────────────────────────────┐ t│h Instead of t│h Write t│ t├───────────────────────────────┼─────────────────────────────────────────┤ t│0 cCartsController#add_item0 t│0 cStore::CartItemsController#create0 t│ t│0 cSubmissionsController#approve0 t│0 cModeration::ApprovalsController#create0 t│ t│0 cProductsController#publish0 t│0 cStudio::PublicationsController#create0 t│ t│0 cReviewsController#upvote0 t│0 cCommunity::ReviewVotesController#create0 t│ t│0 cSessionsController#logout0 t│0 cAccounts::SessionsController#destroy0 t│ t└───────────────────────────────┴─────────────────────────────────────────┘ • An action is three to six lines. Zero business logic, and bzero cifb on the bstate of a model0. • bWhitelist every input.0 Model attributes go through cpermit0. 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. • bNever call cconstantizeb on a parameter.0 Check it against a whitelist constant first (we already have cSupport::Flag::FLAGGABLES0 and cCommunity::Follow::FOLLOWABLES0). • bOne private method is the only door to the model.0 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. • bRepeated cbefore_actionb bodies belong in the base controller0, 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 (cStore::CheckoutService::Error0, cLibrary::DownloadService::Denied0). The controller rescues bthose0, never cStandardError0. • Shared response shaping — 404, 403, the JSON error body — lives once, in the base controller's crescue_from0. 13. Models and ActiveRecord 1══════════════════════════ • bState an invariant in validations and callbacks, not in a helper method.0 A method like cinvite.accept!0 can be bypassed a dozen ways (cupdate!0, ccreate!0, an attribute setter). Only validations and callbacks are guaranteed to run. • Convenience methods are welcome. Just never rely on other code calling them. • bA callback keeps data consistent. It never does business.0 Generating a slug or a token, mirroring a status, guarding a read-only row: yes. Sending mail, granting an entitlement, calling a webhook: no. • cupdate_columnb, cupdate_columnsb and cupdate_allb skip your own contract.0 Use them only for housekeeping — a clast_used_at0 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 csave0/cupdate!0. • bNever touch the outside world before the row is committed.0 Writing a file in cbefore_save0, or saving another record in cbefore_validation0, leaves orphans behind when the save fails. Use cafter_commit0, or do it in a service inside a transaction. • bA value set lives in a constant on the model, with an cinclusionb validation bnext to it.0 A constant without the validation is a suggestion, not a rule — and never copy the values into an ActiveAdmin filter, reference the constant. • bInteraction-specific code does not belong in the core model.0 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. • bModules and concerns do not slim a model down.0 They are file organisation; the callbacks and methods still load into the class. • cdefault_scope0 is for the soft-delete filter (cwhere(deleted_at: nil)0) and nothing else. bNever put corderb in a cdefault_scope0 — it leaks into every association and aggregate, and you will end up writing cunscope(:order)0 to get out of it. • Every model an admin page touches declares cransackable_attributes0 and cransackable_associations0 explicitly. 14. Names and namespaces 1═══════════════════════ • bEvery domain is a Ruby module, and there are no top-level models.0 cAccounts0, cStore0, cLibrary0, cStudio0, cCommunity0, cSupport0, cFinance0, cOps0. • The module carries the table prefix, so the mapping is automatic: p # app/models/store.rb p module Store p def self.table_name_prefix = "store_" p end p # Store::Product -> store_products This is not decoration: WarpEngine's own tables (csoftwares0, creleases0, cimages0) sit unprefixed in the bsame database0, so the prefix is what structurally prevents a collision. • bThe engine's words are taken.0 cSoftware0, cRelease0, cReleaseAsset0, cDownload0, cSoftwareImage0, cExternalLink0, cPlatformLink0, cApplicationToken0, cPipeline0 never mean anything else in our code. Where the shop's idea differs, use a different word: the thing you sell is a cStore::Product0, a forum thread is a cCommunity::Topic0 (cThread0 is a Ruby class), a content report is a cSupport::Flag0 (a "report" is the sales report). • Also avoid: cProcess0, cSet0, cData0, cMethod0, cFile0, cDir0, cRange0, cSignal0, cRandom0, cComparable0. • bNamespace aggressively, and namespace services by concept0, not by suffix: cSoftware::Publish0, cRss::BlogFeed0, cStore::CheckoutService0 — not a flat folder of seventeen files ending in c_service.rb0. • bUse the same structure everywhere0: models, controllers, views, helpers and specs mirror each other. A request spec lives in cspec/requests0, a model spec in cspec/models0 — 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. 15. The WarpEngine boundary 1══════════════════════════ • bAnything host-specific is an adapter with a default, and the default is btoday's behaviour.0 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. • bShop-specific code never goes into the engine.0 The engine announces and configures; the host decides. • bThe engine talks outwards with cActiveSupport::Notifications0, not callbacks into the host. The host subscribes (cwarp_engine.publish0, cwarp_engine.download0). • bExtend an engine model through its load hook (cActiveSupport.run_load_hooks(:warp_engine_software, self)0), never by reopening the class. • bThe engine's URLs are taken0: c/api/software*0, c/api/builds0, c/api/download0, c/api/service0, c/api/auth/*0, c/api/ci/*0, c/build/*0, c/file/*0. Our own JSON API is versioned (c/api/v10), so it cannot collide. cmount WarpEngine::Engine0 is balways the last line0 of croutes.rb0. • If you deliberately shadow an engine route, the route needs a comment saying why band0 the name of the regression spec that protects it. • bWrite to an engine model the way the engine expects0: cupdate!0, 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. • bWhen a seam replaces old code, delete the old code.0 After the storage adapter landed, four other places still computed the file base path; two of them were already dead. 16. Configuration 1════════════════ • bAll cENVb reading happens in one place0, cconfig/application.rb0, into cconfig.x0: p config.x.downloads.grant_ttl = 15.minutes p config.x.images.container_path = ENV.fetch("IMAGE_CONTAINER_PATH", "/images"» p ) • The code reads cRails.configuration.x0, never cENV0 directly. One place shows what the app expects from its environment, and a test can override it. • bOne name per thing.0 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. • bNo monkey patching from an initializer.0 Reopening cApplicationController0 in a cto_prepare0 block hides behaviour from the file that declares the class — put it in a concern and include it. 17. Views and styles 1═══════════════════ • Tailwind, and ba named class for anything that appears more than twice0. If ctext-2xl font-semibold0 occurs 81 times, that is a heading class asking to be born. • bSemantic colour tokens, not raw palette names.0 csurface0, cline0, cmuted0, caccent — so a theme change is one file, not 134 views. • bA theme is a token swap.0 Overriding other blocks with c!important0 is how a stylesheet becomes unmaintainable; a block decides how it looks in a theme, through its own modifier class. • bOne block never styles another block.0 c.sidebar .article {}0 is out; either the element belongs to the block (c.sidebar__article0) or the block gets a modifier (c.article.is_summary0). • Allowed pragmatic exceptions: children of compound HTML (ctable0, cdl0, cul0), c:before0/c:after0, pseudo-selectors, media queries, markdown or WYSIWYG content inside one container (c.wiki-content0), and markup generated by a third-party library. • bNo queries in views0, and no cincludes0 either — the controller or service prepares the data. A query inside a loop in a partial is an N+1 with extra steps. • bNo inline cstyle:b attributes in ActiveAdmin pages0 when the project already has an admin stylesheet. Repeated markup gets a class; a long ccase0 for icons gets a presenter. • A view that needs six instance variables from five services is not a cshow0 — it is a worksheet, and it wants a presenter or a query object. 18. JSON API 1═══════════ • Blueprinter serializers, bcamelCase keys0, and serializers only for the API — never for HTML pages. • bAnnotate public endpoints with apipie0 (capi0, cparam0, creturns0, cerror0), listing the response properties. That is what c/api/docs0 and the swagger page are built from. • bWrite down why an endpoint is public.0 "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. 19. Tests 1════════ • bUnit tests and full-stack tests are what pay.0 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. • cspec/modelsb for every invariant0: 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. • cspec/servicesb is the centre of gravity0 — every branch, every failure path. • cspec/requestsb for authorisation, status codes and redirects.0 The download gate has a mandatory regression spec. • bEvery screen is kissed by at least one full-stack test.0 One or two paths through it, not every edge case. With Hotwire this is not optional. • bOne command runs everything0, with no extra setup, and the test data is created by the tests. • bNothing is committed with a failing or pending test.0 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. 110. Gems and upgrades 1═════════════════════ • bPin every gem0, and write the reason next to any risky choice. The Gemfile is a good place for a sentence that saves an afternoon. • bNo git branch dependencies.0 A cbranch: master0 gem means anything can arrive between two cbundle update0s, and the lockfile names a revision rather than a version. If a release does not exist yet, pin a cref:0 and bump it deliberately. • bNo c:latestb container images0 in CI. A build's toolchain must not change without a commit. • Adding a gem means bowning it0: 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. • bDo not live on the bleeding edge.0 Wait for a major release to reach a few patch levels. • bMax out the tools you already have.0 Another table before Redis, cLIKE0 before a search server, a cron job before a queue — and hide the choice behind a small API (cArticle::Search.find0) so swapping it later touches one class. • bYou pay for monkey patches at upgrade time.0 Fork, fix with a test, open a pull request. 111. How we work 1═══════════════ • *iThe plan lives in a `NOTES_0.md` in the repo root** (gitignored), not scattered across commit messages: decisions, alternatives, and why. Code may reference it by section. • bPer-repo comment convention.0 In cteletype-orbit0 the "why" is written next to non-obvious code; in the cteletypegames0 monorepo explanatory comments are not used and the why goes into the commit. Both are deliberate — follow the repo you are in. • bAudit publisher actions into a table0, not a log file: who did what, when, to which record. • bSeeds come in two levels0: core data that also runs in production, and example data for development that walks the breal0 path (studio, submission, approval, cart, payment webhook), so the seed exercises the domain instead of side-loading rows. • bNew patterns get judged, not adopted.0 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.