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

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

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

  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.

Leave a Reply