Service layer for business logic — Organizing code in a Rails monolith

Jonathan Steel - Oct 14 '22 - - Dev Community

Our engineering team builds the Aha! suite using a Rails monolith. We carefully weighed a number of options before determining that this would provide the most lovable solution for our users and our team. But the discussion does not end with choosing this path for our code.

We work hard to build the best monolith possible so we can retain a high velocity and a clean code base.

One of the foundations of having a successful monolith is to ensure it is well-organized. Having a service layer for business logic can help keep Ruby on Rails code from growing out of control. Whichever framework you use, it will only help organize the common elements. Design a system for organizing your business logic code using well-defined, unambiguous patterns. Read on to learn how we did this for the Aha! suite.

Managing business logic

Rails does a great job of giving you a place to put just about any kind of code. When you create a new Rails project, it comes with an elegant predefined directory structure. There is a place for the models, views, controllers, tests, assets, database migrations, and much more. It does have its limitations though. The most common issue that Rails projects run into is how to deal with business logic.

When you look at tutorials of Rails code, they are usually beautiful, concise, simple, and easy to read. Many real-life projects start out this way as well. But over time, more and more business logic gets peppered into that clean, elegant code. You cannot avoid this business logic because you would not have a program without it. So as your Rails program grows and ages, all these clean areas of code will start to grow and look less and less like the ideal "Hello, world" examples. The question then quickly arises of where this code should go.

Often this complex business logic will start to collect in the controllers. A very common practice is to push that logic into the models in an attempt to keep the complexity in one spot. The size of your models is going to grow proportionally with the size of your application. Combining all your complicated and most frequently used logic together into a single location that never stops growing is a disaster waiting to happen. This will start to get painful as your application becomes a monolith and the code becomes unreadable.

Does Rails have a place for business logic? The Rails guides mention the lib directory as a place for extended modules for your application. You'll find some examples of pushing complex code into concerns, but most concerns are for code reuse. That code is still being included in the same places we said not to put the business logic.

Never force code into a framework if there is no clear place for it — you may have to venture off the Rails instead.

Business logic attracts complexity and tends to change more frequently than other areas of your code. This creates the perfect breeding ground for bugs and regressions. Any solution should isolate the business logic, making it easier to test and accelerate future changes. When file size increases proportionally with the application size, your code is destined to be hard to read, understand, and modify. The best way to prevent this is to create code that is narrowly focused instead of serving multiple purposes.

Creating a service layer

One concrete solution is to create a service layer composed of small objects that serve specific use cases. A service layer for business logic can help keep Ruby on Rails code from growing out of control. Whether you use Ruby on Rails or another language and framework, that framework will only help organize common elements. Take a cue from your framework and design a system for organizing your business logic code using well-defined, unambiguous patterns.

A common place to create a service later is in app/services. Each object will usually only serve a single purpose or possibly a few very tightly related purposes. Keeping these objects focused and constrained to a single purpose ensures that no matter how complex your monolith gets, these objects will never get too big. Here is an example of a simple service:

class ScheduledReportRunner
  def send_scheduled_report(scheduled_report)
    return unless allowed_to_send_report?(scheduled_report)

    reply_to = calculate_reply_to(scheduled_report)
    ScheduledReportMailer.scheduled_report_message(scheduled_report, reply_to).deliver.now

    scheduled_report.next_run += scheduled_report.send_frequency.days
    scheduled_report.save!
  end

  private

  def allowed_to_send_report?(scheduled_report)
    ...
  end

  def calculate_reply_to(scheduled_report)
    ...
  end
end
Enter fullscreen mode Exit fullscreen mode

This class is small and easy to understand, use, and test.

