ruby

Setting Up Business Logic with DCI in Rails

Julian Rubisch

Julian Rubisch on

Setting Up Business Logic with DCI in Rails

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.

rb
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:

rb
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:

rb
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:

rb
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:

rb
# 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:

rb
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:

rb
# 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:

rb
# 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:

erb
<!-- 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:

rb
# 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:

rb
# controller @checkout_session = Checkout.new(payable: @review).create_session
erb
<!-- 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!

Julian Rubisch

Julian Rubisch

Our guest author Julian is a freelance Ruby on Rails consultant based in Vienna, specializing in Reactive Rails. Part of the StimulusReflex core team, he has been at the forefront of developing cutting-edge HTML-over-the-wire technology since 2020.

All articles by Julian Rubisch

Become our next author!

Find out more

AppSignal monitors your apps

AppSignal provides insights for Ruby, Rails, Elixir, Phoenix, Node.js, Express and many other frameworks and libraries. We are located in beautiful Amsterdam. We love stroopwafels. If you do too, let us know. We might send you some!

Discover AppSignal
AppSignal monitors your apps