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.
Top comments (5)
honestly this is the only way i’ve managed to keep rails stuff from turning into glue code over time you think most teams avoid refactoring like this because it feels too risky or because nobody wants to pause and fix older patterns
Great point—I think it’s both. Teams often avoid refactoring because it feels risky (what if we break production?) and because there’s always pressure to ship new features. Pausing to fix old patterns rarely gets prioritized until the pain becomes unbearable.
The irony? Hexagonal patterns actually reduce risk long-term by isolating changes. But convincing stakeholders (and ourselves) to invest in cleanup is half the battle.
How have you sold technical refactoring to your team or leadership?
This hits so close to home, decoupling makes everything feel so much saner. Have you found any tricks for migrating legacy Rails models without going down a rabbit hole?
Absolutely, the key is incremental migration. Start by wrapping legacy models in adapter classes that implement your new ports. For example, turn
User.active
intoLegacyUserAdapter.active_users
. This lets you slowly shift logic to new core objects while keeping the old system running.Another trick: use events to gradually replace direct model calls. Publish
UserUpdated
from ActiveRecord hooks, then build subscribers that feed your new domain objects.What’s been your biggest hurdle when untangling legacy models?
Some comments may only be visible to logged-in visitors. Sign in to view all comments.