Service Objects Are Not an Architecture
Stop treating service objects as architecture. Learn why data ownership, lifecycle, and invariants matter more than where your methods live in Rails apps.
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.
| Concern | Service Objects | Real Architecture |
|---|---|---|
| Where logic lives | Defined (in app/services/) |
Doesn’t matter |
| Who owns the data | Undefined | Explicit ownership per domain |
| Who can mutate state | Anyone with access | Only authorized code paths |
| Invariant enforcement | Social convention | Structural (DB constraints, types) |
| Lifecycle transitions | Performed but not defined | Explicit and constrained |
| Bypass prevention | None - advisory only | Hard to circumvent |
| Scales with team size | Until shared understanding breaks | Survives organizational stress |
The Pattern I Keep Seeing
Rails codebases adopt service objects as a reaction to growing model complexity, but the relief is temporary. In most 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. The codebase looks organized, but behavior becomes unpredictable. The same fragility from before, wearing a better outfit.
This is not a tooling problem. It’s a boundary problem. And as I wrote about in how Rails monoliths encode organizational assumptions, the structure of your code reflects the structure of your team - including its blind spots.
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 - roughly 3% of orders over a six-week period had incorrect totals, discovered through accounting discrepancies that took another two weeks to trace back to the root cause.
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, but naming is not the same as constraining. 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: 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
Data ownership, lifecycle, and invariants are the three boundaries that determine whether a Rails system stays maintainable under pressure.
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. This applies to deployment infrastructure just as much as application code.
What Actually Works
Explicit ownership, constrained lifecycles, and structural invariant enforcement fix the problems that service objects cannot. 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 and optimization strategies. 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
- Database Optimization Techniques in Rails
- How to Deploy Rails 8 with Kamal
- Solid Queue in Rails: A Practical Guide
- How to Integrate Silverfin API - applying architectural boundaries to API integrations
- Domain-Driven Design by Martin Fowler
- Boundaries by Gary Bernhardt