← All posts

May 18, 2026

Vanilla Rails is plenty, revisited

37signals' Vanilla Rails essay is mostly right and partially wrong. Here's the line I draw between the apps that prove the point and the ones that disprove it.

rails architecture opinion 37signals

In 2023, the 37signals dev blog published Vanilla Rails is Plenty, arguing that the layers most Rails apps add — service objects, form objects, query objects, decorators, the whole app/services industrial complex — are usually avoidance of writing on the model rather than improvement on it. The post is short, sharp, and correct about half the time.

I want to take it seriously. The post is not wrong about HEY. It’s not wrong about Basecamp. It’s not wrong about most of the apps a typical Rails developer touches in a year. But it generalizes from a specific kind of business — single-purpose B2C SaaS, run by a small team, with low aggregate complexity — to all Rails apps, and that generalization is where I part ways.

This is a defense of vanilla Rails for the apps it suits, and a description of the apps it doesn’t.

Where vanilla wins

The cases where the 37signals position is straightforwardly correct.

Small teams. A team of three or four developers shares context constantly. The cost of “go read this model and you’ll see how it works” is near zero, because everyone read it last month. The benefit of an extra layer of abstraction — making the workflow findable without reading the model — is also near zero, because there’s nobody you’d be helping who doesn’t already know.

The architectural patterns I think of as “industrial Rails” — service objects, query objects, form objects, decorators, presenters, policies, validators, interactors — exist primarily to make code legible to people who didn’t write it. On a team of three who all wrote it, they’re paying for a benefit you don’t need.

Low aggregate complexity. A B2C product where the average operation touches one or two models — create a post, edit a post, comment on a post, like a comment — fits Active Record’s grain perfectly. The model’s def publish! method is the right place for the publish workflow, because the publish workflow is twelve lines of code that touches one record and maybe enqueues a notification.

When the average operation touches one or two models, the methods on those models stay short. You don’t end up with a 2,000-line User because User doesn’t have to know about twelve cross-cutting workflows.

Apps where the user-facing surface area is the product. HEY is an email client. Most of HEY’s complexity is in the user experience — how mail is filed, how the screening flow works, how the imbox/feed/papertrail split feels. The internal model is comparatively simple: there are messages, there are users, there are filing decisions. The interesting code is in views, controllers, and JavaScript. The model layer is where the data lives, not where the cleverness is.

For apps shaped like that, vanilla Rails is the right answer. Adding app/services doesn’t make the imbox feature more impressive; it makes the next developer take an extra hop to find where the logic lives.

Where vanilla stops scaling

And then there are the apps where it doesn’t.

Multi-aggregate workflows. When creating an order means: writing to orders, writing to order_items, writing to inventory_reservations, debiting a wallet, calling Stripe, enqueueing a fulfillment job, writing an audit_log, and emailing the customer — all of which have to succeed or fail as a unit — that workflow does not belong on the Order model. It doesn’t belong on Wallet either. It doesn’t belong on any one model because no one model owns it.

You can put it on Order#place! and call it a day. I’ve seen apps do this. What happens, predictably, is that Order accumulates methods called place!, cancel!, partially_refund!, escalate_to_dispute!, mark_fraudulent!, regenerate_invoice!, transfer_to_warehouse!, each of which touches six other models. After two years the Order model is 1,800 lines and nobody can find the part that creates an order.

The honest factoring is to extract the workflow. Call it PlaceOrder. Give it a #call. Have it coordinate the seven writes in a single transaction. The Order model still has its data and its invariants; the workflow has its own home; the next developer reads PlaceOrder and sees the whole thing.

This is exactly the case the 37signals post underweights. Their products don’t have many of these workflows. Many products do.

External I/O orchestration. A workflow that calls Stripe, then writes to the database, then enqueues a webhook receiver, then logs to Segment, then sends a Slack notification is not a model method. It’s coordination. Putting it on the model means the model now has an opinion about Stripe, Segment, and Slack — three vendors that have nothing to do with the model’s domain.

