It has been a long time since developers have been advised against fat controllers. The most common solution was to move the burden down the stack, to the models. But concerned with associations, validations, bloated business logic, etc … model weight has grown rapidly and soon become difficult to reason about… and a new quest for solutions began.
The Unhappy Path
In the book I'm demonstrating how to model application around service objects. I'm describing a layer of abstraction…
Why don’t we keep controllers and models skinny?
One of the widely accepted solutions is the use of Service Objects.
I’ll show how I structure and chain them to improve the flow of the application and refactor the model layer.
Show me some code
I’ll illustrate with an example: users can invite people to subscribe to the website and receive an affiliation credit reward if invitations are converted to new signups.
An implementation could look like:
But… as you may have noticed, code is all over the place. The critical thing, crediting, is hidden inside a callback. We can’t see what is happening.
Red brackets illustrate this problem well:
Code for a single action is split across 3 files which is too difficult to reason about. Moreover models don’t know about the context, so they are trying to figure it out with conditions. And when your codebase gets bigger, it can be quite confusing.
Good luck with maintenance…
Extracting business logic
What is striking here, the code doesn’t reflect clearly the expected steps:
Accepting invitation =
1) create user from invitation
- create the user with data from invitation
- welcome email to invited user
2) credit inviter
These steps are illustrated with the brackets.
Service objects are made to extract these chunks of code into separate classes. This way, you can express business logic clearly and create objects with an identified context.
Let’s create three Service Objects: AcceptInvitation, CreditUser & CreateUserFromInvitation.
What about application flow?
Interestingly enough I had the same train of thought as the one described in Kamil Lelonek’s blog post. In a nutshell: you can rely on exceptions, on success? / failure? predicates or event based services.
But whenever you need them to call each other, you end up entangled in conditionals to know whether everything went well and get return values.
This is why I ended up creating an alternative approach 2 years ago.
As Ruby developers, we lack a mechanism builtin the language to chain actions (like pipes in unix or pipelines in elixir). This is where the waterfall gem can come in handy. Every Service Object has its success and error paths and you can connect their flow together.
After refactoring, our accept_invitation action would look like:
Which flow representation is:
Basically service objects would be chained together, starting on the success path (green). Then if everything goes fine, they remain on it. In the example above AcceptInvitation will successfully render user.
Yet if error happens, the error path (red) would be followed, skipping any remaining actions on the success path. In other words, the flow would be dammed and redirected on the error path. In our case, AcceptInvitation failure will render error.
Implementing the services
Let’s actually implement the services we thought about.
1 . Empty out our models (yes!). This code will be moved inside service objects.
2 . CreateUserFromInvitation will create a new user with params from the invitation and send a welcome email:
3 . CreditUser will credit the user (who happens to be the inviter but now we have a generic approach):
4 . AcceptInvitation will orchestrate the above services and update the invitation:
Yes, I know, there is more code, but benefits outweigh it. By introducing this architecture we are gaining:
- business logic is expressed through named services: you know what the app does in a glance,
- it’s reusable and easy to test,
- better readability, you know what a service object does by looking at its code, not searching all over the place in every single model’s callbacks,
- no more conditional callbacks in your model, no more conditional validations in your model
The astute reader would complain data integrity is not guaranteed, and indeed its not: we didn’t use any database transaction.
Waterfall is a very small gem, around 200 lines of code, but if you follow the advice here you can just wrap the code in call methods within a with_transaction block and here you go. If you enter the error path, every database write will be reverted 😉
Want to know more about Failure Management Patterns, check out my upcoming book:
Thanks to Tomasz Ras for his help when I wrote this post.