The ServiceObject pattern is a powerful way to encapsulate specific business actions or workflows in Ruby on Rails. By separating concerns and keeping your controllers lean, ServiceObjects can help make your codebase more maintainable and easier to understand.
The example provided, CreateUser, is a simple ServiceObject that takes in user parameters, creates a new user, and sends a welcome email. The call method is the main entry point for the ServiceObject and should contain all the logic needed to perform the desired action.
Here's an example of the CreateUser service class:
class CreateUser
def self.call(...)
new(...).call
end
def initialize(user_params:)
@user_params = user_params
end
def call
user = User.create!(@user_params)
UserMailer.welcome_email(user: user).deliver_now
user
end
end
To use this CreateUser
service in your controller, you can call the call
method on the class and pass in the necessary parameters.
class UsersController < ApplicationController
def create
user = CreateUser.call(user_params: params[:user])
redirect_to user_path(user)
end
private
def user_params
params.require(:user).permit(:name, :email, :password)
end
end
ServiceObjects and validation
When using the ServiceObject pattern in Ruby on Rails, it's important to consider how to handle validations. Validations are a crucial part of any application and are used to ensure that the data being passed to the ServiceObject is in the correct format and meets certain requirements.
This is where FormObjects shine!
In my Using FormObject pattern in Ruby APIs post I already talked what's so great about FormObjects. In short, the FormObject pattern is a way to encapsulate form logic and validation in a separate object. It can be used in conjunction with the ServiceObject pattern to separate validation and the actual action.
Here's an example of how you could use a FormObject and ServiceObject together in a controller:
class UsersController < ApplicationController
def create
form = CreateUserForm.new(user_params)
if form.valid?
user = CreateUser.call(form.user_params)
redirect_to user_path(user)
else
render :new
end
end
private
def user_params
params.require(:user).permit(:name, :email, :password)
end
end
In this example, the controller creates a new instance of the CreateUserForm
FormObject and passes in the user_params
. The FormObject is responsible for validating the user_params
and returning a boolean value indicating if the form is valid.
If the form is valid, the controller calls the CreateUser.call
method and passing the form.service_params
. This is the ServiceObject responsible for creating the user and sending a welcome email.
If the form is not valid, the controller will render the new template and display the errors from the FormObject.
Here is an example of how the CreateUserForm
FormObject could look like:
class CreateUserForm
include ActiveModel::Model
attr_accessor :name, :email, :password
validates :name, :email, :password, presence: true
validates :email, format: { with: /\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i }
def service_params
{ name: name, email: email, password: password }
end
end
In this example, the FormObject includes the ActiveModel::Model
module, which provides basic form functionality, such as validation and attribute accessors. The FormObject defines the attributes name, email, password and uses Rails built-in validation methods validates to check for the presence and format of the attributes.
The service_params method returns a hash of the attributes that will be passed to the ServiceObject.
By using the FormObject and ServiceObject pattern, you can keep your controllers lean and focused on their main responsibility, which is to handle the flow of the application. The FormObject is responsible for validating the data and the ServiceObject is responsible for performing the actual action.
This approach helps to separate concerns, making your codebase more maintainable and easier to test.
FormService: one step further
When working with ServiceObjects in Ruby on Rails, it can be useful to have a way to track the state of the object, including whether or not the action was successful, the result of the action, and any errors that occurred. One way to achieve this is by using a FormService, which combines the functionality of a FormObject and a ServiceObject, and returns a state object that contains this information.
Here is how FormService
might look like:
class FormService
def initialize(form_instance, service_class)
@form_instance = form_instance
@service_class = service_class
end
def call
FormService::State.new(
form: @form, service_class: @service_class
)
end
end
This class is just a syntactic sugar which makes it look like a service. Real magic happens in FormService::State
object. The FormService::State
class is responsible for holding the state of the object and providing access to the result, success and errors after the FormService is called:
class FormService::State
NO_ERRORS = ActiveModel::Errors.new(nil)
def initialize(form:, service_class:)
@form = form
@service_class = service_class
end
def success?
errors.empty?
end
def result
return nil unless success?
result!
end
def errors
@errors ||= @form.invalid? ? @form.errors : NO_ERRORS
end
private
def result!
return @result if defined?(@result)
@result = @service_class.call(@form.service_params)
end
end
Here's an example of how you could use the FormService in a controller:
def create
user_form = UserForm.new(user_params)
outcome = FormService.new(user_form, CreateUser).call
if outcome.success?
redirect_to user_path(outcome.result)
else
@errors = outcome.errors.full_messages
render :new
end
end
In this example, the controller creates a new instance of the UserForm
FormObject and passes in the user_params
. The FormService is initialized with the UserForm
form object instance and the CreateUser
service class. The call method is called on the FormService and it returns a FormService::State
object.
You can use the outcome object returned by the FormService to handle the flow of the application. In the example above, if the outcome is successful, the controller redirects the user to the appropriate page, otherwise, it renders the new template and displays the errors.
This approach allows you to separate validation and the actual action, making your codebase more maintainable, and your application more predictable. You can also make it more robust by handling different types of errors or adding more methods to the FormService::State class
.
Final thoughts
One of the advantages of using the FormService
is that it allows you to keep your ServiceObject stateless. A stateless object is an object that does not maintain any information about its previous interactions and can be reused without any side-effects. This can be beneficial in a number of ways and can help to avoid an extra layer of complexity in your application.
When using the ServiceObject pattern, it's common to pass in the data to be used for the action as an argument to the call method. The ServiceObject then performs the action using the data and returns the result. This approach can work well for simple actions, but as the complexity of the application increases, it can be challenging to manage the state of the ServiceObject.
By contrast, the FormService allows data validation and the action responsibilities to be separated. The FormObject is responsible for validating the data, and the ServiceObject is responsible for performing the action. The FormService then calls the call
method on the ServiceObject, passing in the validated data.
Additionally, by keeping the ServiceObject stateless, you can easily reuse the ServiceObject for different use cases, and also you can use it in a variety of contexts without any changes.
Top comments (4)
I love the creativity here. However I feel like these solutions are very verbose and there are cleaner/more elegant solutions already available. That's not a criticism! I only just learned about Dry Transactions myself, and they give me all the features your exampled provide, but with a much more concise DSL.
On top of all that, the Dry Transaction library supports wrapping your whole process in database transactions and is extensible using custom step adapters. It really is a fantastic library.
Check it out here: dry-rb.org/gems/dry-transaction/0....
@writerzephos , @thadeu thank you for showing me those libraries! ♥️ I need to understand how much each tool benefits compared with a bit more verbose, but a no-dependencies-based solution. I felt that my article has already quite complex code, so I skipped the part on how to make code short. In case you are curious, here is how it can be used it in our controllers with some helper methods:
I really appreciate that you shared your libraries - I learned a lot. Seems like those libraries add a lot of additional goodies and allow to have somewhat-like Organizers or better control of the multi-service flow (even though I am worried if it does not complicate testing and debugging). I think those libs might be a good inspiration for my next blog post!
Here is another great resource about Dry Transactions: youtube.com/watch?v=kkLaYoKOa-o
Another library to encapsulated this, is github.com/serradura/u-case.
Simple and beautiful method to avoid write many code.