I’ve been working in Rails for over a decade and sometimes I feel disappointed. Not because the framework is not up to my expectations but because I am not up to the creators’ experience. Finally, I believe that we have reached some places where Rails is really on its limits and some work can be done by us to improve the way we bring solutions to our customers. We’ve been refining the concept of the use case for a long while now, so I wanted to share the current draft that we are working on. Shoutout to @ggalaburri from Migrante which has helped in the development of this piece.

The Use Case is not something that I came upon by my own, some early ideas could be found in Familink’s Contexts, and every once in a while a small improvement appeared.

So, what is the problem that UC solves?

Rails promotes DRY-ness and single-responsabilities, linked with their MVC but the thing is: models are in charge of persisting themselves correctly, controllers must move requests to where they can be processed, and views have no logic. So where should I put any business logic that includes a little more than creating or updating a model?

At that point you start either beefing up the model with some stuff that belongs to it, but also some stuff that modifies other models. Then realize that that shouldn’t be there but in the controller, but the controllers ends up filling up with LOTS of decision making and some redundant code everywhere.

Testing some of these cases is heavy because the way to test them is to make requests over to them and doing lots of reading and writing to the database.

Use cases aims to solve that problem, the problem of not having an specific place for the most important thing of your work: business logic.

The approach

The way we are trying to solve the issue is by using POROs, or the most simple approximation that we can (I’m pretty sure that it already depends on ActiveSupport, but nowadays who doesn’t?).

This Ruby classes will have the business logic inside of them, hopefully trying not to use any complex library, for example, if we are using a model called Car, have car as a parameter instead of car_id so we don’t have to use Car.find(car_id) so we don’t rely on ActiveRecord. Every interaction should be as simple as possible and hopefully not depending on any technology implementation.

Lets use an internal presentations app as an example. To build a new presentation we can write something like

1
2
3
presentation = create_presentation @organization, starts: @starts, ends: @ends
create_rooms presentation:
@organization.members.each { |member| presentation.add_member member }

So here of course I am already abstracting the complexity of creating presentations, rooms and assigning members to create_presentation, create_rooms and add_member but that is alright, the core of this code is the business logic. So we should wrap it in a method.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class CreatePresentation
  def initialize(organization:)
    @organization = organization
  end

  def run
    presentation = create_presentation @organization, starts: @starts, ends: @ends
    create_rooms presentation:
    @organization.members.each { |member| presentation.add_member member }
  end

  private

  def create_presentation; end
  
  def create_rooms; end

  def add_member; end
end

I wrote down the other methods to be later implemented, but they are not necessary for this example.

Now we have an structured UseCase that we can call using CreatePresentation.new(organization: org).run but how do you know it worked? Exceptions? How can you verify its valid before trying to run it? The first thing I decided was that the use cases should follow the Command Pattern, the name of the class already follows that rule, so instead of using run I will use execute().

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class CreatePresentation
  def initialize(organization:)
    @organization = organization
  end

  def execute
    @valid = validate
    return self unless @valid

    @result = run
    self
  end

  def valid? = @valid

  def validate
    # Some validation that you might need, returns a boolean
  end

  def run
    # Business logic
  end 
end

The Usecase can be now checked if its valid to run, be executed, and get the execution result more than once.

We can call it

CreatePresentation.new(organization: org).execute

or

1
2
3
4
5
6
uc = CreatePresentation.new(organization: org)

uc.valid?

uc.execute
uc.result

So we are reaching a point where we can expand this idea further.

The current implementation

The way we are implementing use cases is creating first a module for it, in my opinion is better to say UsesCases::CreatePresentation than to say CreatePresentationUseCase but if you are 100% on board with the idea, you can probably just use CreatePresentation but I am always wary of naming conflicts.

There are about 15 implementations with UseCases, each one made on a different time period, so there is no good version tracking of its evolution (that’s what I’m hoping to do soon). But here is one of the latest.