describe ScheduledReportRunner do
  describe "#send_scheduled_report"
    it "delivers a scheduled report mail message"
      scheduled_report = create(:scheduled_report)
      expect(ScheduledReportMailer).to receive(:scheduled_report_message).with(scheduled_report, "support@aha.io").and_call_original
      described_class.new.send_scheduled_report(scheduled_report)
    end

    it "does not send a report that is not allowed to go out"
      scheduled_report = create(:scheduled_report, last_sent_at: 5.minutes.ago)
      expect(ScheduledReportMailer).to_not receive(:scheduled_report_message)
      described_class.new.send_scheduled_report(scheduled_report)
    end

    it "schedules the next run of the report"
      scheduled_report = create(:scheduled_report, send_frequency: 10.days)
      described_class.new.send_scheduled_report(scheduled_report)
      expect(scheduled_report.next_run).to be > 9.days.from_now
    end

    it "uses a custom reply_to when necessary"
      scheduled_report = create(:scheduled_report, last_sent_at: 5.minutes.ago, reply_to: "fred@aha.io")
      expect(ScheduledReportMailer).to receive(:scheduled_report_message).with(scheduled_report, "fred@aha.io").and_call_original
      described_class.new.send_scheduled_report(scheduled_report)
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

It is often useful to return rich domain objects specific to that service object. For example, instead of returning a single value, you can return an object that has a status, error codes, and multiple model objects. That object can then be passed around as a single unit that shows the correlation between the various items inside it.

Organizing with a service layer

In a controller, you can wrap all related business logic together into a single object, take the return value, and pass that on to your views. The rich return objects often eliminate the need to have multiple instance variables instantiated and drilled down through your views. Helper code can now be stored in a more appropriate place that is usable by parts of the code other than views.

Jobs and rake tasks often end up getting pretty complex as well. These should be very thin wrappers that parse arguments and then delegate to a service object. Structuring your code in this way makes it easy to call from the console if somebody wants to run a rake task or job manually. Having small classes also helps immensely with testing a monolith. By simplifying other areas of your application like models, controllers, and jobs, they now become much easier to test as well.

The service layer also provides isolation from the rest of your Rails code. Isolation is desirable to provide room for experimentation. You can use whatever coding style you want to create these objects without enforcing that structure on other parts of the code. You can use plain old Ruby objects, classic object-oriented inheritance hierarchies, object composition, meta programming, or even some esoteric bash script you wrote years ago. I prefer small and simple plain old Ruby objects, which are easy to follow.

It also does not matter how you organize files within the service layer, especially at first. As your service layer grows, patterns will emerge and you can reorganize by creating subdirectories and moving files around. But no matter how big or organized this code gets, you can always create a new subdirectory and try out a new experimental approach there.

Shipping a Minimum Lovable Product

Using a service layer helps us to align our work with our team and company goals. We always strive for the Minimum Lovable Product (MLP), only shipping features when they are ready from a technical standpoint. An MLP is an initial offering that users love from the start. It should also be loved by the engineers that are working to maintain it.

Features cannot just look good on the frontend — they should be well-organized in the background as well.

The service layer helps you ship strong code the first time. When you are first authoring a feature, it is very simple to take a chunk of code and create a service object out of it. This means you are more likely to do that right from the start instead of waiting to refactor at a later date. If you do need to refactor, it will mean shuffling around code in small isolated objects and files. That is much easier than coming back after your feature is ready and trying to collect up all the logic you sprinkled around in models, controllers, and views. The service layer is so easy to use that you will feel guilty pushing up a pull request with code where it should not be.

If used properly, the service layer will help keep your Rails code looking like the simple examples of the Rails guides. It helps to avoid technical debt and allows Rails to accomplish what it was built for. It also helps reduce the complexity of implementing, maintaining, and testing business logic. It will help you deliver great features with your growing monolith and keep you happing while doing it.

We will continue to evolve our monolith and make alterations to the architecture to meet our growing needs. Read our other posts on how we chose to utilize a monolith for the Aha! suite:

From One, Many — Building a Product Suite With a Monolith

Embrace the monolith: Adding a new product to Aha!

Sign up for a free trial of Aha! Develop

Aha! Develop is a fully extendable agile development tool. Prioritize the backlog, estimate work, and plan sprints. If you are interested in an integrated product development approach, use Aha! Roadmaps and Aha! Develop together. Sign up for a free 30-day trial or join a live demo to see why more than 5,000 companies trust our software to build lovable products and be happy doing it.

. . . . . . . . . . . . . . . . . . . . . .