Extracting side effect from Service Objects
Bài đăng này đã không được cập nhật trong 9 năm
Introduction
In the first chapter of "Functional Programming in Scala", it is discussed how to extract side effect from your logic and why it is good. In the example, they refactor a code of buying a coffee. The original code was doing two things. One is to make a Coffee object and another is to charge to a credit card.
class Cafe {
def buyCoffee( cc: CreditCard, p: Payments ): Coffee = {
val cup = new Coffee()
p.charge(cc, cup.price)
cup
}
}
In this code p.charge()
has a side effect. It will send a payment request to an external service. They said it is not good and refactored this code so that it does not have any side effect like the following.
class Cafe {
def buyCoffee( cc: CreditCard ): (Coffee, Charge) = {
val cup = new Coffee()
val charge = Charge(cc, cup.price)
(cup, charge)
}
}
The new code just creates two object cup
and charge
and return them to the caller. Absolutely no side effect. Purely functional. It vanishes all side effect from our code. Wow. Great. Awesome.
So, can you tell me, what is great indeed?
Why side effect matters
In the above example it seems that it just kicked out the side effect to the caller. buyCoffee
method does not have a side effect anymore, it is true. But we still have it somewhere. It just delayed the side effect. So there isn't any big difference, is it? So what is great? Actually it is not very easy to understand it if you only look at this one use case because the advantages of this code are reusability, composability and testablity.
In the example above, the buyCoffee
method just create two objects. So it is easy to reuse this code for other purposes. For example, when you want to show users the price before they really buy it, we can show them the charged amount. And it is composable because we can combine two charges into one charge and merge two external payment requests to one request. And it is easier to test than the original code because original one has side effect and depends on the external services, but still the main business logic which needs a test is in the buyCoffee
method.
So the key idea is to separate the Planning part and Execution part. In the above example, buyCoffee
is a planning about what buyer will get and how much he will pay. And the p.charge()
method is the execution part. If we divide like this way, all the complexity related to business logic is in the Planning part and all the complexity between external resouce is in the Execution part. So two kinds of complexities are not mixed together. And execution part is rather simple because all complicated business logic is already done in the Planning part. Execution just executes the plan.
Example: ActiveRecord
ActiveRecord is one typical example of this design. In the age of Rails 1, we only have simple API for query like this:
Model.find( :all, :conditions => ["id < 3000 and deadline < ?", Date.today.next_month], :order => "id desc")
It immediately returns the list of found models. So it is not reusable nor composable.
But now we can query like this
Model.where("id < 3000").where("deadline < ?", Date.today.next_month).order("id desc")
What where
method returns is a kind of query object, which is a plan of query.
The real query to the database (execution) is delayed until we call to_a
.
And as you know, it is highly reusable and composable.
In this example, it is easy to see that all complexity of building query is decoupled from execution of query.
Application to Service Objects
So now let's move our eyes on Service Objects. This is the purpose of this article. I have an idea (actually just an idea with no practice ) that we can also use this design pattern to build Service Objects in some cases.
The idea is very simple. We divide the code of a service object into the Planning part and Execution part. In the planning part, we determine what should be done in the service. And then we pass the plan to the execution part. In execution part we just execute the plan. So the code of execution should be straightforward, which means there is no complicated conditional structure in the execution part.
By this way we get some advantages. One is the reusability of the plan. For example, when user wants to do delete an order, we can tell user exactly what will happen in advance and ask him to confirm in a very clear way. Or we can send message to user what has happened after the execution. And it is composable. For example, when we update many orders at a time and we want to send email to customers, we can merge multiple emails to same user into one email. When we realize that our code is slow and we need some optimization, it will also help. As for the testing it is easier to test because Planning part has no side effect. And because we can easily stub plan object, we don't need to prepare complicated data for testing execution part.
Let's look at the following class. This is a service object for updating an order. When order is updated, we want to change the current total point given to the user.
class Service::UpdateOrder
def update( order )
update_points( order )
order.save!
end
def update_points( order )
member = order.member
member.point += point_diff( order )
member.save!
end
def point_diff( order )
old_order = Order.find(order.id)
order.acquired_point - old_order.acquired_point
end
end
In the above code, calculating the new point and updating data is mixed together.
We can divide this into two parts like the following.
class Service::UpdateOrder
def update( order, plan = Plan.new( order ) )
order.save!
update_points( plan )
end
def update_point( plan )
member = plan.member
member.point = plan.new_point
member.save!
end
class Plan
attr_reader :new_point, :member, :order
def initialize( order )
@order = order
@member = _member()
@new_point = _new_point()
end
def _member
@order.member
end
def _new_point
old_order = Order.find(order.id)
member.point + order.acquired_point - old_order.acquired_point
end
end
end
In this code we make a Plan object and do all claculation when starting service method.
All the planning is done in Plan.new
. Then we execute the plan in the body of the method.
Because of this structure, we can create Service::UpdateOrder::Plan.new(order)
and we easily show how much the user's point will be after the update.
About the testing, execution part (update_point
) only depend on plan. And the plan can be given as initial argument of service object. So we can create stub to control the behavior of the execution part.
Notice that in the original code, order.save!
must be done after updating point because inside update_point
old order data is loaded from database. But in the new code, this calculation is done beforehand so the order of execution is not relevant. We have less constraint and less bugs.
Also notice that all the business logic is done in the Plan
class. So the execution part is rather straightforward.
Now, Business layer and Infrastructure layer are happily decoupled.
But I'm not going to say that this pattern is universary applicable. I think we can enjoy juice of this pattern only when the service is complicated. For small service, introducing more layers just introduces more complexity.
All rights reserved