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
With this approach, you should consider writing software as if it should consistently execute/run. This means following a few rules of thumb:
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.
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.
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 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 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 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 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 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 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:
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.
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
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.
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.
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 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: