Skip to content
← All posts
5 min read Dawid Skłodowski

Refactoring toward hexagonal Rails

Rails is a great delivery mechanism and a poor place to keep your business logic. A deep-dive on hexagonal architecture in Rails — ports and adapters, isolating the domain from the framework, and how far to take it before it becomes over-engineering.

There is a provocative idea that has been circulating for a while: Rails is not your application. Rails is a delivery mechanism — a superb one for turning HTTP requests into database rows and back — but the actual value of your software, the business rules that make it worth money, has a way of dissolving into controllers, models, callbacks, and concerns until it is impossible to find, test in isolation, or reason about without booting the whole framework. Hexagonal architecture is one disciplined answer to that. Here is what it means in a Rails context, and — just as importantly — how far to take it before the cure is worse than the disease.

The core idea: ports and adapters

Hexagonal architecture (also called “ports and adapters”) puts your business logic in the centre and pushes everything else — the web, the database, external services — to the edges. The centre, your domain, knows nothing about Rails, HTTP, or SQL. It communicates with the outside world through ports (interfaces it defines), and the outside world plugs into those ports through adapters (implementations).

The mental picture: a hexagon with your domain inside. On one side, adapters drive the domain — a Rails controller, a rake task, a Sidekiq job all call the same domain code. On the other side, the domain drives adapters — it needs to persist something, so it calls a repository port, and an ActiveRecord adapter implements it. The crucial property is the direction of dependencies: everything points inward. The domain depends on nothing; the framework depends on the domain. Rails becomes a detail at the edge, not the thing your logic is tangled into.

What this looks like in practice

The most useful first step is separating use cases (business operations) and repositories (persistence) from the framework. The domain defines plain Ruby objects and the interfaces it needs:

# domain — no Rails, no ActiveRecord, just business logic
class PlaceOrder
  def initialize(orders:, payments:)
    @orders = orders        # a repository port
    @payments = payments    # a gateway port
  end

  def call(cart:)
    order = Order.new(items: cart.items, total: cart.total)
    @payments.charge(order.total)
    @orders.save(order)
    order
  end
end

PlaceOrder does not know that @orders is backed by ActiveRecord or that @payments talks to Stripe. It depends on roles — something that can save an order, something that can charge money — not on concrete classes. The adapters live at the edge:

# adapter — the Rails/AR side, implementing the port
class ActiveRecordOrders
  def save(order)
    OrderRecord.create!(order.to_h)   # translate domain object <-> AR record
  end
end

# the controller is now a thin driving adapter
class OrdersController < ApplicationController
  def create
    order = PlaceOrder.new(orders: ActiveRecordOrders.new,
                           payments: StripeGateway.new).call(cart: current_cart)
    redirect_to order_path(order.id)
  end
end

The controller’s only job is to translate an HTTP request into a domain call and the result back into a response. The persistence detail (ActiveRecordOrders) is injected, so it can be swapped — for a fake in tests, for a different store later — without the domain noticing.

Why bother: the payoffs

This is more work than vanilla Rails, so it has to earn its place. The genuine wins:

  • The domain is testable in milliseconds. PlaceOrder can be unit-tested with in-memory fakes for its ports — no database, no HTTP, no Rails boot. The logic that matters most gets the fastest, most focused tests.
  • The business rules are findable. “How does placing an order work?” has one answer: read the PlaceOrder use case. The logic is not smeared across a controller, three model callbacks, and an observer.
  • The framework becomes replaceable at the edges. Swapping Stripe for another payment provider is a new adapter, not a hunt through the codebase. Even swapping Rails itself becomes conceivable, because the domain never depended on it.
  • External services are mockable at a clean seam. The port is the boundary; tests inject a fake adapter and never touch the network.

The honest cost, and how far to go

Now the crucial caveat, because hexagonal architecture is as easy to over-apply as service objects were. Full ports-and-adapters everywhere is a lot of indirection: domain objects separate from AR records, mapping code between them, port interfaces, adapter implementations, dependency injection wiring. For a CRUD app that is mostly forms over a database — which is a great many Rails apps, and exactly what Rails is brilliant at — this is pure overhead. You will write three layers to accomplish what Post.create did, and gain nothing, because there were no complex business rules to protect in the first place.

The pragmatic position we hold:

  • Default to plain Rails. For straightforward CRUD, let ActiveRecord be your domain. Rails’ “the model is the business object” works wonderfully until the business rules get genuinely complex. Do not pay for isolation you do not need.
  • Introduce boundaries where complexity concentrates. When one area of the app has intricate rules — pricing, eligibility, a multi-step workflow, heavy integration with external services — that is where a hexagonal seam pays off. Isolate the gnarly domain; leave the simple CRUD as plain Rails.
  • You do not need the whole hexagon to get the benefit. Even just extracting use cases (service objects with injected dependencies) and putting persistence behind a repository for the complex parts captures most of the value without a wholesale rewrite. Adopt the direction (dependencies point inward; isolate the domain) more than the full ceremony.

Verdict

“Rails is not your application” is a useful provocation, not an absolute law. Rails is a fantastic delivery mechanism, and for most of what most apps do, letting the framework be the application is exactly right. But when real business complexity arrives, the domain logic deserves to be protected from the framework — testable on its own, findable in one place, and depending on nothing but itself. Hexagonal architecture gives you the vocabulary for that: ports, adapters, dependencies pointing inward. Apply it surgically, where complexity actually lives, and it makes the hard parts of your app dramatically easier to reason about. Apply it everywhere and you have just rebuilt Rails, badly. The skill is knowing which parts of your app are the hexagon and which parts were always fine as plain Rails.