Vanilla Rails gives you nowhere to put this code. You can put it in the controller (it gets long), you can put it in the model (the model gets long and starts mocking external services in tests), or you can put it in a Sidekiq job (which is a transport layer, not a place for business logic). The fourth option — extract a service that owns the workflow and coordinates the model and the I/O — is the one the post argues against, and the one I find I keep needing.

Regulated domains. Healthcare, finance, anything where an auditor is going to ask “show me the code that records consent / books the journal entry / verifies the KYC check.” That code wants its own file with its own name, because the auditor is going to read the file. “Open the User model and scroll to line 1,400” is a fine answer for an engineering team and a terrible answer for a compliance review.

In regulated domains, the legibility benefit of an extracted workflow goes from “nice for new developers” to “load-bearing for the audit.” That changes the math.

Long-lived apps with team turnover. A codebase that’s eight years old, that’s been touched by forty people, that has new developers joining every quarter — the legibility tax of an undocumented workflow on a fat model compounds. The first developer to write the workflow understood it. The fortieth developer is reading a 2,000-line model and trying to figure out which of its methods is the entry point and which are helpers. Extraction is documentation: a service named PlaceOrder tells you, before you read a single line, what the file is about.

37signals doesn’t have this problem at the same intensity, in part because the team turnover at 37signals is famously low. Most companies don’t have that property.

The honest middle

The position that survives both arguments:

  • Most Rails apps don’t need service objects. The layer is the wrong default.
  • Some specific Rails apps need service objects badly, and pretending they don’t produces 2,000-line User models with twelve cross-cutting workflows fused into the noun.
  • The bar for extracting a service should be high — multi-aggregate, external I/O, transaction-spanning. If a candidate service doesn’t meet the bar, leave it on the model.
  • The bar for refusing to extract should also be high. “Vanilla Rails is plenty” applied dogmatically to a workflow that crosses six aggregates produces code nobody can maintain.

I think 37signals would mostly agree with the first three of those. The disagreement is on the fourth, and on whether they’re underweighting it because their portfolio doesn’t include the kind of app it bites hardest.

What this looks like at the keyboard

The rule I apply, in practice:

When I’m about to add a method, I ask: does this method touch more than one aggregate, talk to an external system, or wrap multiple writes in a transaction? If no — model method, every time, no exceptions, no app/services/activate_post_service.rb for one-line wrappers around post.update(active: true).

If yes — extract a service. Name it after the verb. Single public method. Constructor takes the inputs. Test it as integration, not as units with mocked collaborators. Keep it under 100 lines or split it.

That rule produces an app/services directory with maybe ten files in a mid-sized app, not two hundred. Each file earns its place. The model layer stays under 500 lines per model because the cross-cutting workflows don’t all land on the same noun.

I don’t think this is a contradiction of Vanilla Rails is Plenty. I think it’s the rule the post implies but doesn’t quite state. The two-thirds of app/services directories that are one-line wrappers should not exist; that’s correct. The remaining one-third, the workflows that genuinely coordinate, should — and pretending otherwise produces the very fat-model anti-pattern the post otherwise warns against.

The takeaway

Vanilla Rails is plenty for the apps shaped like the apps 37signals builds. For the apps shaped differently — multi-aggregate, I/O-heavy, regulated, long-lived — the vanilla position taken to its limit produces the fat-model anti-pattern by another name.

The fix isn’t more layers. It’s a higher bar for any new layer, applied honestly. Extract the workflow when it earns the file. Leave it on the model when it doesn’t. Most apps don’t need many extracted workflows. Some apps need a dozen. The two answers are both right, for different apps.

The mistake is generalizing from either one to all of Rails.


Reference: Vanilla Rails is Plenty by Jorge Manrubia, 37signals dev blog, 2023.