Photo by Markus Winkler on Unsplash

Contexts and commands in Ruby on Rails

Our Rails applications use contexts with commands to create a place for everything, and have everything in its place.
Arjan van der Gaag
Arjan van der Gaag
Apr 21, 2023
Ruby Rails

We’re hiring full stack software engineers.

Join us remote/on-site in ’s-Hertogenbosch, The Netherlands 🇳🇱

Join us in building a fintech company that provides fast and easy access to credit for small and medium sized businesses — like a bank, but without the white collars. You’ll work on software wiring million of euros, every day, to our customers.

We’re looking for both junior and experienced software developers.

Find out more…
Ruby on Rails PostgreSQL Docker AWS React React Native

God, junk and spaghetti

The state of the art in writing Rails applications has evolved somewhat over time. Skinny controllers, fat models was the first major shift, responsible for focusing controllers on HTTP and moving business logic to models. Then came “service objects” and, of course, micro-services — and the push back that using vanilla Rails is good enough. In the end, what we’ve all been trying to avoid is building god objects, junk drawers and spaghetti code. In other words: a place for everything, and everything in its place.

Contexts and commands

After some experimentation, the pattern we’ve landed on at Floryn is contexts and commands. It consists of grouping business logic into context modules, and providing a small top-level API as commands. This is best illustrated with an example:

# lib/bank_accounts.rb
module BankAccounts
  def self.enable_bank_account(bank_account)
    # ...
  end

  def self.disable_bank_account(bank_account)
    # ...
  end
end

This is a simple module, exposing two methods to the outside world to enable or disable a bank account. Unless truly trivial, these methods would typically be implemented using command objects:

# app/contexts/bank_accounts.rb
module BankAccounts
  def self.enable_bank_account(bank_account)
    EnableBankAccount.new.call(bank_account)
  end
end

# app/contexts/bank_accounts/enable_bank_account.rb
module BankAccounts
  class EnableBankAccount
    def call(bank_account)
      # ...
    end
  end
end

This code would typically live in a app/contexts directory. But the namespace can still be used in other app directories to organise components:

# app/models/bank_accounts/bank_account.rb
module BankAccounts
  class BankAccount < ApplicationRecord
  end
end

# app/jobs/bank_accounts/enable_bank_account_job.rb
module BankAccounts
  class EnableBankAccountJob < ApplicationJob
    def perform(bank_account)
      BankAccounts.enable_bank_account(bank_account)
    end
  end
end

What of the Active Record pattern?

The Active Record pattern explicitly combines business logic and persistence in a single object. The ActiveRecord library Rails provides is one of the reasons why Rails is such a productive framework. Using contexts and commands, you don’t have to throw out the baby with the bath water: ActiveRecord models can still contain business logic, provided it is tightly coupled to its data. Contexts provide a natural place for more non-ActiveRecord models and logic not tightly coupled to the model’s data.

We do try to keep any dependencies between ActiveRecord models and contexts going in a single direction: our higher-level context commands can use lower-level ActiveRecord models, but not the other way around.

The top-level namespace stays

Not everything the application does can fit into discreet contexts. Some models cut across context boundaries. That is fine, and we have a bunch of models in app/models that simply live in the top-level namespace, as is the Rails default.

Even with top-level namespace models, we can take care to group functionality by context. For example, a context could define a model concern:

# lib/bank_accounts/customer.rb
module BankAccounts
  module Customer
    extend ActiveSupport::Concern
    
    included do
      has_many :bank_accounts
    end
  end
end

We can then use that in our top-level Customer model:

class Customer < ApplicationRecord
  include BankAccounts::Customer
end

Pros and cons

We see several benefits in organising our code into contexts.

  1. First, code that belongs together is grouped together. That makes it easier to understand all the moving parts involved in a particular subdomain — especially when there many such subdomains, such as in our majestic monolith.

  2. Second, code is given some context (pun intended). Rather than merely defining some ActiveRecord associations or some service objects, it is easier to understand what particular domain a piece of code serves.

  3. Third, writing a context module with a discreet public API nudges developers in the right direction of thinking about capabilities and separation of concerns. It nudges us towards better-designed code.

  4. Fourth, the discreet context API provides a nice front, beyond which the refactoring of the “internals” of the context becomes easier.

We do still face some issues with this approach. Some contexts might get intertwined, which is not always immediately obvious on the surface. Contexts do not fit very naturally in the default Rails project structure, leading to files that technically belong to a context being spread over many different files and directories on disk. And since it’s not a Rails convention, it takes a bit of effort to get existing team members and newcomers up to speed with how things work. Finally, writing new code following this pattern always feels a bit like overkill in the beginning — while at the same time, not writing it leads to it becoming harder to add after the fact. We’re still experimenting with how best to deal with these issues.

Conclusion

Our codebase is constantly in flux and full of imperfections. But as our development continues, our efforts tend to converge to this pattern of contexts and commands. Even in a majestic monolith, we’re slowly but steadily creating a place for everything, and putting everything in its place.

Floryn

Floryn is a fast growing Dutch fintech, we provide loans to companies with the best customer experience and service, completely online. We use our own bespoke credit models built on banking data, supported by AI & Machine Learning.

Topics
machine-learning people culture rails online-marketing business-intelligence Documentation agile retrospectives facilitation
© 2023 Floryn B.V.