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.