Business Logic
Missing Guide to Service Objects in Rails

Summarized using AI

Missing Guide to Service Objects in Rails

Riaz Virani • April 12, 2021 • online

In the talk titled "Missing Guide to Service Objects in Rails" presented by Riaz Virani at RailsConf 2021, the speaker explores the concept of service objects within the Ruby on Rails framework and the rationale for their use.

The presentation outlines the transition from Rails as a 'batteries included' framework, which provides standard patterns for application structure, to recognizing the limitations when implementing custom business logic. The speaker emphasizes that while Rails offers their basic components like MVC and Active Job, developers often encounter complexity when deep business logic is involved.

Key points discussed in the talk include:
- Definition of Service Objects: Service objects are defined as code that represents and executes business processes specific to an application. Their purpose is to encapsulate the steps necessary for tasks such as user creation or order processing.
- Patterns for Implementation: The speaker contrasts object-oriented (OO) patterns with functional patterns for structuring service objects. Using OO can result in tightly coupled code that is harder to reuse, whereas functional programming allows clearer sequencing of operations.
- Error Handling: Riaz discusses various ways to handle errors within service objects including:
- Nested conditionals, which can produce complex and unreadable code.
- Raising exceptions, which, while familiar, can lead to performance issues due to stack trace overhead.
- Utilizing Ruby's throw/catch for clean error handling without the performance penalties associated with exceptions.
- Return Values and Communication: Options for return values from service objects include simple Booleans, structs, or custom classes. The choice of structure affects how the controller receives feedback about success or errors in processing.
- Organizing Code: There is a flexible approach to organizing service object code, suggesting developers can group by domain rather than adhere to rigid directories.

Throughout the discussion, the speaker uses code examples to illustrate the concepts, emphasizing the choice of the LightService library as a facilitator for employing functional patterns effectively.

Throughout the talk, the primary takeaway is that service objects, while not mandatory, can significantly improve code organization and error handling in complex applications. The speaker encourages developers to experiment with different patterns to find what fits their needs best, supporting the overarching theme that there is no one-size-fits-all solution in programming.

In conclusion, Riaz's insights prompt a deeper reconsideration of Rails best practices, advocating for the thoughtful incorporation of service objects to enhance maintainability and readability in Ruby on Rails applications.

Missing Guide to Service Objects in Rails
Riaz Virani • April 12, 2021 • online

What happens with Rails ends and our custom business logic begins? Many of us begin writing "service objects", but what exactly is a service object? How do we break down logic within the service object? What happens when a step fails? In this talk, we'll go over some of the most common approaches to answering these questions. We'll survey options to handle errors, reuse code, organize our directories, and even OO vs functional patterns. There isn't a perfect pattern, but by the end, we'll be much more aware of the tradeoffs between them.

RailsConf 2021

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!
Explore all talks recorded at RailsConf 2021
+65