Your Rails app is not your business.
Let’s face it: most Rails codebases look like this:
class OrdersController < ApplicationController
def create
@order = Order.new(order_params)
if @order.save
PaymentService.new(@order).process! # Direct dependency on Stripe
InventoryService.new(@order).update! # Direct dependency on PostgreSQL
render json: @order
else
render :new
end
end
end
This works—until:
- You need to switch payment providers (hello, 3-month rewrite).
- You want to test business logic without hitting the database.
- Your new CTO mandates GraphQL (Rails views become tech debt).
Hexagonal Architecture (aka “Ports & Adapters”) fixes this. Here’s how to apply it without rewriting your monolith.
Why Rails Needs Hexagonal
The Core Problem
Rails encourages:
-
Framework coupling:
ActiveRecord
models handle validation, persistence, and business rules. - Infrastructure entanglement: Stripe/PostgreSQL calls littered across services.
- Untestable logic: Need a database just to test a pricing calculation?
The Hexagonal Fix
- Core: Pure Ruby objects (business logic).
- Ports: Interfaces (what your app does).
- Adapters: Plugins (how it does it).
Step 1: Extract the Core
Before (Coupled)
# app/models/order.rb
class Order < ApplicationRecord
validates :total, numericality: { greater_than: 0 }
def process_payment
Stripe::Charge.create(amount: total, card: card_token) # Direct infra call
end
end
After (Hexagonal)
# core/order.rb
class Order
attr_reader :total
def initialize(total:)
@total = total
end
def valid?
total > 0
end
end
# core/ports/payment_gateway.rb
module Ports
module PaymentGateway
def charge(amount:)
raise NotImplementedError
end
end
end
Key shift:
-
Order
knows nothing about Stripe, databases, or Rails. - Payment is just an interface (
Ports::PaymentGateway
).
Step 2: Build Adapters
Stripe Adapter
# adapters/stripe_payment_gateway.rb
module Adapters
class StripePaymentGateway
include Ports::PaymentGateway
def charge(amount:)
Stripe::Charge.create(amount: amount, currency: "usd")
end
end
end
Fake Adapter (For Tests)
# test/support/fake_payment_gateway.rb
module Adapters
class FakePaymentGateway
include Ports::PaymentGateway
def charge(amount:)
{ success: true }
end
end
end
Now you can test without hitting Stripe:
order = Order.new(total: 100)
gateway = Adapters::FakePaymentGateway.new
order.process_payment(gateway) # No API calls!
Step 3: Wire to Rails
Controller Becomes an Adapter
# app/controllers/orders_controller.rb
class OrdersController < ApplicationController
def create
order = Core::Order.new(total: params[:total])
if order.valid?
payment_gateway = Adapters::StripePaymentGateway.new
order.process_payment(payment_gateway)
render json: { success: true }
else
render :new
end
end
end
Key benefits:
- Switch payment providers by changing one line.
- Test
Order
logic without Rails or databases. - Rails becomes just one delivery mechanism (add GraphQL/CLI later).
When to Go Hexagonal
✅ Complex domains: Fintech, healthcare, e-commerce.
✅ Long-lived projects: Where tech stacks change every 5 years.
✅ Team scaling: Multiple squads working on same codebase.
When to avoid:
❌ Prototypes/MVPs: Overkill for “just ship it” phases.
❌ Simple CRUD: If you’re literally just saving forms.
Adoption Strategy
- Start small: Extract one domain (e.g., Payments).
- Isolate dependencies: Wrap external services in adapters.
-
Gradually decouple: Move logic from
ActiveRecord
toCore::
.
Pro tip: Use dry-rb for ports/adapters if you need more structure.
“But Rails is opinionated!”
Exactly. Opinions are great—until they’re yours instead of Rails’.
Hexagonal Architecture isn’t about fighting Rails. It’s about owning your business logic instead of renting it from a framework.
Have you tried ports/adapters? Share your battle scars below.