00:00:04.980
Hi everyone, my name is Riaz Virani, and this is the Missing Guide to Service Objects in Rails. Thank you for joining us. I know it's a different circumstance than normal considering COVID, but I'm happy to have you here.
00:00:13.320
So, as I mentioned, I'm Riaz Virani. I'm a human male living here, and I figured it's kind of redundant to put a picture of myself since you're also staring at my face. However, if you want to read more about my random musings on life, you can do so at the URL listed on the screen. I'm a professional Rubyist, which is what you would call me. I've been writing Ruby pretty much every day for about six or seven years.
00:00:31.679
At the same time, I realized I had this dog—a very sad dog, I must say. He didn't actually come in the mail; we put him in there. We might have been trying to mail him out, but he didn't take that very well. Anyway, back to real life and not abusing my dog. Let's talk about service objects, which is what I told you we would discuss today.
00:01:02.760
I have sometimes encountered the claim that we don't need service objects anymore. In fact, I think I saw a recent episode on Rails with Jason, which is an excellent podcast. In that episode, he suggested that maybe the concept of service objects is a bit overused and not needed in most applications. I listened to it, and I didn't exactly agree. If you're in that camp and you don't agree with me about writing our code and business processes in service objects, that's totally fine. You're not a bad person for not using service objects or for disagreeing with me.
00:01:33.780
Sometimes, I find it a little annoying when presenters come on and tell me how I should do testing, DevOps, or whatever, making me feel bad if I don't want to do it their way. There are many ways to approach these things, and if you're interested in learning more about different approaches, let's have a talk and explore them together. But before we do that, let's start from the beginning and learn how we got here, and really we should just think about Rails.
00:02:11.760
Rails gives you a lot out of the box and is designed to do that. In fact, we would call it a batteries-included framework—it's not just a library. It provides you with the standard pieces you need to get things on the page. I'm talking about not only the standard MVC pattern but also features like Active Job, for standardized interfaces with background jobs, and Action Mailer, which allows you to send out emails. However, at the most fundamental level, to get something on the page, you need a router that defines the URL, which controller it maps to, performing all the work, and it can include models if it needs to save, create, or read something from the database, along with a view to show the result on the page.
00:02:44.459
This picture on the right illustrates the elegant set of directories you get from Rails simply by running 'rails new.' It provides you with many of the patterns and structures you need out of the box. If you look at this Rails example code, it's typical of what I'm seeing when people say, "Hey, here’s how you write your controller." It looks clean, neat, and very Rails-like. You take some parameters from the view, whether it's in the form or a POST from a JSON submission or AJAX, then pass it into a model and say, "Hey, it’s saved! Let me redirect you somewhere else"—this could be a server-rendered page or possibly rendering another page.
00:03:10.879
However, in my experience, while this looks great, life happens, and your real Rails code might look more like this: it can have a lot of complexities, including authorization, determining if someone can create an order, how payment processing works, pricing strategies, and notifications via email after an order is created. I understand the temptation to put all this logic into a controller, but I believe it doesn't matter whether you place this in the controller or a model. Sure, you can break some of this out into sub-methods, but essentially, it’s about where Rails ends and where the more complex business processes begin.
00:03:54.420
If you sympathize with me and understand what I'm saying—that Rails is great, but there are just some parts and patterns it doesn’t provide for real-life applications—and you've coded yourself into a mess, don’t feel bad. I’ve seen something like this—the warts and skeletons in the closet—in most applications I've worked on, even as a contractor. It always exists. The worst thing you can do is pretend you have perfect code, showing off a perfectly manicured lawn, perfectly tested and organized, because that just doesn’t exist in real life. However, we don’t want to be defeatist either. We want a better way forward, and this is where service objects come in.
00:04:52.440
Now, we can finally talk about service objects for real. What are service objects? I don’t actually think I have found a succinct dictionary definition of a service object in a blog post. I would say that they are code that represents and executes business processes specific to your application. It’s a fairly generic definition, but they embody processes such as creating an order or a user and encompass all the necessary steps in those processes. They don’t represent a specific order or user, nor are they purely technical concepts like a controller or view might be; instead, they convey business processes.
00:05:40.020
This brings us to the next question. Whenever we go down the route of creating a service object, there’s a lot to think about. I find that we don’t do a great job of discussing this in the community. There seems to be a plethora of talks and resources on testing, DevOps, and so on; however, I’ve noted that there aren’t many discussions focusing on how to create service objects. Some of this might be due to the unique way service objects represent business processes. Nevertheless, I believe there are certain patterns in organizing that code which can be shared.
00:06:10.440
Here are some questions we’ll explore today: If we're writing our service objects, should we lean towards object-oriented patterns or perhaps adopt a more functional approach within our service objects to organize their actions? How many public methods should we expose? For example, if our controller is like the service object doing things, should it simply say 'create order' with a perform method, or should it expose additional methods? How do we handle errors? In our previous example, we were doing all this work and then sending an email—what if sending that email fails? Do we roll back? How do we control the logic flow when there’s a sequence of processes? Moreover, what does each service object return? Does the controller receive a simple 'I did it' or 'I didn’t do it'? How does it communicate its success or failure? Finally, where do we place this code since service objects are not part of a predefined structure in Rails?
00:06:53.700
And, when is lunch? Because I’m kind of hungry. That’s a pretty important question, but we’ll address that by the end of the talk.
00:07:00.840
Now, let’s delve into object-oriented patterns versus functional patterns. I’m going to show you some code examples to make these concepts more tangible.
00:07:10.920
To give you a heads-up, object-oriented patterns in service objects may look like plain Ruby. You might have a class named 'OrderCreator' that does a specific task, resembling what we call a PORO, or Plain Old Ruby Object. For those who aren’t familiar, a PORO is just a class that doesn’t inherit from anything; it’s plain Ruby. It seems to fit the Rails approach well since it resembles other concepts in Rails. Yet, I don’t think this is necessarily the best fit for service objects, as they typically represent sequences of actions.
00:08:31.680
For instance, in our previous example of creating an order, you might take parameters, create the order, price it, send an email, and so on. This procedure-oriented approach reflects how service objects often operate. The issue with an overly OO structure for service objects is that it can make them hard to compose and reuse, primarily because the methods are often tied to instance variables specific to a single instance.
00:09:25.440
Let’s look at some real code examples to clarify these concepts. On the left side, you can see what a controller might look like. Instead of placing the logic that creates the order in the controller, we’ve moved that responsibility to another class called 'OrderCreator.' We instantiate this class, passing in the parameters and the user, instructing it to perform its task, and subsequently check if it was successful.
00:09:52.560
Now, we can test and work with our 'OrderCreator' independently of the controller logic, which is also beneficial for testing purposes. On the right, you can see a potential implementation of the 'OrderCreator.' It has an initialize method that takes in parameters and sets them to simple instance variables. When we look at the 'perform!' method, it executes the main logic and subsequently calls private methods to authorize, build, price, charge, and send emails. This setup resembles a procedure, where there’s a sequence of steps to complete, but it also entails interacting with instance variables directly within those methods.
00:10:50.700
Now, let's discuss functional service objects. I tend to favor this approach slightly more and will likely speak more positively about it, although I acknowledge that's not the standard view in Ruby circles. However, I think this pattern better models what most service objects aim to accomplish, as they perform a sequence of actions. In the previous example, we discussed authorization, building an order, and so forth; thus, a functional or procedural approach can capture this sequence effectively.
00:11:24.300
Moreover, I’ve found that with most tools revolving around this, it's usually more manageable to conceptualize a large sequence of steps through a functional lens rather than fussing over where to precisely break out methods within various objects. And yes, one last pro is simple: I like it better, so using this pattern makes me happy, which should be an essential factor in programming.
00:12:01.260
However, there’s a slight learning curve if you’re not familiar with functional programming, especially if your background is in Java or you’re predominantly OO. Furthermore, while OO design easily leverages POROs since Ruby is primarily object-oriented, adopting functional programming can be more challenging without utilizing a third-party library. Later, we'll review some libraries, such as ActiveInteraction and a favorite of mine called LightService.
00:12:41.280
Additionally, it’s valuable to consider that we’re essentially doing something similar to a state machine design, which one of my friends once pointed out. The concepts align with various design patterns, including command patterns, or the railways pattern. I’ll share this since many people may not be familiar with this material. The slides I found on SlideShare contain the best explanation I’ve come across for implementing functional programming in Ruby.
00:13:40.620
Before digging into code examples, it's essential to mention I’ll use LightService to illustrate functional methods as it helps to ease the process of writing functional Ruby, especially service objects. This library is lightweight in syntax, but if you want to delve deeper, I recommend exploring their GitHub.
00:14:37.920
Let’s examine the functional version of our service object. On the left side of the example, you'll see it remains structurally similar, though I’ve named the service differently, opting for a more action-oriented name instead of 'OrderCreator.' You can call it with parameters and a user, and on the right, the structure adapts slightly. In LightService, you’d have an 'Organizer' class, which orchestrates a series of steps where each action is categorized as part of the overall workflow.
00:15:21.060
Each action is its own class in LightService, which may vary slightly if you use a different gem, but the core concept remains. An action can extend a LightService action and specify the expected and promised keys inside the context. The context is just a data bucket woven throughout the process, facilitating easy value storage.
00:16:02.700
The insides of the action will define what happens when it executes, such as taking the supplied parameters and adding actionable insights based on its context. You’ll notice that unlike before, with the functional pattern, you don’t need to raise exceptions for errors. Instead, you can fail and return a value, effectively designing a streamlined interaction with the context when a specific step hits an issue.
00:17:02.520
Now, let’s move on to the key aspect of error handling, which I find most people overlook. It’s common to write code for an ideal happy path and not consider situations where something might go wrong. For instance, we discussed various tasks that can fail during execution. When dealing with such scenarios, how do we bubble these errors up, and how do we handle those errors in our controllers and return values to our views?
00:17:52.620
We can consider three high-level strategies for addressing this: one is using nested conditionals. You can nest a bunch of if-else statements allowing you to raise errors at any point, sending them up to the appropriate rescue statement. However, this approach often leads to messy and poorly structured code that’s hard to read.
00:18:29.760
Another method is raising exceptions. This is a familiar concept, as many gems manage control flow this way—such as the Stripe gem for payments, which communicates errors through exceptions. While it can streamline handling, exceptions come with a performance cost, as they require Ruby to collect the entire stack trace at the moment of failure.
00:19:16.420
Now, we can contrast this with the throw/catch method. This approach has similar benefits to raising exceptions while avoiding the associated performance hits. You may find that services using functional patterns also implement this internally, adding to the efficiency and clarity of error handling. By allowing a clear communication of failure cases while maintaining performance, you position your service objects for success.
00:20:03.260
Finally, let’s talk about return values. This is crucial because the return value is how the controller receives information about what happened during the service object execution. At a fundamental level, there are several options for how we can handle this.
00:20:40.100
You could simply return a Boolean indicating success or failure, which is straightforward but quite limiting when you need to access the context of the failure or any additional results. Alternatively, using structs or open structs as return values provides a more robust solution, allowing dynamic keys that carry information about the success or failure of the operation.
00:21:32.520
Another option is to forego complex structures entirely and stick to a custom class for the result. This route offers flexibility but requires maintaining a more considered structure within your service objects to prevent confusion down the line. Custom classes encourage organization but can offer too much complexity for simple tasks where simpler objects would suffice.
00:22:27.180
Now, let’s wrap this up with a question: where do you put this code—the service objects we’ve been discussing? I actually don’t have a strict answer for this, as personal organization often comes down to common sense. It’s generally preferable to organize them by domain, such as having them located in an app folder labeled 'services,' with sub-namespaces for specific areas—for example, 'orders' or 'users.' However, try to avoid creating a stack of 150 files in a single directory.
00:23:32.760
That being said, keep it straightforward and direct. Don’t overcomplicate things with excessive organizational structures. Alright, that concludes my talk on service objects! Normally, I’d conduct a Q&A session at this point, but since we are in a virtual setting, we’ll be doing our discussions on Discord for those attending the conference. I look forward to answering your questions there!