I’ll be placing parts of the code to explain them separately, we can assume that we will be implementing the CreatePresentation usecase, but now it will be defined as

module UseCases
  class CreatePresentation < Base
  end
end
1
2
3
4
5
6
7
8
9
10
def initialize(*args)
  @stage = :created
  return if args.empty?

  arguments = args.reduce({}) { |p, c| p.merge(c) }
  @entered_arguments = arguments
  method(__method__).parameters.each do |_t, name|
    instance_variable_set("@#{name}", arguments[name])
  end
end

The constructor is shared with every descendant of UseCase, it doesn’t matter how many attributes it has, it stores them as instance variables. So for example

CreatePresentation.new(organization: org) will have @organization automagically assigned to it. The only caveat, that maybe brings all this concept down is that we need to call it like so

module UseCases
  class CreatePresentation < Base
    def initialize(organization:)
      super
    end
  end
end

Probably I can write a separate article about this.

Later, to handle execution or validation errors we can add

1
2
3
4
5
6
7
8
def error(attribute = nil, type = nil, value = nil)
  @errors << { stage: @stage,
                context: self.class.name.gsub('::', '.'),
                attribute:,
                type:,
                value: }
  false
end

This the way I believe error reporting working fairly OK, but its up to you. I tried to copy ActiveRecord Validations here, so, if the organization is missing I could do something like error(:organization, :missing) if @organization.blank?. Here is where dry-validations could be a game changer.

To check if a use case has valid parameters maybe we should expand the validation wrapper

And finally, the execution method should look something like this

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
def execute
  ActiveRecord::Base.transaction do
    prepare!
    return self unless valid?

    before_run
    @result = run
    @stage = :executed
    after_run
  end
  self
rescue StandardError => e
  Rails.logger.error "Failed to execute #{self.class}"
  Rails.logger.error e.message
  e.backtrace.each { |line| Rails.logger.debug(line) }
  self
end
1
2
3
4
5
6
7
8
9
10
11
12
def valid?
  @errors = []
  prepare! unless @prepared
  validate
  @stage = :failed if @errors.present?
  @errors.empty?
rescue StandardError => e
  Rails.logger.error "UC>>StandardError detected #{e.message}"
  e.backtrace.each { |line| Rails.logger.debug(line) }
  @errors << e.message
  false
end

What the hell is prepare!? You already know it. Maybe between initialization and verification some processing must be done. Writing software is all about adding in-between layers. The important thing is that the way to check if something is ok is to check @errors.empty?

