Rails Architecture
Your Service Layer Needn't be Fancy, It Just Needs to Exist

Summarized using AI

Your Service Layer Needn't be Fancy, It Just Needs to Exist

David Copeland • May 17, 2022 • Portland, OR

Summary of 'Your Service Layer Needn't be Fancy, It Just Needs to Exist'

In this talk presented by David Copeland at RailsConf 2022, the main topic centers around the implementation and significance of a service layer in Rails applications. Copeland argues for the necessity of service layers in handling business logic separate from Active Records and controllers, ultimately fostering app sustainability and maintainability.

Key Points Discussed:

  • Understanding the Code Structure:

    • Copeland begins by illustrating a simplistic widget example, demonstrating initial interactions with Active Records in Rails. He highlights how business logic often becomes complicated as applications grow.
  • The Right Places for Business Logic:

    • It is emphasized that placing business logic within controllers or Active Records can lead to difficulties in testing and maintenance. Copeland argues these areas are not optimal for managing complex logic, providing insights into how it can become convoluted and error-prone.
  • Treat Rails for What It Is:

    • He advises developers to accept Rails for its intended design and functionalities rather than trying to extend its limits unnecessarily. Rails should be treated as a framework for database interaction and HTTP response generation.
  • Implementing a Service Layer:

    • The concept of a service layer is introduced as a necessary architectural component that acts as a seam between the UI and database, allowing for clear separation of concerns. Creating a simple service class to handle widget creation is demonstrated, promoting an organized structure for handling business logic.
  • Benefits of a Service Layer:

    • Copeland posits that with a service layer, changes to business logic can be made without affecting the underlying controller or Active Record, promoting a more stable system. This separation helps isolate testing and allows for easier modifications and debugging.
  • Handling Validations within Rails:

    • The discussion recognizes that while validations are a form of business logic, they still belong within Active Record due to their fundamental nature and Rails infrastructure.
  • Trade-Offs and Real-World Applications:

    • Copeland reflects on his experiences with service layers over years of development at various scales, emphasizing that while architecturally sound decisions might trade off with immediate elegance, they lead to more sustainable codebases in the long run.

Conclusions and Takeaways:

  • A service layer is essential for effective Rails architecture, ensuring that business logic is not conflated with interface or data layer code.
  • Accepting the limitations and strengths of Rails can enhance app development and sustainability.
  • The implementation of a simple service layer contributes significantly to the app's maintainability and testing efficiency, ensuring that developers can work within a clear organizational structure.

Overall, the presentation effectively illustrates the importance of a service layer in Rails applications by clarifying the responsibilities of each architectural component, improving clarity, and maintaining robust applications.

Your Service Layer Needn't be Fancy, It Just Needs to Exist
David Copeland • May 17, 2022 • Portland, OR

Where would you put your business logic if not in Active Records? The answer is a service layer. Your service layer provides a seam between your user interface and your database that contains all the code that makes your app your app. This single design decision will buoy your app's sustainability for years. You'll learn why this is and how to start a service layer today without any patterns, principles, or fancy libraries.

RailsConf 2022

