From personal experience there are a couple of strong use cases for service objects.
Firstly if you follow Rails' "fat models thin controllers" methodology religiously you can easily end up with monolithic models, which is where ActiveSupport "concerns" come in. Except that we never really got on with them for one reason or another, mostly because it doesn't solve the monolithic models problem so much as sweep it under a different carpet.
Secondly if you have specific areas of interaction between different models, it's much easier to maintain (and clearer to read) service objects than it is to push these interactions into the models (making them more monolithic), and more reusable than this functionality living in the controllers (where it shouldn't be anyway).
Service objects are like a middle ground between the model and the controllers where it is easier to talk about multiple models and the interactions between them.
For example lets say you have User, Timesheet, and Invoice models and you need to implement a routine that generates a set of invoices for a given user for all of their outstanding timesheets, where each invoice corresponds to a given month of submitted timesheets.
If you were to implement this in the model, the first question is where should it live? Semantically this code seems like it belongs in the Invoice model since we're talking about invoices, but what if timesheets aren't the only way to generate invoices? Are we going to have to bloat the Invoice model every time we need a new way to generate one? We could use ActiveSupport concerns and abstract this code away a bit to keep app/models/invoice.rb nice and tidy - but we're just burying business logic deeper inside our codebase. We could try adding this code to Timesheet or User instead - but this is even less clear (developer thinks "where do I find the invoice generation routines, surely in the Invoice model, right?").
Instead, using a service object negates all of these problems. It's clear where the business logic is (it's in app/services/!) and a service object is free to orchestrate any combination of models it needs without worrying about logical boundaries between them.
Simple example:
# Does what it says on the tin; generates invoices for a user's timesheetsclassTimesheetsInvoicesGeneratordefgenerate_for_user(user)
user.timesheets.not_invoiced.group_by(&:month).map |month, timesheets|
Invoice.generate_for(timesheets)
endendend
A few simple rules I follow when using service objects:
If some code orchestrates models other itself, or if the code is clearly business logic, this code should be in a service object.
I never use class methods on service objects so that they can be stateful if desired (e.g. constructor can take arguments as context).
Service objects should not be abused for anything that "doesn't seem like it should go in the model". Sometimes mix-ins are more appropriate (aka concerns) if the functionality is not business logic and is generic and applicable to multiple models.
It took a bit of fiddling around to find a medium I was happy with, but ultimately I ended up with:
Models that manage their own state and their relationships with other models.
Controllers that delegate either to models or service objects.
Service objects that perform business logic and complex inter-model orchestration.
Confidence that all business logic is handled by the same common code, not spread around waiting for bugs to happen.
And I don't have to go gallivanting around my source code to remember where I put things 12 months ago.
Hope that helps. I'm a little rusty as I have had my head deep in Python, Saltstack and Docker for the past 6 months and haven't really touched our Rails app much.
Cliff Rowley
Thinker, Tinkererer, Dork.
From personal experience there are a couple of strong use cases for service objects.
Firstly if you follow Rails' "fat models thin controllers" methodology religiously you can easily end up with monolithic models, which is where ActiveSupport "concerns" come in. Except that we never really got on with them for one reason or another, mostly because it doesn't solve the monolithic models problem so much as sweep it under a different carpet.
Secondly if you have specific areas of interaction between different models, it's much easier to maintain (and clearer to read) service objects than it is to push these interactions into the models (making them more monolithic), and more reusable than this functionality living in the controllers (where it shouldn't be anyway).
Service objects are like a middle ground between the model and the controllers where it is easier to talk about multiple models and the interactions between them.
For example lets say you have
User,Timesheet, andInvoicemodels and you need to implement a routine that generates a set of invoices for a given user for all of their outstanding timesheets, where each invoice corresponds to a given month of submitted timesheets.If you were to implement this in the model, the first question is where should it live? Semantically this code seems like it belongs in the
Invoicemodel since we're talking about invoices, but what if timesheets aren't the only way to generate invoices? Are we going to have to bloat theInvoicemodel every time we need a new way to generate one? We could use ActiveSupport concerns and abstract this code away a bit to keepapp/models/invoice.rbnice and tidy - but we're just burying business logic deeper inside our codebase. We could try adding this code toTimesheetorUserinstead - but this is even less clear (developer thinks "where do I find the invoice generation routines, surely in the Invoice model, right?").Instead, using a service object negates all of these problems. It's clear where the business logic is (it's in
app/services/!) and a service object is free to orchestrate any combination of models it needs without worrying about logical boundaries between them.Simple example:
# Does what it says on the tin; generates invoices for a user's timesheets class TimesheetsInvoicesGenerator def generate_for_user(user) user.timesheets.not_invoiced.group_by(&:month).map |month, timesheets| Invoice.generate_for(timesheets) end end endA few simple rules I follow when using service objects:
It took a bit of fiddling around to find a medium I was happy with, but ultimately I ended up with:
And I don't have to go gallivanting around my source code to remember where I put things 12 months ago.
Hope that helps. I'm a little rusty as I have had my head deep in Python, Saltstack and Docker for the past 6 months and haven't really touched our Rails app much.