Adding a few more things, maybe stuff that should not be in there, the final result is the following:

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
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
module UseCases
  class Base
    class UseCaseError < StandardError; end

    attr_reader :errors, :result

    # build the instance variables for the given args
    def initialize(*args)
      @stage = :created
      return if args.empty?

      arguments = args.reduce({}) { |p, c| p.merge(c) }
      @entered_arguments = arguments
      method(__method__).parameters.each do |_t, name|
        instance_variable_set("@#{name}", arguments[name])
      end
    end

    # Adds an error to the list.
    # The error can be added in different stages, there is a change that a validation
    # error is not shown because it passed into the next stage and got cleared.
    # The error is based on ActiveModel.
    # @example error('name', 'presence') means that it name failed to be present.
    # @example error('user', 'authorization') means the use is not allowed to be here.
    # The idea is that this will help you form the I18n string for showing the errors.
    # @param attribute[String] what attribute failed
    # @param type[String] what failure made it fail
    def error(attribute = nil, type = nil, value = nil)
      @errors << { stage: @stage,
                   context: self.class.name.gsub('::', '.'),
                   attribute:,
                   type:,
                   value: }
      false
    end

    def dump_errors(error_list: [])
      error_list.each do |err|
        error(err.attribute, err.type)
      end
    end

    def execute
      ActiveRecord::Base.transaction do
        prepare!
        return self unless valid?

        before_run
        @result = run
        @stage = :executed
        after_run
      end
      self
    rescue StandardError => e
      Rails.logger.error "Failed to execute #{self.class}"
      Rails.logger.error e.message
      e.backtrace.each { |line| Rails.logger.debug(line) }
      self
    end

    alias perform execute

    def prepare!
      announce
      before_prepare
      prepare
      after_prepare
    end

    def announce
      return if ENV.fetch('ANNOUNCE_USE_CASES', 'NO') == 'ANNOUNCE'

      Rails.logger.info "Executing #{self.class.name}"
    end

    def before_prepare
      @stage = :validation
      @prepared = false
    end

    def after_prepare
      @prepared = true
    end

    def prepare; end

    def execute!
      prepare!
      raise ArgumentError unless valid?

      before_run
      @result = run
      @stage = :executed
      after_run
      self
    rescue StandardError => e
      Rails.logger.error "Failed to execute #{self.class}"
      Rails.logger.error e.message
      e.backtrace.each { |line| Rails.logger.debug(line) }
      raise UseCaseError, e.message
    end

    def after_run; end

    def before_run
      @stage = :execution
      @errors = []
    end

    def validate; end

    def valid?
      @errors = []
      prepare! unless @prepared
      validate
      @stage = :failed if @errors.present?
      @errors.empty?
    rescue StandardError => e
      Rails.logger.error "UC>>StandardError detected #{e.message}"
      e.backtrace.each { |line| Rails.logger.debug(line) }
      @errors << e.message
      false
    end

    def success?
      return false if @errors.nil?

      @errors.empty?
    end

    def error_list
      return [] unless @errors

      @errors.map do |x|
        "#{x[:context].underscore.tr('/', '.')}.#{x[:attribute].to_s.underscore}.#{x[:type].to_s.underscore}"
      end
    end

    def i18n_error_list
      @errors.map do |e|
        h = e.dup.with_indifferent_access
        h[:context] = h[:context].underscore.tr('/', '.')
        h
      end
    end

    def warning(attribute, type, message: nil)
      str = "Warning in #{self.class.name}, failed #{attribute} #{type}"
      str += "(#{message})" if message.present?
      Rails.logger.warn str
      # SlackService.notify(message: str, type: :warning)
    rescue StandardError
      Rails.logger.warn 'Failed to process warning.'
    end

    def t(key, **args) = I18n.t("usecases.#{self.class.to_s.underscore}#{key}", args)

    def perform_later!(_queue: :async_queue)
      raise NotImplementedError
    end

    def entered_arguments = @entered_arguments.dup

    def self.execute(args) = new(**args).execute

    def self.execute!(args) = new(**args).execute!

    def self.perform_later!(args) = new(**args).perform_later!

    protected

    def run
      raise NotImplementedError
    end
  end

  def execute(uc_name, **args)
    ucase = uc_name.to_s.camelize.constantize
    ucase.execute(args)
  end
end

This has some stuff for trying to work with ActiveJob and some other ways to invoke them, you can chose what you need.

The long road ahead

This version of UseCase works and has simplified our work pipeline tremendously. But there is also room for tweaking so this is what I have planned.

First, rewrite it to use callbacks, so maybe we can do something like

1
2
3
4
5
6
class CreatePresentation

  validate :team_has_members?
  validate :organization_valid?

end

Second, make a gem. This is the weird part, there is already a gem called use_cases and its quite new. I contacted the author so maybe some part of this knowledge can help improve that gem or maybe its an alternative to it, the best thing is that if someone else thought about it then its not that bad of an idea.

Third, modularize it, even if it doesn’t go as a gem, so if you don’t use ActiveRecord, don’t use transactions.

Fourth, build a generator for Rails, this is the other scenario, if you are using Rails, then make something that really helps you to build more and more use cases. Developers are lazy and I’ve seen how making even an ActiveJob in console makes the developer use it more often is why a use case generator must be built.