This post originally appeared on the AppSignal blog as part of a two part series
Ruby on Rails with its strong emphasis on convention over configuration has obviated many architectural considerations that were causes of 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 the app does”.
Another way to phrase this question is:
Where do we put all the transactional code?
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 the one hand, there’s the “Golden Path” which fully embraces MVC and is most prominently followed by 37Signals. On the other hand there is Domain Driven Design (DDD) which is practiced by companies like Shopify and consultancies like Arkency.
All these alternative approaches look pretty heavy handed. Which one is for you depends very much on
- how many business domains an app spans (DDD)
- organizational structure
- app architecture/infrastructure (microservices/monolith, k8s/cloud/…)
Let’s take a closer look at some of the familiar alternatives, and their pros and cons:
Fat Models
are models that are 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 is really belonging to the representation of the object it describes.
-
Logic in Callbacks: An enduring controversy, model callbacks are really a two-sided 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 could follow: Only use model callbacks to prepare or postprocess
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 is merely an act of covering up the mess 🌶️.
The points listed above can be summarized as a question of perspective: Paraphrasing Jim Gay, is a look from within an individual model actually a good vantage point to design what your app does?
This is something a more recent development in Rails development has picked up, 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 example from above, 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 their focal point is:
This object does not encapsulate any instance state.
This is a sign that they do not actually, as Jason Swett has noticed, describe any concept of our domain.
Others, like Avdi Grimm have observed that they run counter the accepted practice of representing objects of your domain with nouns, and messages sent to these objects with verbs. Classes named using the nominalization of a verb are an indicator that you identified a message you want to send but couldn’t come up with a receiver, i.e. a matching role to send it to. Such objects with 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 to 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 are 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 harder to fix.
Event Sourcing
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 (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, take a look at https://railseventstore.org/, for example.
Enter DCI (Data, Context, Interaction)
DCI is an often overlooked paradigm that’s able to circumvent a lot of the issues outlined in the sections above, while still adhering to MVC. More importantly though, it’s a code architecture style that simultaneously lets us consider the application as a whole, while at the same time avoiding the introduction of objects with unclear responsibilities. Without further ado, here’s the definition from Wikipedia:
The paradigm separates the domain model (data) from use cases (context) and roles that objects play (interaction). DCI is complementary to model–view–controller (MVC). MVC as a pattern language is still used to separate the data and its processing from presentation.
One of the main objectives of DCI is to improve a developer’s understanding of system-level state and behavior by putting their code representation into different modules:
- slowly changing domain knowledge (what a system is)
- rapidly changing system behavior (what a system does)
Moreover, it tries to simplify the mental models of an application a developer has to deal with by grouping them into use cases.
Let’s break this down into its individual parts:
Data
In DCI terms, Data encompasses the static, descriptive parts of what in MVC we call the Model. It explicitly lacks any functionality that involves any interaction with other objects. In other words, it’s devoid of business logic.
class BankAccount
attr_reader :balance
def increase(by)
@balance += by
end
def decrease(by)
@balance -= by
end
end
Consider the simple BankAccount
class above: It allows querying its balance, as well as to increase or decrease it. But there is no such concept as a transfer to another account.
Wait a second, I hear you say, isn’t that the description of an anaemic model? And isn’t that the most horrific anti-pattern of all time 👻? Bear with me for a moment…
Context
The point of DCI is not to strip models of any domain logic, but to dynamically attach it when it’s needed, and tear it down afterwards. The concept of Context realizes this.
A context is responsible for identifying a use case and mapping data objects onto roles that those play therein.
The nice thing, by the way, is they align nicely with our mental models of every day processes. People don’t carry around every role with them all the time, either.
In a school classroom, for example, we’re all people, but some are teachers, and some students.
In a public transport, some are passengers, some are conductors. Some even play multiple roles at once, and they may change over time:
- I’m a passenger,
- if it’s a long ride, I might also assume the role of book reader,
- if someone calls me on the phone, I’m simultaneously a conversation participant,
- if I travel with my daughter, I’m also her Dad and have to look after her,
- and so on…
Let’s continue with the BankAccount
example from above, and expand it by the requirement for transferring money from one to the other. Consider the following module, which just defines a method to transfer money from one account to the other:
module MoneyTransferring
def transfer_money_to(destination:, amount:)
self.decrease amount
destination.increase amount
end
end
The key notion in this snippet is the reference to self
, as we shall see in a moment.
The beauty of applying this pattern in Ruby originates from the ability to inject modules at run time. A context would then map the roles of source and destination:
class Transfer
delegate :transfer_money_to, to: :source
def initialize(source:)
@source = source
@source.include(MoneyTransferring)
end
end
Thus, now BankAccount
is equipped with the ability to transfer_money_to
another account, in the Transfer
context.
Interaction
The final part of this pattern comprises everything the system does.
It is here that through triggers the use cases of the application are enacted:
Transfer.new(source: source_account)
.transfer_money_to(destination: destination_account, amount: 1_000)
The source and destination roles are mapped to the respective domain models, and the transfer takes place. In a typical Rails app, this would happen in a controller, or a job - in some cases even in model callbacks.
A critical constraint of DCI is that these bindings are guaranteed to be in place only at run time. In other words, the Transfer
object will be picked up by the garbage collector afterwards, and no trace of the mapped roles remains with the domain models.
If you flip this around, you’ll notice that this ensures that DCI roles are generic, which makes them both easier to reason about and test. In other words, the Transfer
context makes no assumptions about the kind of objects its roles are being mapped to, it only expects increase
/decrease
methods. The fact that they are BankAccounts
with attached state is irrelevant! They could equally be other types of objects (Wallets
, StockPortfolios
, MoneyBox
, …), the context does not care. It is only through its enactment in a certain use case that the roles are associated with certain types, and as the snippet above shows, it’s succinct and readable.
Case Study
I want to conclude this article with an example from a real-world app where I use DCI to organize parts of the business logic. I will attempt to show how regular Rails MVC can be used to enact a DCI use case. Note that I’m using Jim Gay’s surrounded gem to strip away some of the boilerplate.
Here’s a Checkout
context that includes methods to create and fulfill a Stripe::Checkout:Session
object:
# app/contexts/checkout.rb
class Checkout
# ...
role :payable do
def create_session
Stripe::Checkout::Session.create({
line_items: line_items, # provided by model
metadata: {
gid: to_gid.to_s
},
success_url: polymorphic_url(self)
# ...
})
end
def fulfill
# ...
end
end
end
Imagine that the class method role :payable
is just a wrapper around the manual .include(SomeModule)
we did above; create_session
and fulfill
are called the RoleMethods of this context. Note that in this case, the create_session
method only relies on self
, to_gid
(which is present on any ActiveRecord::Base
subclass), and a line_items
accessor.
We can now write a test for the enactment of this context:
class CheckoutTest < ActiveSupport::TestCase
# VCR/Stub setup omitted
test "created stripe checkout session includes gid in metadata" do
@quote = quotes(:accepted_quote)
session = Checkout.new(payable: @quote).create_session
assert_equal @quote, GlobalID::Locator.locate(session.metadata.gid)
end
end
This looks good, now let’s take a look at two separate use cases for this context:
Use Case 1: Quote Checkout
In my app, a Quote
is a bespoke offer to a certain customer. It typically contains only one line item, which will be used by Stripe to create a price:
# model
class Quote
def line_items
[{price: price_id, quantity: 1}] # Stripe::Price
end
end
In a controller, now a Stripe Checkout session can be created as follows:
# controller
@checkout_session = Checkout.new(payable: @quote).checkout_session
This is then used in the view to send the customer to a Stripe Checkout form via a simple link:
<!-- view -->
<%= link_to @checkout_session.url, target: "_top", class: "btn btn-primary", id: dom_id(@quote, :checkout_button) do %>
<%= t(".checkout") %>
<% end %>
Use Case 2: Review Checkout
Another payable in my app is called a Review
, which contains many chapters, each of them their own line item:
# model
class Review
def line_items
chapters.map do |chapter|
{price: chapter.price_id, quantity: 1} # Stripe::Price
end
end
end
Apart from exchanging the payable
, the code for enacting the checkout use case stays exactly the same:
# controller
@checkout_session = Checkout.new(payable: @review).create_session
<!-- view -->
<%= link_to @checkout_session.url, target: "_top", class: "btn btn-primary", id: dom_id(@review, :checkout_button) do %>
<%= t(".checkout") %>
<% end %>
Takeaways
DCI certainly isn’t a grab-all solution to code organization, but compared to some of the other approaches it feels like a natural inhabitant of a Rails app’s ecosystem. What enthralls me the most is that it provides a clear structure for separating descriptive structure (“what the app is”) from behavior (“what the app does”) without compromising the SOLID principles for OOP design. This separation makes it a breeze to refactor key parts of such an app.
Actual design of business logic also feels more streamlined, because DCI attempts to reflect the mental models we construct of the software we build more closely.
That said, like any design pattern or paradigm out there, it’s not a hammer that fits every nail. You might find that this separation of behavior from data is something that diminishes code cohesion in your app, for example.
If you would like to try it on for size, I would recommend using it for integrating third party APIs, or with fringe concerns that don’t directly touch your app’s core functionality. That’s because those are areas that typically don’t change very often and are thus ideal playgrounds for experimenting with new tools.