Service Objects Are Not an Architecture
Service objects organize code but don't enforce boundaries. Real architecture is about data ownership, lifecycle, and invariants - not where methods live.
Service objects are everywhere in Rails codebases. They’re the go-to solution when models get fat, when callbacks get scary, when you need to “clean things up.” Extract the logic into a service object, write some tests, and suddenly the code feels organized again.
For a while.
Then complexity returns. It just moves around. The codebase still looks clean, but making changes feels risky. Bugs become harder to reason about. Engineers lose confidence in the system.
Here’s the thing: service objects are a coordination mechanism, not an architecture. They tell you where code lives, but they don’t tell you what that code is allowed to do. Real architectural boundaries are defined by data ownership, lifecycle, and invariants. When these are implicit or unenforced, systems become fragile no matter how well-organized the services layer looks.
The Pattern I Keep Seeing
In most Rails systems I’ve worked with, the evolution follows the same arc:
- Models grow large
- Callbacks become scary
- Logic is extracted into service objects
- A services layer emerges
- Everyone feels better
For a while.
Then complexity returns, just redistributed.
At that point, the codebase looks organized, but behavior becomes unpredictable. Bugs are harder to reason about. Changes feel risky. Engineers lose confidence in the system.
This is not a tooling problem. It’s a boundary problem.
A Real Failure: The Invisible Invariant
Here’s a concrete example. In one system I worked on, order totals were being recalculated in at least four different service objects:
CreateOrderApplyDiscountsRepriceOrderSyncWithBilling
Each did “the right thing” in isolation. Each was tested.
The invariant - order total equals sum of line items plus adjustments - lived nowhere explicitly. It was assumed everywhere.
Eventually, a background job updated line items without re-running pricing logic. No exception. No validation failure. Just silent data corruption discovered weeks later through accounting discrepancies.
The postmortem? “We need better service objects.”
The actual root cause? No single place owned the invariant.
Why Service Objects Feel Like Architecture
Service objects feel architectural because they introduce names and structure.
They answer the question: “Where should this logic live?”
But architecture answers different questions:
- Who owns this data?
- Who is allowed to change it?
- What must always be true?
- What transitions are illegal?
Most service objects answer none of these. They orchestrate behavior but enforce no constraints.
This distinction matters.
Another Failure: The Bypass Problem
Another system I worked on had a well-established pattern: “All state changes go through services.”
Except they didn’t.
Nothing technically prevented a controller, job, or console script from updating records directly. Over time, exceptions crept in. “Just this once.” “This is simpler.” “We’ll refactor later.”
Six months later, nobody could confidently say which paths were safe.
The services layer became advisory, not authoritative.
That’s not architecture. That’s a gentleman’s agreement.
The Overengineering Trap
There’s another failure mode I see often: teams that go all-in on service objects end up wrapping everything in a service object, even when there’s no complexity to extract.
Suddenly you have:
class PostSaver < ApplicationService
def call
post.save!
end
end
class PostUpdater < ApplicationService
def call
post.update!(params)
end
end
class PostPublisher < ApplicationService
def call
post.update!(published_at: Time.current)
end
end
Each inherits from ApplicationService (or BaseService), follows the same pattern, and adds zero value. They’re just indirection. What was once post.save! now requires navigating to a separate file, understanding the service object convention, and maintaining boilerplate.
This happens when “use service objects” becomes a rule instead of a tool. The team starts optimizing for consistency over clarity. Every operation gets the same treatment regardless of complexity.
The irony? This actually makes the codebase harder to navigate. When everything is a service object, you lose the signal about what’s actually complex. The three-line PostSaver sits next to the 200-line OrderCheckoutProcessor, and they look identical from the outside.
Service objects only make sense when they’re organizing real complexity - coordinating multiple operations, orchestrating workflows, or encapsulating non-trivial business logic. When they’re just wrappers around single method calls, they’re ceremony without substance.
If your service objects layer is going to work, it needs discipline about when to use service objects, not just how. Otherwise you end up with the worst of both worlds: the indirection of abstraction without the clarity it’s supposed to provide.
What Boundaries Actually Matter
After seeing this pattern repeat across multiple systems, it becomes clear what actually matters. Meaningful boundaries inside Rails systems tend to align around three things.
Data Ownership
Ownership is not about file location. It’s about authority.
- Who is responsible for the correctness of this data?
- Where are mutations allowed?
- What code is not allowed to change it?
If multiple services can freely mutate the same records, ownership is undefined. Undefined ownership guarantees invariant drift.
Lifecycle
Most bugs aren’t about wrong values. They’re about wrong values at the wrong time.
Created but not validated. Paid but mutable. Archived but referenced.
Lifecycle transitions need to be explicit and constrained. If any code can jump between states freely, the system accumulates impossible states.
Service objects often perform transitions but rarely define them.
Invariants
Invariants are system laws. They’re not validations - they’re truths that must hold regardless of call path, timing, or actor.
When invariants are enforced socially rather than structurally, they will eventually be violated. This isn’t a question of discipline. It’s a question of pressure.
The Usual Objections
“But service objects improve clarity and testability!”
They do, locally. But systems fail globally. Testable code can still be architecturally unsound. Clarity without constraint is cosmetic.
“This is overengineering for Rails.”
This argument confuses ceremony with structure. Explicit ownership, lifecycle, and invariants don’t require frameworks or heavy abstractions. They often reduce code and decision surface area. The complexity already exists - the choice is whether it’s explicit or accidental.
“Service objects scale better with teams.”
They scale until shared understanding breaks down. At that point, unconstrained services become coordination bottlenecks and sources of hidden coupling. Teams step on each other without realizing it. Architecture exists to survive organizational stress, not ideal conditions.
What Actually Works
I’m not going to prescribe a specific framework, pattern, or folder structure. The shift required is conceptual, not technical:
- Make ownership explicit
- Make lifecycle transitions hard to bypass
- Make invariants impossible to ignore
How you do this will vary. Some teams centralize mutations. Others rely heavily on database constraints. Others model explicit domain operations.
What matters is not where code lives, but what it is allowed to do.
Where Service Objects Fit
Service objects aren’t the enemy. They’re useful for orchestration, coordination, and expressing workflows. But they should depend on real boundaries, not pretend to be them.
When service objects become the place where rules live, architecture dissolves into convention. And conventions fail quietly.
The Bottom Line
Most Rails systems don’t fail because they lack structure. They fail because their structure doesn’t enforce the things that matter.
If your system feels fragile despite clean code, the issue is probably not your services layer. It’s that the system cannot clearly say: “This must never happen.”
And no amount of refactoring will fix that until it can.
Need help with Rails architecture? I help teams with architecture, performance, and scaling challenges. If your Rails system feels fragile despite clean code, let’s talk. Reach out at nikita.sinenko@gmail.com.
Further Reading
- Rails Monoliths Encode Organizational Assumptions
- Solid Queue in Rails: A Practical Guide
- Domain-Driven Design by Martin Fowler
- Boundaries by Gary Bernhardt