What is business logic?

Over the years, I built a personal understanding of what business logic is. Maybe this approach could help others in their software journey too.

Here is my take:

Business logic is anything preventing a default behaviour.

In this regard, a lot of things fall into this category:

The idea is that every method can be split into a validation query and resulting outcomes. Something like this:

def intended_action(*args)
  return other_outcome unless business_logic(*args)

  main_outcome
end

# OR the general form as a case statement

def intended_action(*args)
  case business_logic(*args)
  when :condition1 then outcome1
  when :condition2 then outcome2
  when :condition3 then outcome3
  else                  main_outcome
end

Business logic heuristics

With this approach, you should consider writing software as if it should consistently execute/run. This means following a few rules of thumb:

  1. method calls assume the default behaviour will execute
  2. branching is permitted only to exit early
  3. early exits are delegated as much as possible

Here is a quick example

# BAD
module Alarms
  def refresh
    @prior_alarm = find_prior_alarm
    clear_obsolete_alarms
    evaluate_breach
    create_alarm if raise_alarm? # <- no good
  end
end

# GOOD
module Alarms
  def refresh
    @prior_alarm = find_prior_alarm
    clear_obsolete_alarms
    evaluate_breach
    create_alarm # <- good
  end
end

private def create_alarm
  return unless raise_alarm

  # code to create alarm
end

Just like To Push or Not to Push the Crosswalk Button?, it's not about whether you know if you should push the button, but more about constantly pushing the button unless something prevents YOU (the caller) from doing so. Thinking about business logic this way can help design and improve readability.

Here is a business logic statement to illustrate the concept:

Customers with a business class ticket are entitled to rebook up to 20 minutes before departure with no fees.

Question: What default behaviour are we preventing with this business rule?

Therefore the business logic should be implemented where the code applies the rebooking fees.

module Bookings
  DEFAULT_REBOOKING_FEE = 25
  
  module_function

  def rebook(booking, booking_params)
    transaction do
      booking.cancel!
      new_booking = new(booking_params)
      new_booking.booking_fee = rebooking_fee(booking)
      new_booking.save!
    end
  end

  def rebooking_fee(booking)
    return 0 if booking.business_class? && time_before_departure(booking, duration: 20.minutes)

    DEFAULT_REBOOKING_FEE
  end
end

The default behaviour for this business rule was about the rebooking fee, not the customer. In this case, the business logic prevents the default fee from being applied when a customer rebooks a flight. #rebooking_fee method clearly shows that we should apply a DEFAULT_REBOOKING_FEE unless some logic (that we don't immediately care about) is true.

We're not preventing the application of fees but the application of a default fee. We've used heuristics #3 and pushed down the guard clause as much as possible.

In the beginning, there was no business logic

If your application should enable rebookings for business class customers only, write your code to allow rebookings regardless of the status of the customer. Only then can you apply a guard clause preventing the behaviour. Your application should allow rebookings so implement this outcome first.

This approach makes the code easy to change. What happens if we want to allow rebooking for:

The rebooking feature should be possible regardless and we prevent it's execution with a query matching business rules currently in place.

Routes, Authentication, Authorisation, Validation, Policies, Permissions, DB constraints, Types are all the same: business logic.

Validations, authorisation, policies whatever you want to call them are the same; they are just business logic. They have different names but all prevent an intended default behaviour and often their names describe where they live in the stack trace of a request.

Routes

Routes are a giant case statement, redirecting to an intended action based on URL. To some extent we can imagine routes as a logic that allows access to a page by preventing access to all other pages. Each route directs to a valid action available on our system. Routes are business logic.

Rails.application.routes.draw do
  root 'pages#homepage'

  get "up" => "rails/health#show", as: :rails_health_check

  # User session
  resources :registrations, only: [:new, :create]
  resource :sessions, only: [:new, :create, :destroy]

  # Resources
  resources :demos, only: [:create]
  resources :scenarios do
    scope module: :scenarios do
      resource :clone, only: [:new, :create]
      resource :timeline, only: [:show]
      resource :payment_details, only: [:show]
    end
  end
end

Authentication

Authentication prevents the access of a page from your application. Our application should make pages available first which we then prevent access when a user is not authenticated. Authentication is business logic.

class ApplicationController < ActionController::Base
  before_action :authenticate_user

  private

  def authenticate_user
    unless user = User.find_by(id: cookies.signed[:user_id])
      session[:return_to_after_authentication] = request.url
      redirect_to(new_sessions_url) and return
    end
    
    Current.user = user
    cookies.signed.permanent[:user_id] = { value: user.id, httponly: true, same_site: :lax }
  end
end

Authorisation

Authorisation prevents the execution of controller actions. The action @post.update is the intended default behaviour but is then prevented based on business rules. Authorisation is business logic.

