With its strong emphasis on convention over configuration, Ruby on Rails has counteracted many architectural considerations that caused bikeshedding when building web applications.
Still, one area that has continuously piqued developers' interest is how to handle business logic, i.e., code that epitomizes "what an app does." Another way to phrase this question is: Where do we put all the transactional code?
In this first part of a two-part series, we'll explore the most well-known methods to organize your business logic in a Ruby on Rails application.
Let's get going!
There has been an ongoing debate about this topic in the Rails community, with two extremes being advocated for, and a couple of intermediate solutions.
On one hand, there's the "Golden Path", which fully embraces MVC and is most prominently followed by 37Signals.
These alternative approaches look pretty heavy-handed. The one you should choose depends very much on the following:
- how many business domains your app spans (DDD)
- organizational structure
- app architecture/infrastructure (microservices/monolith, k8s/cloud, etc.)
Let's take a closer look at some of the familiar alternatives, and their pros and cons, starting with fat models.
Fat models are models equipped with methods for processing their data, possibly resulting in side effects and updating other records. Consider this example, simplified from Jorge Manrubia's post about rich models:
# app/models/recording class Recording < ApplicationRecord def copy_to(bucket, parent: nil) copies.create! destination_bucket: bucket, destination_parent: parent end end
One problem with fat models is that unless you are really disciplined in your code hygiene, they can tend towards an assortment of code smells:
- Feature Envy: Models reaching out to other models, either to query their internals (a "tell, don't ask" violation) or to actually mutate them, are a red flag 🚩. To achieve low coupling, objects should be confident just sending messages to other objects without querying them for the results.
- Single Responsibility Principle Violations: Models encoding interactions with other models may break the rule of single responsibility. Keep rigorously asking yourself if the code you are adding to a class really belongs to the representation of the object it describes.
Logic in Callbacks: An enduring controversy, model callbacks are really a double-edged sword. A powerful tool, they can simplify a lot of imperative code, but can also hide complexity and lead to bugs that are hard to track down. Here is a rule of thumb you can follow: only use model callbacks to prepare or post-process
self. Never trigger any jobs, mailers, or other services from them.
- Tendency towards God Objects: Rich models act as attractors for functionality. Wait a few development cycles, and I'll take any bet there'll be at least one model accumulating a few dozen methods. Even splitting up such an object into model concerns merely covers up the mess 🌶️.
The points listed above can be summarized as a question of perspective. To paraphrase Jim Gay, is a look from within an individual model actually a good vantage point to design what your app does?
This has been picked up by a recent advance in Rails development: namely, service objects.
Really another title for the Command Pattern, service objects encapsulate a "unit of work", or "action", that usually involves several steps of transactional logic. Following the above example, you would typically formulate something like this:
# app/services/recording_copier.rb class RecordingCopier def call(source, destination) source.copies.create! destination_bucket: destination end end
There are multiple problematic facets of this pattern, but the focal point is:
This object does not encapsulate an instance state.
This is a sign that service objects do not actually describe any concept of our domain, as Jason Swett has noticed.
Others - like Avdi Grimm - have observed that service objects run counter to the accepted practice of representing your domain objects with nouns, and sends messages to these objects with verbs. Classes named using the nominalization of a verb indicate that you have identified a message you want to send but can't come up with a receiver, i.e., a matching role to send it to. Objects with such fuzzy responsibilities can be the hardest to refactor.
First and foremost, though, I always found this concept a bit confusing, because Rails already has a built-in primitive for this: jobs.
In my consulting practice, I've observed that jobs are generally underused because their limits and requirements can seem daunting. Let's first rewrite our example as a job:
# app/jobs/copy_record_job.rb class CopyRecordJob < ApplicationJob def perform(source, destination) source.copies.create! destination_bucket: destination end end
As you can see, there's almost no syntactical difference between a job and a service object. What, then, sets them apart?
A couple of things, actually:
- The code above isn't idempotent. If you run it twice, it will create two copies, which is probably not what you intended. Why is this important? Because most backend job processors like Sidekiq don't make any guarantees that your jobs will run exactly once.
- Jobs cannot return a value or indicate when they are done out of the box (although you can do this manually, via callbacks, for example).
There are several workarounds for this, like the magnificent Acidic Job gem or Sidekiq Pro/Enterprise features around enhanced reliability and unique jobs. Still, if they occur, bugs related to missing jobs and/or job idempotency are hard to track down and even harder to fix.
Event Sourcing ensures that all changes to application state are stored as a sequence of events. Not just can we query these events, we can also use the event log to reconstruct past states.
Source: Event Sourcing by Martin Fowler
Essentially, event sourcing boils down to a publish/subscribe algorithm with integrated versioning. It's a high-level Domain Driven Design concept that I will not discuss in detail here.
That's not to say it's not an interesting pattern. You should use it if you have advanced reporting requirements, for example. If you want to learn more about it, look at Rails Event Store.
We looked at a few of the most popular approaches to organize your business logic in a Ruby on Rails application: fat models, service objects, jobs, and (briefly) event sourcing.
In the next and final part of this series, we will look at a convenient alternative called DCI (Data, Context, Interaction). DCI caters particularly well to the mental models we employ as engineers when we reason about application behavior.
Until then, happy coding!
P.S. If you'd like to read Ruby Magic posts as soon as they get off the press, subscribe to our Ruby Magic newsletter and never miss a single post!