Get Even More Visitors To Your Blog, Upgrade To A Business Listing >>

Service objects in Rails: how to find a mess

Oct 10, 2023 #ruby #rails Service objects are hard. Back in days, the goal was to extract business logic from controllers and models, and, in some cases, turned out to a black hole inside app/services folder.In this post we will discuss two things that are often missing: contracts and composability. After that I will share a list of anti–patterns I found while working on dozens of Rails applications. We will examine more and less popular gems to see if they help us to avoid these bad practices. The final part is all about diagnosing your code for these problems and fixing them quickly.Bear in mind that we will talk about pretty standard Rails monoliths with the relational Database, key–value storage, background jobs, mailers etc. If you have something complex (e.g., message queues or search engines)—you will be probably able to extend these cases to handle these interactions as well. Also, sometimes service objects are also called interactors or (business) actions—all issues are applicable to anything in this list.Ruby does not have explicit type annotations out of the box. In simple cases it’s easy to guess: what class will be used if the instance called user?However, what if you want to compose your services? In that case you will have to dig up some code or tests to understand types and make sure you handled all possible cases.You might be using rbs or Sorbet to get some types. If you use them—let me know if missing contracts are still an issue in your project.Ideally service objects should somehow specify their input and output types explicitly. Also, it would be really helpful to have the validation of the returned value to make sure that there are no hidden scenarios that return something different.There is a number of ways to add validation for input parameters, for instance using Rails Attributes API:You can also take a look at dry-initializer.Output validation is harder, and it’s more dangerous. Take a look at this code:What is result? Can this method raise exceptions? We can open up the source code and check, but what if it calls other classes? We will have to reach the maximum depth to get the list of possible results and exceptions. And we will do it every time for every class, because this knowledge is not written anywhere.As a homework exercise, try to estimate how much time you team could waste while figuring out these missing contracts.In functional programming composition is an act or mechanism to combine simple functions to build more complicated ones. In other words, you can compose two functions into a new one that runs the first function on the input and passes the value to the second one. You get the same thing as you would get if you call two functions manually.In object–oriented programming composition means the ability to use one Object from another. They say that there is a has–a assoctiation between them.Do not confuse it with has_one from ActiveRecord.You can call one service object from another, i.e., compose them like this:Object–relational impedance mismatch is a set of concepts that are similar between object–oriented languages and relational databases, but they work in completely different ways. For instance, objects and tuples both can hold data, but databases do not have encapsulation.The most important thing for us is the difference in error handling. In the program you can write some logic, raise exceptions and handle them, but in the database there is an additional level—transactions. When you start Transaction, you can make some changes in the database, and commit or rollback them altogether. This is called atomicity, which is represented by A in ACID).Depending on the isolation level, database can behave differently when you make queries. For instance, default isolation level READ COMMITTED can return different data when you make the same query two times if something had changed in between, but READ COMMITTED will make queries return the same data until the commit.Database is not the only thing we use when we write logic: we can also change other data stores (e.g., Redis or Elasticsearch), perform HTTP requests, work with file system and so on. These actions are not managed by transaction—after the rollback, these changes will stay unless we handle it in some way.Take a look again at the example above. Do you think it works fine? Maybe, depends on the implementation of the FindOrCreateCart. For instance, this is how it could look like:Imagine that we use this service directly as well. We need the lock and transaction to make sure that user has only one cart, and we need to increment counter (just for the demonstration purposes).This service object looks fine as well, this is how it works:However, when it’s called as a part of AddItemToCart, two bad things can happen.First one appears when FindOrCreateCart goes to one of scenarios when counter is incremented, but FindOrCreateCart raises a Rollback error after: in this case cart creation will be rolled back, but counter increment will still happen.The second one happens when something causes user.orders.find_or_create_by(status: :cart) to raise Rollback: in this case the nested with_lock block will catch the exception, but won’t rollback the transaction because it’s not the place where it was open. As a result, everything will be commited!Read more about nested transactions issue hereHow could we avoid these issues? Well, for the second one we could add requires_new: true to make sure a nested transaction will use the SAVEPOINT. The first issue is not trivial: nested service has no way to know that parent transaction was rolled back; parent service has no way to know if nested action needs a specific rollback.This is the perfect example of services that cannot be composed. One way to fix this issue is to not reuse the code at all: for instance, you can keep everything in controller actions, which will make transaction usage clearer, but I believe that it will lead us to the unmaintainable mess.Another way is to split service objects into groups: first is used only on top level (opens up transactions, sets locks etc.) and cannot be called from other services; second contains only database actions; third contains only non–atomic actions.As a summary of this section we can conclude that services should either be fully composable or composition should be prohibited at all. In the next section we will discuss more service object anti–patterns, some of them cause actions to be non—composable.Nested transactions were already illustrated above: this problem happens when one service object opens up the transaction and calls another service that tries to open up transaction as well. This can lead to the situation when nested rollback is just thrown away.This one was also illustrated above. The symptom of the problem is that action behaves differently depending on whether it was called directly or from another service object.This is kinda a sub–problem of nested transactions: if some service needs a higher isolation level that a default one and it’s called from another action that did not request this isolation level, it will either behave in a wrong way or database error will happen.I might be wrong, but all modern databases I know does not allow it. However, even if it was possible, imagine the following situation:ChildService works fine when called directly, but when it’s called inside REPEATABLE READ—it will behave in a wrong way.Non–atomic action is literally anything that changes a state of anything except the database that runs the transaction. When something fails, transaction will be rolled back, but change made by non–atomic action will stay.For example, take a look at the following service:This looks kinda fine: if user already exists than we won’t sent mail and issue discount. However, what if CreateCart fails? In this case user record will not be committed, but email will be sent and job will be enqueued.After that, later job will raise an error because it won’t be able to load (or deserialize) user from the database. Moreover, even if transaction will commit, there is a huge chance that background job processor will pick up the job before the commit, leading to the same error. Of course, it will restart and load the user, so job will succeed but there will be a reported error. Pretty nasty bug to investigate later, right?It worth mentioning that this issue can be obscure when transaction is opened on the upper level. In this case you won’t notice the issue unless you examine the whole call stack. One possible sign is that you have atomic and non–atomic action in the same class: unless there’s an implicit transaction—there is something going wrong here.This section is dedicated to another similar pattern, but causing different sympthoms is IO actions inside transactions. Imagine a following service:From the business logic perspective it looks fine: we fetch the status from the external API and update our database based on the response. However, what if that API is down?Make sure to not make ANY IO actions in the main (i.e., web server) thread cause it can consume all workers and application will be down. This is the anti–pattern itself, not related to the place where you keep the business logic. Prefer background jobs for such things instead.That will make our transaction longer. Moreover, locks will be held while application waits from the response, preventing other transactions from finishing.I know there might be exceptions, but most of the time when you have two transactions (not nested) inside the single action it means that something is wrong. What if the second one fails? Should we revert a first one? How?For instance, imagine the situation, when you need to do 3 things:The best way to do that is to run background job after the first transaction and keep steps 2 and 3 there—it will either complete or we will get the exception to our error tracker, fix the problem and re–run the job.In this section we will discuss ways how to implement service objects using existing popular gems and approaches. As you might have noticed, all these anti–patterns can be more or less easily refactored, but can we prevent them by design? Does gem design help to achieve that?Linters seem to be helpless here because usually they can check only a single file in isolation, there is no way to see if transaction was opened around. As mentioned earlier, we can mitigate these patterns if we do not have any composition, but that might be a bad solution in terms of maintenance.One of most popular solutions is interactor.Do not confuse it with Interactor pattern coming from the Clean architecture. By definition from the internet it means “little, reusable chunks of code that abstract logic from presenters while simplifying your app and making future changes effortless”. Not related to our topic at all.A very first thing that’s mentioned in the README at the moment of writing is context. Effectively it’s just a hash that contains all passed arguments, and you can add more if you want. When service runs successfully you get the whole context back, otherwise you get Interactor::Failure. You can also use context.fail! to halt and cause service to return a failure object.The context itself feels a bit dangerous, because it can be used as a replacement of instance variables, which sounds like a breach of encapsulation. Are we sure someone even wants to read these new variables?Frankly speaking, it took me a second to understand that we need to pass email and password to make this call. However, just to make things even more hard, let’s take a look at organizers:What does it accept and return? You will have to go and read specs or code of all three services. Understanding the contract becomes a really hard job.Let’s see what we got for transactions and non–atomic actions. There is an around hook you can use:I bet SendThankYou contains some non–atomic logic. Can we pull it away from the transaction?That kinda works, but what if we need to do something non–atomic inside the service in the middle of the chain? That would be really hard because you will have to not forget to pull it away as well. Moreover, in this case CreateOrder and ChargeCard cannot be used directly without wrapping them into the transaction.It worth mentioning, that there is a rollback method that can help us to do some cleanup at least.As a result, composition does not play well here too: we have to prohibit a direct usage of services that not open the transaction for themselves. Also, we should always remember to not add anything non–transactional to these services and have separate services for that.The next stop is active_interaction. Let’s rewrite our previous example:This looks a bit better in terms of contract: we can see that there are two strings expected. The returned value ({ user:, token: user.secret_token }) will be placed to the result object:We still need to read the code to understand, what is returned. Note that there is no validation to make sure that result is always the same. For instance, in some cases we might return only user, and the code that uses this service should be aware of it.What about the composition? According to the README, we can call another service using compose and pass arguments explicitly:It’s better than implicit context and organizer we saw in interactor! However, all other problems are still here: we have to split services into ones that can be directly used and manage transactions by ourselves.Let’s take a quick look at mutations:There is an input validation. The same as active_interaction there is an implicit result, but the difference is that there are no other validations except data types, README suggests to do everything manually in the execute method.Composition is done using plain old method calls:This approach has same issues as previous solutions we inspected.Another popular gem I found is LightService:What we can notice here:Contract is defined better for separate services, but it’s still a challenge to read it for the organizer. Composition has all the issues we saw before.You might be curious how gem with



This post first appeared on VedVyas Articles, please read the originial post: here

Share the post

Service objects in Rails: how to find a mess

×

Subscribe to Vedvyas Articles

Get updates delivered right to your inbox!

Thank you for your subscription

×