class PostsController < ApplicationController
  def create
    authorize (@post = Post.find(params[:id]))

    if @post.update(post_params)
      redirect_to @post
    else
      render :edit
    end
  end
end

Validations

Validations prevent the creation of a record. Our application should allow the creation of a new Post, which we then prevent from being created if a title is not provided. Validations are business logic.

class Post < ActiveRecord::Base
  validate :title, presence: true
end

DB Indexes and constraints

DB constraints prevent storing a new row in the database. Our application should be able to store new rows which we then prevent with constraints. Often these constraints can change like a uniqueness not that unique after all. These are business dependent, and thefore business logic.

create_table "posts", force: :cascade do |t|
  t.string "title", null: false
  t.index "title", unique: true
end

Types?

Types didn't exist in Ruby until recently, but types match the heuristics of business logic: Code will not execute its default behaviour unless arguments match the expected types. They are a programming language feature yet I consider them as business logic.

With this in mind, we can then ask ourselves:

Where does business logic live?

There are a lot of opinions about where the business logici should live. Business logic should be stored in a service folder, the models folder or lib folder. Business logic should be made out of Plain Old Ruby Objects, made out of Services, or made out of global public call methods.

Business logic lives the furthest down possible in the application stack. If you can delegate a condition to another method call then do it, when you no longer can delay a condition then you have found where the business logic should live (for now).

Note: Business Logic in the database.

Pushing that logic to the extreme, you could consider writing your business logic as database stored procedures, but you would quickly hit skill and maintenance issues. This is why people mostly keep DB business logic to indexes only.

Business logic boundaries

There are two main ways to call a method:

# Option #1 - Wrapping a condition around the called method
def caller
  callee.foo if condition
end
# Option #2 - Using a guard clause in the called method

def caller
  callee.foo
end

class Callee
  def foo
    return unless condition

    do_foo
  end
end

Placing the condition inside the method as a guard clause offers better encapsulation, ensures consistent application of the validation, and simplifies the caller's code. Still it can reduce flexibility for the caller. On the other hand, having the condition outside the method in the caller context keeps the method more generic but risks missing essential checks. It can lead to code duplication, increasing the responsibility and complexity for the caller. The choice largely depends on the balance between method reusability and code maintainability

Business logic VS reusability

When we understand where business logic lives, we can understand a familiar problem developers face with callbacks or active records. A default behaviour that used to be performed in one location in the codebase can now be actioned from two locations where each context follows its business rules.

What used to be a condition preventing a default behaviour has now changed. A default validation or a callback in a model prevents the reusability of the message in a different context. In the same way, a unique index in the database can become problematic once the business realises that a name isn't unique.

This is where you need to move that condition up a level in the stack or provide contexts to the method calling the behaviour. Moving a condition up a level is straight forward. It means implementing option #2 instead of option #1 in the example above. Providing context-specific validations is a bit trickier.

Context specific business logic

Since we think that business logic is anything preventing a default action then it's possible to encapsulate all the business logic in the method call by providing some context specific validations.

Something like this:

module MyModule
  class CallerContext
  end

  def caller
    callee.foo(*args, context: CallerContext.new(*some_args))
  end
end

class Callee
  def foo(*args, context: DefaultContext)
    return unless internal_condition
    return unless context.validate(self)

    do_foo
  end
end

Or even allow composability with something like

module MyModule
  class CallerFooValidator; end

  def caller
    callee.foo(*args, extra_validations: [CallerFooValidator.new])
  end
end

class Callee
  class CalleeFooValidator; end

  cattr_accessor :foo_validations, default: [CalleeFooValidator.new], instance_writer: false

  def foo(*args, validations: foo_validations, extra_validations: [])
    default_validations.each { |v| v.validate(self) }
    extra_validations.each { |v| v.validate(self) }
    return if errors.present?

    do_foo
  end
end

This part is an interesting idea: you can now compose default validations too. If different application instances follow different business logic, you could look into initializing default and extra validations at load time. This can be powerful if your engines or gems should not be introduced in the core code, yet still be applied at runtime.

Arf, Let's move the condition up a level straight away

We could move the business logic up a level preventively and don't care about this stuff. It's just another if statement right? Like anything in our industry, it depends. What level of detail do you want to display in your method? Does another condition hinder readability? Whatever works for you as long as you keep in mind that:

Business logic is anything preventing a default behaviour.

We know too much & we're too procedural about it

We love talking about the latest functional features of Ruby, how Elixir is the best OOP language, how a type system blah blah blah. The reality is that we know too much, and we have to show it everywhere in the code. We're experts in the domain we're developing and, therefore, can't help ourselves adding little if statements everywhere because they match our understanding of the business requirements.

This is what I tried to illustrate in the short story: to press or not to press the crosswalk button and empathetic coding. Here are my advice to help you write better code: