In our last post, we examined the most common ways to organize business logic in Ruby on Rails. They all have advantages and drawbacks, and essentially, most do not leverage the full power of Object Oriented Programming in Ruby.
This time, we will introduce another alternative that more naturally fits the mental models we apply when reasoning about the behavior of our applications: DCI.
Enter DCI (Data, Context, Interaction) for Rails
DCI is an often overlooked paradigm that can circumvent a lot of the issues outlined in the last post, while still adhering to MVC.
More importantly, though, it's a code architecture style that simultaneously lets us consider an application as a whole and avoids introducing objects with unclear responsibilities.
Without further ado, here's the definition of DCI 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 by grouping them into use cases.
Let's break this down into its individual parts: data, context, and interaction.
Data
In DCI terms, Data encompasses the static, descriptive parts of what we call the Model in MVC. It explicitly lacks 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 you to query the account balance, and 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 a description of an anemic 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 afterward. Context realizes this.
A context is responsible for identifying a use case and mapping data objects onto the roles that those play.
The nice thing, by the way, is that they align nicely with our mental models of everyday processes. People don't carry every role around with them all the time either.
Real-World Examples
A school classroom, for example, is composed of people, but some are teachers and some students.
Similarly, some people are passengers on public transport, and some are conductors. Some even play multiple roles at once that may change over time — e.g.:
- I'm a passenger.
- If it's a long ride, I might also assume the role of book reader.
- Suppose 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.
Back to Our BankAccount
Example in Rails
Let's continue with the BankAccount
example from above, and expand it with a requirement to transfer money from one person to another. Consider the following module, which just defines a method to transfer the money:
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 to Ruby is the ability to inject modules at run time. A context can then map source and destination roles:
class Transfer
delegate :transfer_money_to, to: :source
def initialize(source:)
@source = source
@source.include(MoneyTransferring)
end
end
So, now BankAccount
is equipped with the ability to transfer_money_to
another account in the Transfer
context.
Interaction
The final part of DCI — interaction — comprises everything the system does.
It is here that the use cases of an application are enacted through triggers:
Transfer.new(source: source_account)
.transfer_money_to(destination: destination_account, amount: 1_000)
The source and destination roles are mapped to their respective domain models, and a transfer takes place. In a typical Rails app, this would happen in a controller or a job — sometimes 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 afterward, and no trace of the mapped roles remains with the domain models.
If you flip this around, this ensures that DCI roles are generic, making 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 mapped to. It only expects increase
/decrease
methods. The fact that they are BankAccounts
with an attached state is irrelevant! They could equally be other types of objects (e.g., Wallets
/StockPortfolios
/MoneyBox
). The context does not care. Only through its enactment in a certain use case are the roles associated with certain types. As the snippet above shows, it's succinct and readable.
Case Study Using DCI in a Rails Application
I want to conclude this article with an example from a real-world app where I used DCI to organize parts of the business logic. I will attempt to show how regular Rails MVC can 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
(present on any ActiveRecord::Base
subclass), and a line_items
accessor.
We can now write a test to enact 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 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 Stripe will use to create a price:
# model
class Quote
def line_items
[{price: price_id, quantity: 1}] # Stripe::Price
end
end
Now a Stripe Checkout session can be created in a controller 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 a Review
, which contains many chapters, each with its 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 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.
The actual business logic design also feels more streamlined because DCI attempts to closely reflect our mental models of the software we build.
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 want to try it on for size, I recommend using DCI for integrating third-party APIs, or with fringe concerns that don't directly touch your app's core functionality. That's because those areas typically don't change very often and are thus ideal playgrounds for experimenting with new tools.
Wrapping Up and References
In part one of this two-part series, we examined common approaches for building business logic in your Rails application, including fat models, service objects, jobs, and event sourcing.
In this second and final part, we turned our attention to DCI specifically, exploring each individual part: Data, Context, and Interaction. We showed how to use the DCI paradigm in a real-world Rails application.
Happy coding!
References:
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!
Top comments (1)
I was looking forward to this post as I've rarely found good use cases for DCI in Ruby applications, meaning that the code would eventually look a lot more convoluted that it needed to be. However, looking at the code in this article, I'm still not very convinced...
Let's look at the sample code because I think it's easier to follow.
So, we have a
MoneyTransferring
module, define like this:The plan is to mix in this module into a "source" and use it to transfer money from one account to another:
To me, the obvious approach would be to mix in the
MoneyTransferring
module in theBankAccount
class (similar to a "concern"), which yields the exact same results without an intermediateTransfer
class:But let's assume that the operation is more complex than just subtracting money from one account and adding them to a different account, so we'd need an intermediate class like
Transfer
for possible fraud detection. Then the code would look like this:Hm.
The last line looks very odd. Why do we need
transfer
in the method name? Well, because the transfer is done at the account level, even though we have a dedicatedTransfer
class that should handle the transfer of money between accounts.Let me propose a different approach.
I'd argue that the above example is a lot easier to reason about because we have something (the
Transfer
class) that helps us reason about our code. There's also a natural cohesion between theTransfer
and theBankAccount
as they are supposed to work together. On the other hand, it feels very odd to see that theTransfer
is delegating the money transfer to theBankAccount
object because the concept of transferring money seems to be the most important reason aTransfer
class might have to exist.Anyway, very interesting example, looking forward to the next article!