All you need is to decide
For a (too) long part of my life I was researching how to handle business logic in the code. Fat controllers, fat models, operations, service objects, you name it. Finally, thanks to Jeremie (plus Yves, Ivan and Oskar for their public work and writing that shaped my understanding) I finally found my answer that is both surprisingly simple and surprisingly unknown.
Common interface for handling business logic - a decide function
I love to operate on theories, I guess that’s some kind of legacy of my University where a lot of time is spent on writing proofs. That should explain why I got so passionate when I finally found a strong foundation how to deal with business logic in code.
But what is a business logic? Long time ago smart folks in our industry invented a perfect way to describe it with Given - When - Then format (fear not, I won’t use any vegetables here). Because that’s what business logic is in essence: expected outcome when something happens in specific context (by context we basically mean that part of history that is relevant for the case).
To express that logic in code, all you need is a decide function. A decide function always takes two arguments - the first one represents the intent (WHEN) of the action, the second one represents the context (GIVEN) in which it is executed. And it always returns a list of outcomes that were decided (THEN) (can be empty in case of no decisions, ie nothing happened).
The wiring around that concept can be adjusted to your environment in many ways. Eg. find a record in a db table as a context, and update it mapping decisions to columns. The power lies in simplicity, but explaining it takes at least an hour long presentation full of mathematical concepts that are hard to absorb in the era of short span attention. Instead, let me just show how it works on a simple example. We can dig into details another day and in small batches (or a meetup or conference maybe).
Power of simplicity
As a good TDD citizen, let’s start with tests.
def test_account_was_registered
given(
[]
).when(
RegisterAccount.new(email: "j.kowalski@example.com")
).then(
[AccountWasRegistered.new(email: "j.kowalski@example.com")]
)
end
def test_email_was_taken
given(
[AccountWasRegistered.new(email: "j.kowalski@example.com")]
).when(
RegisterAccount.new(email: "j.kowalski@example.com")
).then(
[EmailIsAlreadyTaken.new(email: "j.kowalski@example.com")]
)
end
GWT methods here are custom helpers highly dependent of the infrastructure of the application, but they should not require great imagination to visualize how they could work.
And the implementation.
def decide(command, state)
case [command, state]
in [RegisterAccount(email:), Account(email: ^email)]
[EmailIsAlreadyTaken.new(email: email)]
in [RegisterAccount(email:), _]
[AccountWasRegistered.new(email: email)]
end
end
In essence - this is all you need to express any business logic.
Power of the pattern matching and beautiful abstraction that values declarative way of expressing the logic. Zero clutter of side effects, reading, or writing data to the database - just pure logic. In some languages or with a little help of custom DSL, we can make it even cleaner to write and read, but that’s for another episode.
How do we get command and state?
Command is just a data structure where we wrap the user or system intent, the WHEN. It can be simply initialized, e.g. in the HTTP controller by translating the request into struct. A state could simply be a record in a database. E.g.:
class RegistrationsController < ApplicationController
def create
command = RegisterAccount.new(
email: params[:email]
)
state = Account.find_by(email: params[:email])
# ...
end
end
In the never-ending discussion “should Ruby be a strongly typed language?” I was always torn between. Encapsulating request and context as data structures allows pattern matching to take the best of both worlds. The power of types shines the best where the lack of them distress the most - in handling the input. Treating input as types removes most of the error-prone error handling, and makes everything explicit, removing the space for mistakes. It might be the minimal set of types that you would ever need.
We can enhance intents with even more types, but the main point is, that the only thing that needs focus now, is to get a list of all actions that the system needs to handle and their expected outcome per context. Benefit even more by not accepting invalid commands.
Wrong format of the request? Just would not pattern match. Action executed in unexpected context? Also would not pattern match, raising an error, so we can patch that exact case. Long list of different conditions that normally ends up with nested if-else blocks? Just declarative flat list with precise definitions.
Finally, we have to map how to store the outcome.
class RegistrationsController < ApplicationController
def create
command = RegisterAccount.new(
email: params[:email]
)
state = Account.find_by(email: params[:email])
events = decide(command, state)
case events
in [AccountWasRegistered.new(email:)]
Account.create!(email: email)
render json: {status: "success"}
in [EmailIsAlreadyTaken]
render json: {status: "error"}, status: 422
end
end
end
In a real app, most of the work from such controller would be more like a configuration entry, since loading and saving will end up around just one or max few repeatable patterns. One day we might end up with such clean wiring as:
module AccountRegistration
module Setup
Infra::Config.command_bus.register(
RegisterAccount,
Infra::CommandHandler.new(
decider: AccountDecider,
events: [AccountWasRegistered],
tags: %i[email]
)
)
end
end
What’s left for a controller is mapping of the request into command and events into response.
But my logic is more complex
The brutal truth is that probably it’s not. We’re just trained to create accidental complexity that makes it look complex.
Expressing it in the proper way makes it visibly simple. To reason, to fix, to change, to adapt, to learn, to explain, to test, to use by AI. It gives a minimal set of types that guardrail you by making very explicit what could be done in what context and what we can expect as an output.
Sure, sometimes it can break the brain to realize how to split the logic into fitting pieces, but from my experience it’s more about unlearning the bad habits.

Sure, there are some technical concepts that I concealed here for clarity, but most of them you need to do just once and reuse.
Sure, there are places where we need complex algorithms, or strong optimizations. Lucky you 1%, working on the 1% of the lucky app where they need to be applied.
Sure, this humble article does not uncover all the nuances, but none does. And go and read articles by mentioned heroes, you will find a lot of wisdom there, that is missing here.
Sure, it would be a good idea to add event sourcing here too. But I’ll let you figure that out on your own.