DEV Community

Alex Aslam
Alex Aslam

Posted on

Hexagonal Rails: Escape the Framework Trap

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
Enter fullscreen mode Exit fullscreen mode

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

  1. Core: Pure Ruby objects (business logic).
  2. Ports: Interfaces (what your app does).
  3. 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Now you can test without hitting Stripe:

order = Order.new(total: 100)
gateway = Adapters::FakePaymentGateway.new
order.process_payment(gateway) # No API calls!
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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

  1. Start small: Extract one domain (e.g., Payments).
  2. Isolate dependencies: Wrap external services in adapters.
  3. Gradually decouple: Move logic from ActiveRecord to Core::.

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)

Collapse
 
nathan_tarbert profile image
Nathan Tarbert

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

Collapse
 
alex_aslam profile image
Alex Aslam

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?

Collapse
 
dotallio profile image
Dotallio

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?

Collapse
 
alex_aslam profile image
Alex Aslam

Absolutely, the key is incremental migration. Start by wrapping legacy models in adapter classes that implement your new ports. For example, turn User.active into LegacyUserAdapter.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.