Problems to Solve
I’d just spent eight years at Kickstarter striving to craft a low-debt and lean monolith, but there were problems we’d never been able to solve. Our ActiveRecord callbacks and state machines were about as tightly tuned and elegant as they could be, but eventually the intrinsic complexity caught up.
Here are issues likely to be found in any reasonably-maintained Rails monolith:
- validations grow costly as they work to prevent unwanted outcomes in diverse processes
- validations block important processes because of bugs in unrelated data
- varying validations by user (e.g. admins) adds a model dependency on the request context
- god models ascend as they harvest logic from overworked controllers
- moving tangled features to asynchronous workers is error-prone
- engineers write and share fragile support scripts to solve common issues that have not been prioritized for an admin interface
- adding new features requires combing through lifecycle callbacks to prevent unintended side effects
- making changes to lifecycle events requires surgical precision
- the learning curve for newcomers is high, as they work to acquire deep understanding before becoming productive
Some people may assert that the problem is inherent to monolithic architecture. After working with service objects and witnessing how they hold up when exposed to the same stress patterns, I have some confidence saying that the problem is simply overuse of ActiveRecord.
The ActiveRecord contributors and community have built an amazing ORM. I have not found its equal. But ActiveRecord draws developers into two problematic usage patterns.
The first problematic pattern is validations. The Rails Way is to validate the world: every change validates everything. This is solely responsible for three of the listed problems, and contributes significantly to others by adding a point of combinatorial complexity.
The second problematic pattern is overuse of lifecycle hooks. When developers rely on them to add business logic, they intertwine behavior and disperse the logic across multiple files. This organizational problem is a significant source of combinatorial complexity.
In Search of Solutions
Service objects are objects (classes) that implement a business action such as “create a user”, “save this draft”, or “publish that blog post”.
The service objects that we use at Empatico are responsible for:
- validating a requested change
- performing the change
- performing or scheduling all side effects
Bringing these responsibilities into one place may feel dirty to the uninitiated but it’s an instance of a natural and common pattern where concerns specific to a feature are organized into a gem, an engine, a microservice, or a view component.
The result is a directory full of actions that describe the business logic of your application. You invoke them from your RESTful controllers, GraphQL mutations, background job workers, and anywhere else.
Here’s how they solve the stated problems:
- validations are faster when they focus solely on the requested change
- validations only block execution for relevant data issues
- admin validations only exist in service objects built for admins
- service objects extract actions from god models, leaving only computed properties
- service objects are already decoupled and ready to invoke from asynchronous workers
- when support engineers write and share service objects, they’re making productive progress towards offering a new admin action
- the scope of change for a new feature is much smaller thanks to encapsulation
- surgical precision is not required when the business logic is not intertwined
- newcomers have one new thing to learn (the service object pattern) but can use it to be productive much faster
Here’s a simple base class for getting started. It builds on a
result = MyServiceObject.perform(args) interface and offers automatic transactions and a hook for instrumentation.
You may also be happy using a library that offers the pattern. Check for transactions and instrumentation!
Tips for Success
Decide on a calling convention for your service objects. The example above looks like
result = MyServiceObject.perform(arguments), using Promises for a Result interface.
Create one service object per file and name them consistently with a
noun_verber.rb pattern. (I prefer the first.)
Resist the impulse to create clever service objects. You know this is happening when you invoke one from multiple locations and expect different outcomes.
The service object pattern can be used in other frameworks and languages, which likely won’t have a library with as much investment and robust functionality as ActiveRecord. It’s a transferrable pattern that every Ruby developer should understand.