In the Ruby on Rails community, service objects have gained popularity as a way to encapsulate business logic and keep controllers and models lean. However, there's a growing trend of misusing service objects, particularly those with "-er" or "-or" suffixes. This article aims to shed light on poorly implemented service objects and provide guidance on creating more effective, domain-focused alternatives.
Many developers tend to name their service objects with suffixes like -er
or -or
, such as ArticleCreator
or UserAuthenticator
. While this naming convention may seem intuitive, it often leads to a procedural style of programming rather than embracing the object-oriented nature of Ruby.
It's becoming evident that service objects the way we often see them are not actually "objects", but just a procedures.. or better to say, processess.
Let's take a look at the real-world example of a codebase powering the dev.to community: https://github.com/forem/forem/blob/main/app/services/articles/creator.rb.
The class seems okay at first glance; at least the code is organized and lives in its own space. It could be workable. However, we're just hiding the problem in the long term.
# The code structure is taken from https://github.com/forem/forem/blob/main/app/services/articles/creator.rb
class ArticleCreator
def initialize(user, article_params)
@user = user
@article_params = article_params
end
def call
rate_limit!
create_article
subscribe_author
refresh_auto_audience_segments if @article.published?
@article
end
private
def rate_limit!
# Rate limiting logic
end
def create_article
@article = Article.create(@article_params.merge(user_id: @user.id))
end
def subscribe_author
NotificationSubscription.create(user: @user, notifiable: @article, config: "all_comments")
end
def refresh_auto_audience_segments
@user.refresh_auto_audience_segments
end
end
A Domain-Centric Approach
Instead of focusing solely on actions (creating, updating, deleting), we should model our service objects around domain concepts and processes. Hence my suggestion: let's stop pretending service objects are objects and start calling them processess instead.
NOTE: I'm not talking about DDD (Domain-Driven design) since it's a whole separate topic, however, we can be inspired by some of the concepts.
I'll be using dry-*
libraries to demonstrate how a process might have looked like:
module Articles
class PublishingProcess
include Dry::Transaction
step :validate
step :rate_limit
step :create_article
step :subscribe_author
step :refresh_auto_audience_segments
step :notify_subscribers
private
def validate(input)
contract = Articles::ValidationContract.new
result = contract.call(input)
result.success? ? Success(input) : Failure(result.errors)
end
def rate_limit(input)
limiter = Articles::PublishingQuota.new(input[:user])
limiter.within_limit? ? Success(input) : Failure(:rate_limited)
end
def create_article(input)
repository = Articles::Repository.new
article = repository.create(input[:user], input[:params])
Success(input.merge(article: article))
end
def subscribe_author(input)
subscription = Articles::AuthorSubscription.new(input[:user], input[:article])
subscription.create
Success(input)
end
def refresh_auto_audience_segments(input)
# NOTE: the naming might not be the best one for this case since I'm not aware about the actual business logic behind
audience = Articles::Audience.new(input[:user])
audience.refresh_segments
Success(input)
end
def notify_subscribers(input)
publication = Articles::SubscriberNotification.new(input[:article])
publication.dispatch
Success(input)
end
end
end
See, not only the business process is very well defined, but it also deals with domain-centric objects: Articles::ValidationContract
, Articles::PublishingQuota
, Articles::Repository
, Articles::AuthorSubscription
and etc.
⭐ Thanks to having a clear separation between Processes and Domain objects we'll stop pretending that we play in OOP game when dealing with service objects.
Let's imagine an ideal world where each app consisted of Process-like implementations. As a result, our app might look like a set of business processes. One process might start another as an async job, or some processes might be executed in the background on scheduled basis. Yet, it doesn't change the fact that an app consists of a set of business processes.
# Hotel Booking Process
module Bookings
class HotelReservationProcess
include Dry::Transaction
step :check_room_availability
step :calculate_total_cost
step :process_deposit
step :create_reservation
step :send_confirmation
step :schedule_reminders
end
end
# User Registration Process
module Users
class RegistrationProcess
include Dry::Transaction
step :validate_user_data
step :check_unique_email
step :create_user
step :send_verification_email
step :assign_default_roles
end
end
I think it goes without saying that having a clear separation of processes and a set of domain-centric objects powering these processes is a cleaner way to write business logic. However, I understand that the "ideal" world and the "real" world are sometimes completely different pictures. It always boils down to engineering contracts established in a particular team or the whole organization, and sometimes it's not that easy to change habits, especially when we're talking about legacy production applications.
Nevertheless, what we can do is refine our mental models to try to perceive "business processes" instead of "some" objects, and incorporate the business language into the domain language of our system.
Top comments (0)