00:00:12.179 I've got a lot to get through, so I'm going to get started here. This is a talk about code and what to do with it.
00:00:18.119 Let's look at some examples. We've got a widget. We all know what this means. We have a widgets table in our database, which includes a manufacturer and some validations. It's not important to see all the details, but here's a little backstory for this discussion.
00:00:30.300 If you look up 'rails controller' in the dictionary, you'd find an image of this source code. This is the most basic way to explain Rails. You can't be a Rails developer for too long without understanding this. We post parameters to our '/widgets' endpoint, which creates a new widget and calls 'save.' This checks the validations, and if they pass, it writes that data to the database and returns true. If the validations fail, it won’t write to the database, returns false, and we re-render the 'new' template, allowing the user to correct any mistakes.
00:00:43.500 So, is everybody here writing code that simply writes validated data to the database? Right? Sometimes we start off that way, but it always becomes more complicated. We always have more stuff to do.
00:01:02.940 For instance, in this widget company, what if we want to pre-order a bunch of widgets? When we create one, we might want to have some in the warehouse to be available. This is easy; we just queue this job. However, if we have an expensive widget, we don’t want to just throw a bunch of laptops in our warehouse. We need to figure things out: how many do we think we’re going to sell? It's a very nebulous process.
00:01:19.380 So, what do you do when you have an AWS process? You email somebody, and they figure it out, right? Well, we’re the biggest widget company in the world; we’re the W and Fang, but we used to be a startup. When we were a startup, we had to work hard to get manufacturers on our platform. Those manufacturers—let's call them legacy manufacturers—do something else.
00:01:46.320 So, when they make a widget, they don't go through this process; instead, we have our White Glove salesperson hand-holding them through it. To manage this, we put a row into the sales notifications table, and we sort out what to do later. This is kind of more like it, right? Maybe we wouldn't put this in the controller or use these specific words or if statements, but ultimately we have to execute these actions.
00:02:11.940 A user takes some action on the website, and we need to execute complicated, counterintuitive operations that simply must happen. We don’t get to control the requirements of our application, so we have to do whatever is needed. When a widget gets created on our website, does that code actually belong in the controller? We probably all feel like it doesn’t, but I want to talk about why.
00:02:30.360 One big reason is testing this code. What is a controller? It’s a thing we don’t instantiate; it has methods, but those methods don’t take parameters, and their return values are ignored. It's kind of a strange setup, making it difficult to test. You could do a system test, which is slow and flaky, or you can do integration tests, which let you post things to the controller that wouldn’t actually happen.
00:02:52.620 You can probably all post a Boolean to our controller in a test, but the controller never receives a Boolean; it always gets a string, and the string 'false' is truthy. This creates a problem, so maybe we should move this logic somewhere more test-friendly.
00:03:04.680 Another option is to put it in the 'after create' callback. If you don't know what that is, it runs some code after valid data is written to the database but before it's shared with everyone else. That seems reasonable, and the testing issue is solved—it becomes easier to test. However, this is also not the best place for this code. Why is that? Are active record callbacks evil and gross? No, there are real reasons.
00:03:40.560 First, if we change the business logic, all the tests that other pieces rely on will need to know about this change and either skip the callback or mock out all that logic. There’s also a semantic issue; creating a widget on the website and inserting a row into the widgets table are different things. By using the callback, we conflate them. It’s likely not true that the business wants this logic executed every time a row is inserted into the widgets table.
00:04:11.340 Even if that were true, there would be sources other than Rails that could insert into the widgets table, so the callback wouldn’t account for either case properly. So, how do we sort this out? Let's start from this piece of advice: treat Rails for what it is, not for what you want it to be.
00:04:35.040 So, what does that mean? Here is our active record again. The line of code states, 'I have a table called widgets in my database, and I want to interact with it.' Active Record is all about database operations. If you didn’t have a widgets table, you would never write that code. It emphasizes that active records are models of database tables, which is what they excel at.
00:05:01.920 But we chose to extend Active Record beyond its purpose when we tried to manage this logic within it. Controllers, on the other hand, exist to respond to HTTP. No matter what else they do, they are designed to receive requests and generate responses. If you have business logic to run, you might think to put it in a job, but that’s not the correct context. A job is for running code in the background, and there is no dedicated part for our business logic.
00:05:35.280 Where does that logic go? We know about concepts like body shaming, SOLID principles, and hexagons, but these ideas are vague. Your team might argue about what they mean instead of writing code, and they create complexity when perhaps simplicity is needed. Let's focus on where Rails organizes code.
00:05:58.920 Rails structures code by architectural components; there’s no designated space for all widget-related logic or manufacturing logic. Instead, we have building blocks that we use to construct features. Each feature might need a model, a view, and a controller, and together they form the structure of our application.
00:06:27.579 You probably don’t want all the logic in one place, and we feel it's wrong. But I think there’s a real reason for this: consider that in a different part of the organization, a great Rails developer might know nothing about your code. Even if they worked at a competing widget company, the way they create widgets may differ significantly.
00:06:50.880 Thus, the knowledge required to operate within this callback is esoteric and hard to predict. It’s also complicated and easily forgettable. We’ve all experienced that moment of confusion when trying to recall how a feature works within the code. We might remember we created a widget, but it doesn’t behave how we expect. With business logic changing frequently, inserting code all over the place makes your app harder to maintain.
00:07:23.160 Whenever changes are needed, it requires relearning parts of the application and it's more challenging if the logic is scattered throughout. Our decisions then become less about clear organization and more about where we think the logic should reside based on past readings of object-oriented programming.
00:07:47.040 The Rails guide hints at representing business data and logic without clarifying what that means effectively. We need to internalize that the framework provides us with the necessary components without forcing us to expand or misplace our business logic into unexpected places.
00:08:10.680 We could manage business logic as its own architectural layer, creating a seam that maintains clear boundaries between HTTP interactions and database type concerns. This presents an opportunity for structuring service logic without complicating the core mechanics of Rails.
00:08:30.420 The best term for this structure, or layer, is a service layer. You might remember the title of the talk: 'Your service layer needn’t be fancy; it just needs to exist.' I needed to stretch that phrase to fit the title's length, but it still sounds nice.
00:09:05.100 We have to contend with what it means to implement such a service layer. In terms of structure, rather than placing business logic anywhere in our application, we want an unfancy service layer that simply exists to handle business logic and make it clear where it resides.
00:09:39.660 Returning to our controller, while we've explored two options, the more effective one is tied to the actual triggering event. However, we’re hesitant to execute logic in this manner. Why? I dug into Ruby features to help understand how we can abstract this without over-complicating things.
00:10:11.520 You can achieve this by defining a class and creating methods within it to encompass our widget creation process. It might seem cumbersome at first, but having a class to manage this provides clarity. You’re ensuring that as your application grows, it still makes logical sense.
00:10:32.640 This method's usage may seem repetitive, but encapsulating this functionality creates an object that can easily be referenced. This creates a clear distinction in terms of what tasks manage the widget creation, allowing for current and future code maintenance.
00:11:00.000 So now let's address where this class resides. We can utilize the Rails feature of service directories for organizing our code. Rails will automatically load anything from directories you define within the 'app' folder, which is a significant advantage.
00:11:24.360 This enables us to maintain separation of concerns without bestowing magical powers on classes placed in models, views, or controllers. Everything you need to build features remains organized. We need one or more elements to properly structure and develop our features, ensuring everything is in the right place.
00:11:48.000 Now our widgets controller functions like the dictionary picture we described earlier; it simply receives the parameters and sends them off to the widget creation service, which carries out the logic from there. If it's valid, great; if it's not, cool.
00:12:09.180 The controller now only concerns itself with receiving requests and generating responses, which is what we want. Yet, I know there might be some opinions on this approach—we can get to those later. First, let’s recognize a few facts.
00:12:43.740 The widgets controller is stable with respect to item or widget creation. We can change the widget creation code, and the widgets controller is unlikely to need changes. The active record, in this case, follows the same principle; we can radically shift the widget creation process, yet the widget class is likely unaffected.
00:13:20.520 The tests that depend on inserting a widget into the widgets database won't need to change when we modify the creation process. Our aim should be to develop a system where tests fail only when the code they’re testing is broken. We create an environment where changes to widget creation are now isolated from the rest of the system.
00:14:04.080 We still haven’t abstracted Rails away using clean architecture reports or hexagons. We can leverage the Rails guide and use it as intended. Yes, you can still use all Rails features. Even if you find it necessary to run code after data is saved but before the transaction is committed, the after-create callback is still there if needed.
00:14:30.660 Next, I haven't discussed much about who I am because it may not be that interesting, but I will share that I’ve implemented this concept at different scales and seen codebases progress and grow over the years.
00:15:00.480 So, if possible, arrange to stay in one place long enough to see how software maintenance can be truly understood over time. I’ve had those experiences, and I was accountable for making decisions and contributing to the team responsible for outcomes.
00:15:18.360 Now, let's reconcile a few things. Yes, validations are a form of business logic. Active model validations work wonderfully, it's reliable, but we don't want to reinvent that process for the sake of architectural purity.
00:15:47.040 Thus, we could assert that our architectural principle is: don't put business logic into your active records. But, we can make an exception for validations. That’s acceptable in this context.
00:16:13.920 There is a trade-off between architectural purity and the desire to leverage accurate resources. But, yes, it’s not too big of a problem letting validations exist where they are; they can be utilized outside of Active Record as needed.
00:16:38.520 Your development approach doesn't cater to what is deemed beautiful code; striving for beauty in code can create confusion. This means establishing confusing processes around arguing over subjective aesthetics, making teams adhere to a singular vision of 'beauty.'
00:17:09.180 On the other hand, while we want widget.create to clearly correlate with widget creation, it simply inserts rows into the widget database table. It's fine, but I wish it had a name that more accurately reflected its functionality.
00:17:37.920 Critics say it doesn't matter whether your code is object-oriented; Rails isn't that object-oriented. Yet, as your application evolves and grows more complex, it becomes essential to organize methods around the logic they serve. Each conditional process can and does require its attention.
00:18:03.360 When you're faced with increased complexity, do you have a plan for handling it? You need to consider implications over ideas that are abstract; theory doesn't always manifest clearly.
00:18:29.640 This is how it typically goes: you’ll have services. Every feature of your application will have associated methods. These methods could involve dedicated functional approaches, but given the complexity of business logic, they’ll often become complicated.
00:18:55.080 Yet, when you do this, you gain clarity of what your application does. It’s often easier to organize what exists in reality than to work with hypothetical structures. Even though some aspects might feel messy, it's much easier to structure tangible versus hypothetical things.
00:19:24.720 Managing your app involves creating strategies everyone can learn. Making new classes and defining their functions is straightforward for Ruby developers, which simplifies many aspects when organized correctly.
00:19:43.520 For instance, if you come across a new use case like importing from CSV, you can pull out shared logic in a structured manner. The utility of having categorized classes makes it easier to move relevant functionalities into appropriate services.
00:20:07.020 This improvement is possible as you may notice commonalities between features. Employing private methods can help achieve this without requiring separate testing since the targeted operations revolve around public functions tested for integrity.
00:20:38.040 If problems arise, you can easily extract functionality into separate services that call upon others, such as having a widget creation service call an admin notifier service.
00:21:12.960 As your services develop around a shared theme, you can also establish namespacing to organize them, providing clarity while reducing confusion. Ensure you’re backing everything with tests—testing is essential in guiding refactorings.
00:21:49.920 However, remember that the risk of renaming or moving code isn't as significant as altering core functions or features. You are free to implement your philosophy within the service layer. If you prefer object-oriented paradigms, feel free to integrate them.
00:22:25.740 Ultimately, treat your application for what it currently is. Instead of presuming future needs, recognize what exists today. Allow your organization to grow organically alongside developments.
00:23:01.680 Lastly, always reflect on Rails’ strengths compared to its weaknesses. Don't over-extend its capabilities; it has all the components necessary to build efficient applications. Remember: do not place business logic in Active Records. If you take one thing home today, please let it be that.
00:23:38.520 At this point, I want to highlight that I have a book discussing these concepts in further detail, including insights on Rails. It's currently priced at $25, and I appreciate your consideration of that investment.
00:24:11.520 If any of you are from countries with different economic standards, I have a code tailored to those appropriate pricing structures. I had not anticipated time for questions, but I will open the floor now.
00:24:39.179 Yes, I have certainly discovered patterns emerging from services. Extracting functionalities leads to interdependencies evolving, naturally prompting the creation of base services with logging and requests.
00:25:00.460 Although not all can be easily categorized, the process is worthwhile as it provides useful frameworks informally, balancing common challenges with shared contexts.
00:25:23.880 Making everything callable with the same method name may seem intuitive, but I find it confusing. Furthermore, when you require method names that refer to specific processes, it can lead to awkward naming conventions.
00:25:46.620 In using a command pattern, it is often more practical to structure Rails job specifications depending on the background requirements involved.
00:26:09.000 How about testing the controller and method invocations? If it’s complicated, I'd opt for a controller test, but I lean toward system tests for simpler scenarios.
00:26:29.520 On the subject of authorization: I do not typically place it within the service layer. Instead, I design services that could sustain various contexts if called independently.
00:26:55.620 Scopes do not possess any magical properties; they are implemented through standard methods, so I prioritize managing behavior centered around the database. When establishing logical parameters, scope with names related to your business logic.
00:27:30.420 For presentational data, rarely do I use the active record directly. I often create representations dedicated to UI contexts, employing services to map these entities as necessary. If you want to discuss this further, let's converse later.
00:27:54.840 And with that, I conclude. Thank you for your time. If you want to continue the conversation, feel free to approach.
Explore all talks recorded at RailsConf 2022
+68