Ever stared at a Rails controller that's grown into a 200-line monster, handling everything from authentication to email sending to complex business logic? That was me last month, debugging a user signup flow that had become an unmaintainable nightmare.
You know that moment when you realize your "quick fix" has turned into spaghetti code that nobody wants to touch? This was mine.
In this issue, I'll take you through how Use Case Objects completely transformed my approach to organizing business logic in Ruby applications. No theory dumps – just real code, real problems, and real solutions.
🚀 What you'll learn:
How to isolate business logic from Rails controllers using Use Case Objects
The four essential rules for designing maintainable use cases
How to structure inputs and outputs with value objects
How Use Case Objects fit into Hexagonal Architecture
The Problem
Picture this: I'm working on a user onboarding system where the signup process involves user validation, email verification, welcome email sending, analytics tracking, and trial account setup. All of this logic was crammed into a single controller action.
Here's what we were dealing with:
class UsersController < ApplicationController
def create
@user = User.new(user_params)
if @user.valid?
if @user.save
UserMailer.welcome_email(@user).deliver_now
Analytics.track('user_signup', user_id: @user.id)
if params[:trial]
trial = Trial.create(user: @user, expires_at: 30.days.from_now)
TrialMailer.trial_started(@user, trial).deliver_now
end
redirect_to dashboard_path, notice: 'Welcome!'
else
render :new
end
else
render :new
end
end
private
def user_params
params.require(:user).permit(:username, :email, :password)
end
end
💡 Warning Signs:
Multiple responsibilities mixed together (validation, persistence, email, analytics)
Difficult to test individual business rules
Hard to reuse signup logic from other contexts (API, admin interface)
Complex conditional logic that's prone to bugs
The Journey: From Problem to Solution
Step 1: Extract Business Logic into Use Case Objects
Before:
class UsersController < ApplicationController
def create
@user = User.new(user_params)
if @user.valid?
if @user.save
UserMailer.welcome_email(@user).deliver_now
Analytics.track('user_signup', user_id: @user.id)
# ... more logic
end
end
end
end
After:
module Onboarding
class SignUp
# Input as value object
Credentials = Struct.new(:username, :email, :password, :trial_requested, keyword_init: true)
def self.of(attrs)
Credentials.new(
username: attrs.fetch(:username),
email: attrs.fetch(:email),
password: attrs.fetch(:password),
trial_requested: attrs.fetch(:trial_requested, false)
)
end
# Output as value object
SignedUp = Struct.new(:user, :trial, :success, keyword_init: true)
def self.call(params:)
credentials = of(params)
user = User.new(
username: credentials.username,
email: credentials.email,
password: credentials.password
)
return SignedUp.new(user: user, success: false) unless user.valid?
user.save!
# Handle business logic
UserMailer.welcome_email(user).deliver_now
Analytics.track('user_signup', user_id: user.id)
trial = nil
if credentials.trial_requested
trial = Trial.create(user: user, expires_at: 30.days.from_now)
TrialMailer.trial_started(user, trial).deliver_now
end
SignedUp.new(user: user, trial: trial, success: true)
end
end
end
class UsersController < ApplicationController
def create
result = Onboarding::SignUp.call(params: signup_params)
if result.success
redirect_to dashboard_path, notice: 'Welcome!'
else
@user = result.user
render :new
end
end
private
def signup_params
params.require(:user).permit(:username, :email, :password).merge(
trial_requested: params[:trial].present?
)
end
end
🎯 Impact:
Business logic is now isolated and testable
Controller reduced from 25 lines to 12 lines
Signup logic can be reused across different interfaces
Step 2: Apply the Four Essential Rules
Following the rules for Use Case Objects, I refined the implementation:
Naming: Used
SignUp
(verb) to represent the actionPublic Interface: Exposed only the
.call
methodInput/Output Structure: Created value objects for both inputs and outputs
Business Metrics: Focused on user experience metrics
The Aha Moment
The breakthrough came when I realized that Use Case Objects act as the application boundary in Hexagonal Architecture. They sit between external actors (controllers, background jobs, APIs) and the system's internal logic (models, services, repositories).
This means my business rules are no longer scattered across controllers, models, and service objects – they're centralized in use cases that clearly define what the application does.
Real Numbers From This Experience
Before: 45 lines of controller code handling signup
After: 12 lines in controller + 35 lines in well-structured use case
Test coverage increased from 60% to 95% for signup flow
The Final Result
module Onboarding
class SignUp
# Input as value object
Credentials = Struct.new(:username, :email, :password, :trial_requested, keyword_init: true)
def self.of(attrs)
Credentials.new(
username: attrs.fetch(:username),
email: attrs.fetch(:email),
password: attrs.fetch(:password),
trial_requested: attrs.fetch(:trial_requested, false)
)
end
# Output as value object
SignedUp = Struct.new(:user, :trial, :success, :errors, keyword_init: true)
def self.call(params:)
credentials = of(params)
user = User.new(
username: credentials.username,
email: credentials.email,
password: credentials.password
)
unless user.valid?
return SignedUp.new(user: user, success: false, errors: user.errors)
end
user.save!
# Execute business rules
UserMailer.welcome_email(user).deliver_now
Analytics.track('user_signup', user_id: user.id)
trial = create_trial_if_requested(user, credentials.trial_requested)
SignedUp.new(user: user, trial: trial, success: true, errors: [])
end
private
def self.create_trial_if_requested(user, trial_requested)
return nil unless trial_requested
trial = Trial.create(user: user, expires_at: 30.days.from_now)
TrialMailer.trial_started(user, trial).deliver_now
trial
end
end
end
🎉 Key Improvements:
Single responsibility: each use case handles one business process
Clear input/output contracts using value objects
Testable business logic separated from framework concerns
Reusable across different interfaces (web, API, background jobs)
Monday Morning Action Items
Quick Wins (5-Minute Changes)
Identify your fattest controller action
Look for business logic mixed with presentation logic
Find repeated business processes across controllers
Next Steps
Extract one business process into a Use Case Object
Create input and output value objects
Write comprehensive tests for your use case
Your Turn!
The Use Case Refactoring Challenge
Take this messy controller action and refactor it into a proper Use Case Object:
class OrdersController < ApplicationController
def create
@order = Order.new(order_params)
@order.user = current_user
if @order.save
@order.items.each do |item|
item.product.decrement!(:stock_quantity, item.quantity)
end
OrderMailer.confirmation(@order).deliver_now
PaymentProcessor.charge(@order.total, @order.payment_method)
redirect_to order_path(@order), notice: 'Order placed!'
else
render :new
end
end
end
💬 Discussion Prompts:
What value objects would you create for input and output?
How would you handle the stock decrement logic?
What would you name this Use Case Object?
What's Next?
Next week: "Repository Pattern in Ruby: Abstracting Your Data Layer" - We'll explore how to use the Gateway pattern to separate your business logic from ActiveRecord dependencies.
🔧 Useful Resources:
Found this useful? Share it with a fellow developer! And don't forget to try the refactoring challenge above and share your solution.
Happy coding!
Tips and Notes:
Pro Tip: Start with your most complex controller action when implementing Use Case Objects
Remember: Use Case Objects should have a single public method (
.call
) and clear input/output contracts