Organizing business logic in Rails with contexts


Rails programmers have almost always tried to figure out the golden approach to business logic in their applications. From getting better at object-oriented design, to service objects, all the way to entirely new ideas like Trailblazer or leaving Active Record altogether. Here’s one more design approach that’s clean yet railsy.

What we have now

Before I dive into this new design pattern, let me comment a little 2 main approaches that I see people take today. As an example, let’s imagine an accounting task of making an invoice.

Models

The Rails default is about keeping everything related to business logic in app/models. Nice and practical for small projects, but hard to keep clean when the project and team grow:

  • Harder when more models and transactions come together
  • Easily leaking to controllers
  • Naming is hard

As for making the invoice, you probably start with Invoice.create, but when expanding on it you’ll have to bundle more in Invoice itself or create a new model such as Accounting so that Accounting.new_invoice can handle a more complex transaction across models. But should Accounting be a class in the first place? What will be return values when it’s not just a single object value or nil? Should you move from class method to regular method and initialize @accounting before calling it?

I think the default is a good choice but requires discipline. Unfortunately, I have only seen subpar implementations, and I am fighting with this design a little to be honest, although I do believe that the team at Basecamp keeps it clean.

Service objects

Some kind of a service object or operation is a usual community response to a mess that app/models alone create. There are many designs out there for these objects, but a command pattern with a result object is common. I kind of dislike where most people go with a single class method initializing an object and calling a single method. I certainly don’t like how Trailblazer removes the method name:

result = Memo::Create.(params: {text: "Enjoy an IPA"})

As I see it, service objects often come with:

  • Abuse of classes
  • Nouns vs. verbs naming decisions
  • Visually ugly (.call() or .() everywhere)

With our example in mind, we could extend the application with app/services or app/operations and have Invoice::Create#call similar to Trailblazer, CreateInvoiceOperation#call (to fix the verb) or even Accounting#create_invoice (to fix the method to something descriptive).

These methods can then do many things by working with several models individually or together. A result object would be returned so we could determine if the operation succeeded (result.success?) and access its values (result.invoice or result.errors).

So while I agree it’s a bit easier to follow this pattern many times, I am not sold. I accept that it’s practical and my objections cosmetic, but something feels wrong.

Phoenix contexts

I worked for over two years with the Phoenix framework, and I like how they “fixed” the business logic design in version 1.3 with contexts.

The idea of a context is to group particular logic like Accounts (for Invoices and teams) and Accounting (for invoicing and taxes). Although one context can contain many different modules (including “models”), they have one front-facing public module. Thus, you consistently access the context functionality via the public functions in the top-level module.

Phoenix even divides the application into lib and lib_web where contexts (your domain logic) live in lib and anything exposed on the web like controllers or GraphQL schemas in lib_web. Anything from lib_web has to call the appropriate contexts’ public functions.

In our example, this would be an Accounting module with the create_invoice function. Since we are functional in Elixir, create_invoice is just a function returning a tuple ({:ok, invoice or {:error, error}) and in the Phoenix design the invoice schema would be part of this context (as Invoicing.Invoice).

I feel like Phoenix contexts are the answer I was looking for to maintain the core business logic. And then it hit me.

Rails contexts

Why cannot we have contexts in Rails? For one, the Accounting module can easily be a Ruby module! Ruby has modules, so why don’t we use them this way?

We can write:

module Accounts
  def self.create_user
    # business logic magic
  end
end

module Accounting
  def self.create_invoice
    # business logic magic
  end
end

I mean… isn’t it clean? Accounting as a module hints that it’s indeed a domain rather than an object.

Is this Railsy? I think so. The following works with Zeitwerk by default:

# app/contexts/accounts.rb
module Accounts
  def self.active_users
    User.all.active
  end

  def self.account_details(id)
    account = Account.find(id)
    # ...
  end
end

# app/contexts/accounting.rb
module Accounting
  def self.create_invoice
    # business logic magic
  end
end

Since models are singular in Rails, plural names like Accounts won’t conflict with models.

So app/contexts could be our business logic calling to and working with app/models models. In controllers, we would refrain from calling models directly and rather use public functions of the contexts’ modules.

Since we don’t have tuples in Ruby, we keep the idea of a result object:

class Result
  attr_reader :object

  def initialize(success:, object:)
    @success = success
    @object = object
  end

  def success?
    @success
  end

  def failure?
    !@success
  end
end

class Success < Result
  def initialize(params)
    super(success: true, **params)
  end
end

class Failure < Result
  def initialize(params)
    super(success: false, **params)
  end
end

There are probably more sophisticated implementations of a result object out there, but I think you get the idea. And sometimes, going to the bare bones with the simplest implementation is best anyway.

Now we can see how this plays out in the method implementation:

module Accounting
  def self.create_invoice(params)
    invoice = Invoice.new(params)

    if invoice.save
      Success.new(object: invoice)
    else
      Failure.new(object: invoice)
    end
  end
end

…and in a controller:

class InvoicesController < ApplicationController
  before_action :set_invoice, only: %i[ show edit update destroy ]

  # POST /invoices or /invoices.json
  def create
    action = Accounting.create_invoice(invoice_params)

    respond_to do |format|
      if action.success?
        format.html { redirect_to action.object, notice: "Invoice was successfully created." }
        format.json { render :show, status: :created, location: action.object }
      else
        format.html { render :new, status: :unprocessable_entity }
        format.json { render json: action.object.errors, status: :unprocessable_entity }
      end
    end
  end
end

The entry point for anything is really a context’s top-level module, but not much had to change. #object could have an #invoice alias if we want to. Let’s also compare it to the original:

class InvoicesController < ApplicationController
  before_action :set_invoice, only: %i[ show edit update destroy ]

  # POST /invoices or /invoices.json
  def create
    @invoice = Invoice.new(invoice_params)

    respond_to do |format|
      if @invoice.save
        format.html { redirect_to @invoice, notice: "Invoice was successfully created." }
        format.json { render :show, status: :created, location: @invoice }
      else
        format.html { render :new, status: :unprocessable_entity }
        format.json { render json: @invoice.errors, status: :unprocessable_entity }
      end
    end
  end
end

Of course, you’ll need to handle various cases, and your result object and patterns would evolve, but hopefully, the idea is clear: Let’s skip using classes and objects for something that can be a module.

I am not saying it’s perfect. For one, returning Active Record models is still a leaky abstraction. But it also keeps up the Rails spirit and doesn’t fight with the framework at all. And going forward you could replace those records with read-only structs or something.

I think I like it and will model my next application business logic this way. I am curious to find out where it will lead me.

Let me know what you think!

← IT’S OUT NOW

I wrote a complete guide on web application deployment. Ruby with Puma, Python with Gunicorn, NGINX, PostgreSQL, Redis, networking, processes, systemd, backups, and all your usual suspects.

More →



Source link

Latest articles

Related articles

Leave a reply

Please enter your comment!
Please enter your name here