A Case For Use Cases

What’s the problem?

Most Ruby on Rails applications start off as simple web applications with simple requirements. As a developer, you take those requirements, map out the business domain and then implement it using the MVC pattern of models, views and controllers.

Over time, more requirements are added and simple controllers can become bloated with complex logic. The obvious next step is to create public methods on your models and move the logic into them, thus keeping the controllers simple. However, this inevitably leads to fat models.

One of the guiding principles of Software Design is the Single Responsibility Principle and fat models have too many responsibilities. In fact, you could argue an empty model that extends ActiveRecord::Base already has multiple responsibilities: persistence and data validation/manipulation. Furthermore, an empty ActiveRecord model contains 214 methods (not counting any of the methods on Object). So these classes are already tightly coupled to the Rails framework and if you put your logic in them too, they’re also coupled to the business domain.

This means a class has many reasons to change, which makes future changes harder and which also makes it more prone to bugs and merge conflicts. It also means you’re going to end up with huge classes that contain many public and private methods. Large classes make it very hard to see which methods are related to each other as well as where and how they are used in the codebase. If you think ActiveSupport concerns are going to help with that by moving methods into their own files, well now you’ve only made the code even harder to find.

Another problem is that now your business logic is obfuscated inside the ORM layer. If you look at the structure of the source code of a typical Rails application, all you see are these nice MVC buckets. They may reveal the domain models of the application, but you can’t see the Use Cases of the system, what it’s actually meant to do.

What is a Use Case?

Like most other definitions in our industry, the term “Use Case” is overloaded but I really like the definition from Usability.gov:

A use case is a written description of how users will perform tasks on your website. It outlines, from a user’s point of view, a system’s behavior as it responds to a request. Each use case is represented as a sequence of simple steps, beginning with a user’s goal and ending when that goal is fulfilled.

Written Use Cases are a great tool for explaining how the system should behave by providing a list of the goals. But once the application has been built, the documentation starts to drift away from the implementation and becomes less and less useful. Simon Brown gives a great talk on The Essence of Software Architecture where he talks about the importance of reflecting the architecture in our code. How can we reflect the Use Cases of our application in our code?

Implementing Use Cases at Envato

Envato Market has a large codebase with a lot of developers working on it, so we needed our implementation to be simple, consistent and conventional. A UML Use Case usually depicts an entire user flow, which may involve multiple actions. We decided to reduce the scope and define a Use Case as a plain Ruby class which defines one user action and all of the steps involved in completing that action. Each Use Case is named after the action the user takes, e.g. PurchasePhoto or ResetPassword.

We created a directory app/use_cases so that all of the Use Cases are easy to find (namespaced appropriately under their domains) and a module called UseCase which defines a simple public interface for our Use Cases to implement.

use_case.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
module UseCase
  extend ActiveSupport::Concern
  include ActiveModel::Validations

  module ClassMethods
    # The perform method of a UseCase should always return itself
    def perform(*args)
      new(*args).tap { |use_case| use_case.perform }
    end
  end

  # implement all the steps required to complete this use case
  def perform
    raise NotImplementedError
  end

  # inside of perform, add errors if the use case did not succeed
  def success?
    errors.none?
  end
end

As you can see, the module consists of a class method .perform that takes as many arguments as required and returns an instance of itself. It wraps the call to #perform in a tap block to ensure that perform remains a command method (any return value is ignored). It also has an instance method #success?, which bases success on whether the Use Case has accumulated any errors.

Here’s an example of a class that includes this module; a plain old ruby object named after the operation it carries out. All the collaborating objects are passed in at initialization and the perform method contains all the steps required to execute its goal.

purchase_photo.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
class PurchasePhoto
  include UseCase

  def initialize(buyer, photo)
    @buyer = buyer
    @photo = photo
  end

  def perform
    if create_purchase
      send_invoice
      increment_sales_count
      allow_buyer_to_review_photo
    end
  end

  private

  attr_reader :buyer, :photo, :purchase

  def create_purchase
    @purchase = Purchase.new(buyer: buyer, photo: photo)
    @purchase.save.tap do |success?|
      errors.add(:base, "Purchase failed to save") unless success?
    end
  end

  def send_invoice
    Buyer::InvoiceMailer.enqueue(purchase: purchase)
  end

  def increment_sales_count
    Photo::SalesCount.increment(photo: photo)
  end

  def allow_buyer_to_review_photo
    Photo::ReviewPermissions.add_buyer(buyer: buyer, photo: photo)
  end
