Use cases and Rails

The engineering team at Onfido works hard to ensure that, while we deliver rapidly, we continue to carry out a stable and maintainable service. As a B2B company in a sensitive space - identity verification and background verification - stability and quality are incredibly important to us and our customers.

We've built a lot of the Onfido platform with Ruby on Rails. About eight months ago, we started thinking deeply about how to make sure that Rails - which served us pretty well as a scrappy, quick-building startup - could continue to serve us as we grow and mature.

We're still big fans of Rails: it's amazing in terms of development process and developer happiness. And, at our current stage of growth, we'd probably be insane to consider the dreaded GIANT UNHOLY REWRITE. Yet equally, we firmly believe that some elements of the "Rails way" inevitably lead to unmaintainable and bloated applications:

  1. Tight coupling between business logic and database access, leading to monolithic and difficult-to-test classes, rather than single responsibility
  2. Pollution of queries, through associations, into the view layer
  3. A natural focus, exacerbated by #1, on testing through slow integration tests or even slower feature tests. No one on the team is masochistic enough that they want a slow test suite!
  4. Encouraging concerns to break apart large objects, rather than true composition or inheritance.
  5. Don't even get me started on callbacks

You can fight against all those things, but that requires substantial energy and discipline, whereas I believe it's crucial to ensure that increasing application complexity doesn't result in exponential time and money spent asking "have we broken X?" and "where the f**k is that field used?"

Lastly, as a team, we think it's compelling to reason about systems primarily in terms of what they do - their behaviour - rather than the data that they store. Rails focuses on data first; our engineers want to focus on behaviour (verbs, rather than nouns alone)

Action-driven architecture

It was great to see we weren't the only Rails engineers who were worried by Rail's limitations in a context where accuracy, testability and stability are crucial.

Like Airbnb, it was important to us that anything we decided upon was an evolution from a standard Rails approach, rather than a big bang. Some of us had experienced "transformation" projects before; we believe the following was foundational for success:

  1. Architecture should respect existing code and effort, where possible
  2. Architecture should respect familiar language idioms
  3. Barrier to entry needs to be as low as possible e.g. shouldn't force full rewrite of existing features to use

To research!

We looked at alternative frameworks first. Some of them made ambitious claims, such as Lotus ("brings back Object Oriented Programming to web development"). We liked the thinking behind Lotus, but:

  • At the time, it didn't feel ready for production (although it's looking great and much more mature now)
  • We wanted to leverage our existing Rails code as much as possible, rather than having to rewrite everything we wanted to bring in line with our "new style"
  • Some of us had worked with the Repository pattern before, on Java and C# projects. Our experience in that world suggested that repositories become either (a) bloated or (b) leaky abstractions; in general we'd had better experience with alternatives, such as query objects

We also found a number of compelling patterns, such as hexagonal architecture and Bob Martin's clean architecture. But in both cases, we were concerned about the level of "purity" they espoused. The paucity of real world implementations we could find for the latter suggested that it was beautiful on paper, but difficult in reality (an honourable mention to Jim Weinrich's seminal talk "Decoupling from Rails").

However, we did feel those architectures introduced some interesting concepts, particularly use cases:

  1. Focussed on actions and behaviour
  2. Atomic and ideally composable
  3. Clear API for a consumer
  4. Very easy to test and reason about
  5. Represent business rules and behaviour

We'd consider them relatively analogous to "service objects", which have been written about several times elsewhere.

So we asked ourselves: how can we balance these idealistic approaches with our need for a pragmatic solution?

What did we do?

We decided that the best place to start was by introducing use cases as (a) our primary approach for representing business logic and (b) as a facade over our existing AR models, representing our data access layer.

Something like this:

Alt text

In this model:

  • Use cases can be either "commands" (make some change) or "queries" (retrieve data, without side-effects).
  • A use case takes a request: a value, a hash, or a more complex object e.g. a Virtus model
  • And it returns a response. We're not dogmatic at this stage about what they return in order to keep the barrier to change low. However, our general principle is that a use case only returns what is needed, and nothing more.
  • By implementing use cases as a facade, it also makes it easy for us to swap in different underlying implementations but maintain a consistent API.

We hunted for an implementation of a "use case" pattern that suited our needs, and found some pretty great work in the community:

We decided to use those libraries as inspiration for building something to best met our needs: Tzu.

Tzu

Tzu provides a simple interface for writing classes that encapsulate a single command.

A command needs to inherit Tzu and implement a #call method.

class DoSomething  
  include Tzu

  def call(params)
    "result!"
  end
end  

We try name our commands with verb+noun pairs. A Tzu command exposes two public methods run! and run, and returns an outcome:

outcome = DoSomething.run(message: 'Hello!')  
outcome.success? #=> true  
outcome.failure? #=> false  
outcome.result #=> "result!"  

If complex input is required, we tend to use request models. If Tzu::Validation is included, the command will call valid? on any request passed, stopping execution if the request is not valid.

class Request  
  include Virtus.model
  include ActiveModel::Validations

  validates :name, :age, presence: :true

  attribute :name, String
  attribute :age, Integer
end  

We've found breaking the request model out particularly useful for complex validation scenarios, e.g. ID number requirements across the countries in which we operate.

If a request object is explicitly defined, then Tzu will also coerce hashes passed to run, so long as that object supports #new(attribute_hash):

class DoStuff  
  include Tzu

  request_object Request

  def call(params)
    "#{params.name}, #{params.age}"
  end
end

DoStuff.run(name: "Morgan", age: 28)  

Finally, we loved how the solidusecase gem handled results. It inspired my favourite feature of Tzu: a block can be passed to run to handle outcomes. We use these heavily in our controllers:

def create  
  DoSomething.run(message: params[:message]) do
    success do |result|
      render(json: result.to_json, status: 200)
    end

    invalid do |errors|
      render(json: errors.to_json, status: 422)
    end
  end
end  


Was it successful?

We've been using Tzu in our production code for just over four months. We're really happy with our move towards a use-case-centred architecture; we feel it strikes a good balance between the "Rails way" and the needs of a more complex and mature codebase.

It's not perfect yet, but we're working on it:

  1. "Query" use cases aren't as elegant as the write side of the equation and not a good fit for Tzu. Lately we're quite intrigued by the relation approach taken by ROM
  2. Tzu is overkill for basic CRUD. We wrote Tradesman to solve this, which I'll blog in another post.
  3. Composing commands is clunkier than we'd like; we recently added Tzu::Sequence to address this problem

In tandem, we're actively moving toward a less monolithic application. Some of our other services are written in Ruby (although not Rails), and we've found that use cases help to make those small apps particularly clear and elegant!

In a later post I'll talk more about our experience so far and some of the other challenges we've faced.


If all the above sounds interesting, we're hiring!

Author image
As VP of Engineering, Morgan leads the development team at Onfido. Besides code, Morgan has a soft spot for Korean food. He enjoys making kimchi and exploring London with his French bulldog.
top