Introduction
In Rails applications, it’s easy to fall into the trap of scattering business logic where it doesn’t belong, especially in controllers. At first, it might seem harmless: a quick if
statement here, a validation there. But as rules multiply (user permissions, seasonal promotions, inventory checks), controllers bloat into unreadable, untestable eldritch monsters.
Design patterns exist to prevent this chaos. They provide structured ways to organize logic, making codebases maintainable and predictable. However, before we explore solutions, let’s examine what happens without them.
Let’s Imagine a Rails Project: The E-Commerce Nightmare
Picture this: a simple e-commerce app with User
, Product
, and Cart
models. At first, the CartsController
seems straightforward:
class CartsController < ApplicationController
def create
product = Product.find(params[:product_id])
cart = current_user.carts.create!(product: product)
render json: cart
end
end
But then, business rules creep in:
Rule 1: Age-Restricted Products
Some products are for adults only (age_restricted: true
). If a child tries to add one, block it!
def create
product = Product.find(params[:product_id])
if product.age_restricted? && current_user.child?
return render json: { error: "Forbidden" }, status: 403
end
# ...
end
Rule 2: Christmas Override
During Christmas, all products are available to everyone.
def create
product = Product.find(params[:product_id])
unless christmas_season?
if product.age_restricted? && current_user.child?
return render json: { error: "Forbidden" }, status: 403
end
end
# ...
private
def christmas_season?
Date.today.month == 12 && Date.today.day.between?(15, 31)
end
end
Rule 3: Inventory Checks
Oh, and products can’t be added if they’re out of stock (duh).
def create
product = Product.find(params[:product_id])
if product.out_of_stock?
return render json: { error: "Out of stock" }, status: 400
end
unless christmas_season?
if product.age_restricted? && current_user.child?
return render json: { error: "Forbidden" }, status: 403
end
end
# ...
private
def christmas_season?
Date.today.month == 12 && Date.today.day.between?(15, 31)
end
end
As you can see, the controller becomes a dumping ground for rules, which means a convoluted project that is both painful to test and risky to change. So, touching one rule might impact the whole flow of the application, and mocking seems like a nightmare.
Imagine a scenario where there are 5 or 10 rules, nearly impossible to test, brittle to change, unreusable, this is a 300+ line controller with mixed concerns (auth, inventory check, business rule, rendering) waiting to happen, and as MVC lovers we know that controllers are supposed to be lean… not this hellscape:
class CartsController < ApplicationController
def create
# Rule 0: Basic validation
unless current_user
return render json: { error: "Not logged in" }, status: 401
end
product = Product.find_by(id: params[:product_id])
# Rule 1: Product exists
unless product
return render json: { error: "Product not found" }, status: 404
end
# Rule 2: Inventory check
if product.out_of_stock?
return render json: { error: "Out of stock" }, status: 400
end
# Rule 3: Age restriction (unless Christmas)
if product.age_restricted? && current_user.child? && !christmas_season?
return render json: { error: "Age-restricted product" }, status: 403
end
# Rule 4: Regional availability
unless product.available_in?(current_user.country)
return render json: { error: "Not available in your region" }, status: 403
end
# Rule 5: User hasn't exceeded cart limit
if current_user.carts.count >= 5
return render json: { error: "Cart limit reached" }, status: 400
end
# Rule 6: Product isn't recalled
if product.recalled?
return render json: { error: "Product recalled" }, status: 400
end
# Rule 7: Special membership requirement
if product.premium_only? && !current_user.premium_member?
return render json: { error: "Premium membership required" }, status: 403
end
# FINALLY create the cart
cart = current_user.carts.create(product: product)
if cart.persisted?
render json: cart
else
render json: { error: cart.errors.full_messages }, status: 422
end
end
private
def christmas_season?
Date.today.month == 12 && Date.today.day.between?(15, 31)
end
end
I don’t know about you, but for me, this reads like:
To solve this, we would need to extract logic from controllers, encapsulate rules in reusable objects/components, and maybe even make the code predictable.
What can do this? Well, spoilers: design patterns.
What is a Design Pattern?
First of all, let’s deconstruct the situation of our nightmare scenario to find a “default” resolution. We have a problem: we need to check different flows to find out if the product is adequate for a user. We have a context: new rules can be introduced, as we saw in the eldritch class with 7 rules. And we need a solution: for example, to encapsulate and isolate the satisfying condition for each rule in defined objects.
Well, this could turn out to be useful: we would move all the logic out of the controller, place it elsewhere, and use it as needed. A pattern, I would say.
But really, that would suffice as a resolution for our problem; the way we described and solved the situation is indeed a common way of thinking patterns.
Each pattern describes a problem which occurs over and over again
in our environment, and then describes the core of the solution to
that problem, in such a way that you can use this solution a million
times over, without ever doing it the same way twiceChristopher Alexander, A Pattern Language, 1977
This means that a design pattern is a solution, for a problem, in a given context
Alas, the Trifecta
We can further decompose the design pattern by detailing the trifecta:
The problem is a recurring issue or a roadblock in our system that, although it could be solved by brute force (e.g, logic in the controller), can have a more sophisticated solution.
A solution has a name and components; some patterns even have a plurality of components (like multiple objects). Anyway, they all have names, roles, and some can even have relations with other patterns (more on that later!).
And finally, the context will “decide” which solution is fit for each problem in our system.
Where do they come from?
As you can deduce, the patterns come from common and recurring problems; the solutions will become a pattern that will be reused by others. Eventually, they are formalized and named, so they all come from experience, they don’t come from nothing, in a way, they are just like frameworks!
Anti-Patterns
Although they come from recurring problems, contexts, and solutions, not all patterns are necessarily good; we also have their evil twin, the Anti-Patterns. They sometimes can seem like a good and intuitive solution, but they have negative consequences that are not always that clear, so they are considered a bad-practice. There is also the possibility that a pattern becomes obsolete and starts being considered an anti-pattern.
e.g: God Objects, React Mixins, Rails Fat Controllers
Team Patterns
In light of this knowledge, we should be mindful of how a team deals with its project. We have a lot of patterns, but sometimes maintaining conceptual integrity through cultivated patterns and solutions that don’t necessarily create a whole new design pattern is more important than forcing a pattern into your application. This means that less generic patterns that are tightly coupled to the project can be improved with time.
Community and Catalogued Patterns
While classic design patterns are language-neutral, frameworks like Ruby on Rails have adapted them into community conventions that better fit their ecosystems. For instance:
Service Objects encapsulate business logic outside models/controllers, acting like Command Pattern adaptations.
Form Objects (using ActiveModel::Model) resemble the Adapter Pattern, using validation outside ActiveRecord.
These “Rails-flavored” patterns aren’t formal “Gang of Four” patterns but have emerged from real-world needs, just like all patterns (the Gang of Four didn’t create them, they simply described), showing how design patterns evolve within specific technologies. They trade some academic purity for practicality.
Common Rails Patterns
Finally, let’s check some common Rails backend patterns that you can use! Given all that was said, you’ll see that they are mainly community design patterns for Rails!
For each of them, we will identify the trifecta
Serializer
- Context: Some data needs to be transformed to be stored or transferred
- Problem: The data will need different approaches to satisfy different specifications (like API versions), there are also multiple formats for this data, like XML and JSON
- Solution: Decouple the data from the formats and specifications by creating objects that can transform the original data to the needed format or specification on demand
Main Characteristics
Here we can call the object Serializer
, e.g, ProductSerializer
, this means that we can also specify further, e.g, ProductV1Serializer
or ProductJsonSerializer
We also would semantically benefit by having a public method with the symbolic transformation we want, following the pattern to_FORMAT
, e.g, to_json
, bear in mind a serializer can have multiple methods!
# Original code
class ProductsController < ApplicationController
def index
products = Product.all
render(
status: :ok,
json: products.map do |product|
{
id: product.id,
name: product.name,
price: product.price,
category: product.category.name,
created_at: product.created_at,
updated_at: product.updated_at
}
end
)
end
def create
products = Product.create(...)
render(
status: :created,
json: {
id: product.id,
name: product.name,
price: product.price,
category: product.category.name,
created_at: product.created_at,
updated_at: product.updated_at
}
)
end
end
# With Serializer
class ProductsController < ApplicationController
def index
products = Product.all
render(
status: :ok,
json: products.map { |product| ProductSerializer.new(product).as_json }
)
end
def create
products = Product.create(...)
render(
status: :created,
json: ProductSerializer.new(product).as_json
)
end
end
# app/serializers/product_serializer.rb
class ProductSerializer
def initialize(product)
@product = product
end
# A simple demonstration, but you could add logic (like conditional displaying), formatting (for dates, prices, and names), and much more here!
def as_json
{
id: @product.id,
name: @product.name,
price: @product.price,
category: @product.category.name,
indication: @product.indication,
created_at: @product.created_at.as_json,
updated_at: @product.updated_at.as_json
}
end
end
Result Objects
- Context: We need a way to standardize the return of operations to later act on them instead of different formats for each operation
- Problem: Without a unified way to return outcomes, we risk inconsistent responses, forcing callers to handle ambiguities like nil checks or ad-hoc error handling
- Solution: Introduce immutable result objects that explicitly represent success or failure, attach relevant data, and provide clear boolean checks (success? or failure?) for streamlined control flow
Main Characteristics
The pattern uses a base Result
class with subclasses like SuccessResult
and FailureResult
to distinguish outcomes. Results expose data
(payload) and message
(context) while ensuring immutability. This approach eliminates ambiguity, encourages consistent error handling, and simplifies testing.
# Base Result Classes
class ResultObjects
class Result
attr_reader :data, :message
def initialize(success:, data: nil, message: '')
@success = success
@data = data
@message = message
end
def success?
@success
end
def failure?
!success?
end
end
private_constant :Result
class SuccessResult < Result
def initialize(data: nil, message: '')
super(success: true, data:, message:)
end
end
class FailureResult < Result
def initialize(data: nil, message: '')
super(success: false, data:, message:)
end
end
end
### Usage
def valid_transaction?(amount:)
if amount.positive?
SuccessResult.new(data: { transaction_id: rand(1000) }, message: "Payment processed")
else
FailureResult.new(message: "Amount must be positive")
end
end
result = valid_transaction?(amount: 100)
result.success? # => true
result.data # => { transaction_id: 42 }
Service Objects
- Context: We need a way to accommodate business logic or actions in encapsulated and reusable objects, since some of this logic can be long and complicated
- Problem: Central behaviour or business logic that does not naturally fit anywhere; often this behaviour is closer to the model class, but it would not benefit us to put it in the model classes. We also need to decouple the behaviour from the caller to preserve SOLID.
- Solution: Create an object that can be invoked, which represents this action or behaviour as a whole.
Main Characteristics
The name of the object follows the pattern VerbSubstantive
, e.g, CreateProduct
. We can also follow the pattern as SubstantiveSubject
, e.g, ProductCreator
The object has only one public method named call
, for better semantics and close interface of Lambda
and Proc
. Since it is an operation, this call
method returns an object according to the Result Object described earlier. This can all be achieved by using an ApplicationService
base class.
# Base Service Class
class ApplicationService
def self.call(...) # call method also initializes the object
new(...).call
end
# same as earlier
class Result
attr_reader :data, :message
def initialize(success:, data: nil, message: '')
@success = success
@data = data
@message = message
end
def success?
@success
end
def failure?
!success?
end
end
private_constant :Result
class SuccessResult < Result
def initialize(data: nil, message: '')
super(success: true, data:, message:)
end
end
class FailureResult < Result
def initialize(data: nil, message: '')
super(success: false, data:, message:)
end
end
end
Let’s encapsulate some heavy logic on a service that adds a product to a cart:
class AddProduct < ApplicationService
def initialize(cart_id:, product_id:, quantity: 1)
@cart_id = cart_id
@product_id = product_id
@quantity = quantity
end
def call
# Validate if cart exists
cart = Cart.find_by(id: @cart_id)
return FailureResult.new(message: "Cart not found") unless cart
# Validate if product exists
product = Product.find_by(id: @product_id)
return FailureResult.new(message: "Product not found") unless product
# Check stock availability
if product.stock < @quantity
return FailureResult.new(
message: "Insufficient stock",
data: { available: product.stock, requested: @quantity }
)
end
# Add product to cart
cart_item = cart.add_product(product_id: @product_id, quantity: @quantity)
if cart_item.persisted?
SuccessResult.new(
data: { cart: cart, item: cart_item },
message: "Product added to cart successfully"
)
else
FailureResult.new(
message: "Could not add product to cart",
data: { errors: cart_item.errors.full_messages }
)
end
rescue => e
FailureResult.new(message: "Unexpected error: #{e.message}")
end
end
### On the controller:
class CartsController < ApplicationController
def add_item
result = AddProduct.call(
cart_id: params[:cart_id],
product_id: params[:product_id],
quantity: params[:quantity] || 1
)
if result.success?
render json: {
cart: result.data[:cart],
message: result.message
}, status: :ok
else
render json: {
error: result.message,
details: result.data
}, status: :unprocessable_entity
end
end
end
Query Objects
- Context: We have queries and filters for a given object in an index
- Problem: Complex queries and filters can quickly bloat a REST action in an API
- Solution: Decouple the query and filters by creating an object to represent this logic
Main Characteristics
The object will follow the pattern VerbSubstantive
, e.g, FindProducts
or VerbSubstantiveQuery
, e.g, FindProductsQuery
A single public method to be called on the object, e.g, call
, execute
, or fetch
. We should also bear in mind that the query can be later incremented with more parameters.
# app/queries/find_products.rb
class FindProducts < ApplicationQuery
def initialize(filters:)
@category = filters[:category]
@size = filters[:size]
end
def call
products = Product.all
if @category.present?
products = products.joins(:category).where(categories: { name: category })
end
if @size.present?
products = products.where(size: size)
end
products
end
end
# In the controller:
class ProductsController < ApplicationController
def index
products = FindProducts.call(filter: filter_params)
render(
status: :ok,
json: products.map { |product| ProductSerializer.new(product).as_json}
)
end
end
Conclusion
Design patterns offer structured solutions to common problems, helping keep your codebase clean and maintainable. By leveraging patterns like Service Objects, Query Objects, and Result Objects, you can avoid bloated controllers and tangled logic while improving testability and reusability.
For a deeper dive into design patterns, consider exploring resources like patterns.dev or Refactoring Guru, which provide comprehensive guides on both classic and modern patterns! I also highly recommend going through the Catalog of Patterns of Enterprise Application Architecture from Martin Fowler for a more in-depth analysis of patterns as a whole. Applying these principles wisely will lead to more robust and maintainable applications, saving you from future headaches and technical debt.
Happy coding! 🚀
We want to work with you. Check out our Services page!