end

The main method, #perform, contains the high level steps (which are defined as private methods) required for the action.

This means if another developer wants to know what happens when a purchase is completed, they can go straight to the perform method of the PurchasePhoto Use Case. If they need to know the detail behind any step, they can look at the relevant private method to see what it’s doing/calling.

The Use Cases are designed to be called from controllers (or from other use cases), creating a clear boundary between a controller, a model, and the business logic.

What lessons have we learned?

We’ve refactored our Use Cases a few times now and we’ve learnt a few valuable lessons along the way.

1. Don’t mix Queries and Commands

Command Query Separation is an important concept in software design, and one that we originally ignored by making the Use Case #perform method return a result object as well as producing side effects. This made our use cases harder to test and harder to refactor (see Sandi Metz’s great testing talk for advice on how to test objects that obey the CQS rule).

2. A great fit for Integration Tests

We started unit testing our Use Cases but quickly realised that they were actually a great layer to write integration tests in. This way, we could test the actual side effects the perform method produced, and be confident our business logic was correct. Generally speaking, we unit test the classes in the layers below our use cases, and have a smaller layer of full stack acceptance tests to ensure the system works from the front end down.

Here’s a really simple example of what I mean by integration test.

purchase_photo_spec.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
RSpec.describe PurchasePhoto do

  describe #perform” do
    subject(:use_case) { PurchasePhoto.perform(buyer, photo) }

    it creates a purchase record do
      expect { use_case }.to change { Purchase.count }.by 1
    end

    it sends an invoice do
      use_case
      expect(Mailer.deliveries.last).
        to have_attributes(subject: Purchase Invoice)
    end
  end

end

In our subject we run perform on our use case, so we can make expectations on its side effects. For example, we expect it to change the number of purchase records in the database and we expect an email to be created with the subject “Purchase Invoice”.

3. Don’t put all your logic in your Use Case!

This introduces multiple levels of abstraction and gives the use case too much responsibility and knowledge of the rest of your system. Instead, only call high level commands that delegate to lower level objects. This will increase the readability and changeability of your code.

4. Don’t make your Use Cases generic.

One of the key benefits of a Use Case is that it reflects a single use case of the system. If you try to make it generic enough to handle multiple use cases, then you lose the benefits of clarity and context.

For example, we actually have more than one way to complete a purchase, depending on how the user chose to pay for it. Initially, we tried to make one Use Case handle all the ways to complete a purchase, which only made it complex, confusing and error prone. We replaced this Use Case with multiple single purpose Use Case classes that are simple to read and understand. Don’t be tempted to reuse code for the sake of it.

What does the Use Case pattern provide?

  • A consistent interface:
    • All Use Cases are easily identifiable because they include the UseCase module, meaning they share a common interface (namely the .perform method).
  • Self documenting and readable code:
    • Use Cases provide explicit, living documentation of the important user driven actions of your system.
    • They are easy to read because they list out each step at a single level of abstraction.
  • Context and encapsulation:
    • Because a Use Case maps to a single user action and the code inside it encapsulates everything you need to carry out that action, the Use Case creates a contextual wrapper for that operation.
  • Extensibility:
    • It’s easy to extend the behaviour of a Use Case because of the Command-Query separation.
    • It’s easy to change the logic because each step is implemented by a specific object.
  • Decoupling your domain from your framework:
    • Use Cases allow you to keep your rails controllers and Active Record models very simple and decoupled from your business logic.
  • A codebase that reveals its features:
    • Developers can look in one place, app/use_cases, to see all of the user features of the application.

In Conclusion

Of course, the Use Case pattern is not a silver bullet (yes, I said it); don’t expect it to solve all of your design problems. There are also many similar patterns out there which provide equivalent benefits - always be pragmatic to use the best tools for the problem at hand.

However, we can confidently say that introducing use cases in our codebase has greatly helped us to reduce coupling, increase visibility of features and provide context to our complex business domain.

Resources

There are already a few gems out there that provide similar functionality:

  • Mutations focuses on input validation and sanitisation.
  • Use Case was inspired by the mutations gem but has lot of other features such as step pipelines and pre-conditions.
  • Interaction is a gem that my colleague Steve has just released, based on our Use Cases.
  • Interactor has a slightly different implementation to our Use Cases but virtually identical motivations.