DEV Community

Cover image for Setting Up Business Logic with DCI in Rails
julianrubisch for AppSignal

Posted on • Originally published at blog.appsignal.com

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.

class BankAccount
  attr_reader :balance

  def increase(by)
    @balance += by
  end

  def decrease(by)
    @balance -= by
  end
end
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Now a Stripe Checkout session can be created in a controller as follows:

# controller
@checkout_session = Checkout.new(payable: @quote).checkout_session
Enter fullscreen mode Exit fullscreen mode

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 %>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
<!-- view -->
<%= link_to @checkout_session.url, target: "_top", class: "btn btn-primary", id: dom_id(@review, :checkout_button) do %>
  <%= t(".checkout") %>
<% end %>
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
andreimaxim profile image
Andrei Maxim

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:

module MoneyTransferring
  def transfer_money_to(destination:, amount:)
    self.decrease amount
    destination.increase amount
  end
end
Enter fullscreen mode Exit fullscreen mode

The plan is to mix in this module into a "source" and use it to transfer money from one account to another:

source.transfer_money_to destination: account, amount: 1_000
Enter fullscreen mode Exit fullscreen mode

To me, the obvious approach would be to mix in the MoneyTransferring module in the BankAccount class (similar to a "concern"), which yields the exact same results without an intermediate Transfer class:

class BankAccount
  include MoneyTransferring
end

# Now we can do this:
source.transfer_money_to destination: destination, amount: 1_000
# vs
Transfer.new(source: source)
  .transfer_money_to(destination: destination, amount: 1_000)
Enter fullscreen mode Exit fullscreen mode

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:

transfer = Transfer.new source: source

# run fraud detection, etc

transfer.transfer_money_to destination: destination, amount: 1000
Enter fullscreen mode Exit fullscreen mode

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 dedicated Transfer class that should handle the transfer of money between accounts.

Let me propose a different approach.

class Transfer
  def initialize(from:, to:, amount:)
    @source = from
    @destination = to
    @amount = amount
  end

  def finalize!
    with_lock(@source, @destination) do
      @source.decrease @amount
      @destination.increase @amount
    end
  end
end

transfer = Transfer.new from: source, to: destination, amount: 1_000

# run fraud detection, etc

transfer.finalize!
Enter fullscreen mode Exit fullscreen mode

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 the Transfer and the BankAccount as they are supposed to work together. On the other hand, it feels very odd to see that the Transfer is delegating the money transfer to the BankAccount object because the concept of transferring money seems to be the most important reason a Transfer class might have to exist.

Anyway, very interesting example, looking forward to the next article!