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!
Get Test Driving Rails and make your tests faster and easier to maintain.