Overview
Hexagonal Architecture, introduced by Alistair Cockburn in 2005, structures applications to separate business logic from infrastructure concerns. The pattern creates an explicit boundary between the application core and external systems like databases, APIs, user interfaces, and message queues. This separation makes applications technology-agnostic, testable without external dependencies, and adaptable to changing infrastructure requirements.
The architecture represents the application as a hexagon with the business logic at the center. External systems connect to the application through ports (interfaces) using adapters (implementations). The hexagon shape holds no mathematical significance—it simply illustrates that multiple adapters can connect to the same port from different sides.
The pattern addresses a fundamental problem in software design: business logic becomes entangled with framework code, database access, and external service calls. When business rules depend on infrastructure details, testing requires spinning up databases, mocking HTTP clients, or configuring complex environments. Changes to infrastructure force modifications throughout the codebase.
# Traditional layered approach - business logic coupled to infrastructure
class OrderProcessor
def process(order_params)
order = Order.create(order_params) # Directly coupled to ActiveRecord
PaymentGateway.charge(order.total) # Coupled to specific payment system
EmailService.send_confirmation(order.email) # Coupled to email service
end
end
Hexagonal Architecture inverts this dependency structure. Business logic defines what it needs through ports (interfaces), and adapters implement those interfaces to connect with real systems. The core domain never imports infrastructure code; infrastructure imports and adapts to the core.
# Hexagonal approach - business logic depends on abstractions
class OrderProcessor
def initialize(order_repository, payment_gateway, notification_service)
@order_repository = order_repository
@payment_gateway = payment_gateway
@notification_service = notification_service
end
def process(order)
saved_order = @order_repository.save(order)
@payment_gateway.charge(saved_order.total)
@notification_service.notify(saved_order.customer)
saved_order
end
end
The pattern divides the system into three regions: the application core, ports, and adapters. The application core contains business entities, value objects, domain services, and use cases. Ports define interfaces for communication between the core and external world. Adapters implement ports to connect with specific technologies.
Key Principles
Hexagonal Architecture rests on dependency inversion. High-level business logic does not depend on low-level infrastructure details. Instead, both depend on abstractions defined by the business logic. This principle manifests through ports and adapters.
Ports represent the application's boundary. A port defines the contract for communication without specifying implementation details. The application defines two types of ports: primary (driving) ports and secondary (driven) ports.
Primary ports expose the application's functionality to the outside world. These ports represent use cases or commands that external actors can invoke. A REST API, CLI command, or background job might call a primary port to execute business logic. Primary ports face inward—they define what the application offers.
# Primary port - defines application functionality
module Ports
module Primary
class ProcessOrder
def call(order_data)
raise NotImplementedError
end
end
end
end
Secondary ports define what the application needs from the outside world. When business logic requires data persistence, external API calls, or email delivery, it declares these needs through secondary ports. The application core defines the interface without knowing about PostgreSQL, Redis, or SendGrid. Secondary ports face outward—they define what the application requires.
# Secondary port - defines application needs
module Ports
module Secondary
class OrderRepository
def save(order)
raise NotImplementedError
end
def find_by_id(id)
raise NotImplementedError
end
end
end
end
Adapters implement ports to connect the application with concrete technologies. Each adapter translates between the application's language and the external system's language. Adapters come in two varieties matching port types.
Primary adapters (driving adapters) receive requests from external sources and translate them into calls to primary ports. A web controller adapter receives HTTP requests, extracts parameters, invokes the use case through the primary port, and formats the response. A CLI adapter parses command-line arguments and calls the primary port. Multiple primary adapters can connect to the same port, enabling the same business logic to serve different interfaces.
# Primary adapter - translates HTTP to use case call
class OrdersController < ApplicationController
def create
process_order = UseCases::ProcessOrder.new(
order_repository: Adapters::ActiveRecordOrderRepository.new,
payment_gateway: Adapters::StripePaymentGateway.new,
notification_service: Adapters::EmailNotificationService.new
)
result = process_order.call(order_params)
render json: result, status: :created
end
end
Secondary adapters (driven adapters) implement the interfaces defined by secondary ports. An ActiveRecord adapter implements the repository port using database access. A Stripe adapter implements the payment gateway port using Stripe's API. A fake adapter implements the port using in-memory data structures for testing. The application core interacts with these adapters through port interfaces, remaining unaware of implementation details.
# Secondary adapter - implements repository using ActiveRecord
module Adapters
class ActiveRecordOrderRepository
def save(order)
record = OrderRecord.create!(
customer_id: order.customer_id,
total: order.total,
status: order.status
)
Domain::Order.new(
id: record.id,
customer_id: record.customer_id,
total: record.total,
status: record.status
)
end
def find_by_id(id)
record = OrderRecord.find(id)
Domain::Order.new(
id: record.id,
customer_id: record.customer_id,
total: record.total,
status: record.status
)
end
end
end
The application core contains business entities, value objects, domain services, and application services (use cases). This code has no knowledge of databases, frameworks, or external services. Entities and value objects represent business concepts. Domain services implement business rules that span multiple entities. Application services orchestrate use cases by coordinating entities, domain services, and secondary ports.
Dependency flow moves unidirectionally: adapters depend on ports, ports belong to the core, and the core depends on nothing external. This structure enables testing the entire business logic without external dependencies by substituting test doubles for secondary adapters.
The pattern enforces the Dependency Rule: source code dependencies point inward. Outer layers (adapters) can reference inner layers (ports and core), but inner layers cannot reference outer layers. This rule maintains the business logic's independence from infrastructure concerns.
The architecture achieves symmetry by treating all external systems uniformly. Whether the external system is a user interface, database, or third-party API, the application core communicates through ports. This symmetry simplifies the mental model: every external interaction follows the same port-and-adapter pattern.
Design Considerations
Hexagonal Architecture trades implementation complexity for long-term flexibility and testability. The pattern introduces additional layers and abstractions compared to direct framework integration. This overhead becomes justified when applications face frequent technology changes, require extensive testing, or need to support multiple interfaces to the same business logic.
When to Apply
Applications with complex business logic benefit significantly from hexagonal structure. When business rules drive the application's value, isolating those rules from infrastructure prevents them from becoming buried in framework code. Financial systems, healthcare applications, and e-commerce platforms often contain intricate business logic that warrants this separation.
Projects expecting technology evolution gain protection against disruptive changes. When the database might migrate from PostgreSQL to MongoDB, or the payment processor might switch from Stripe to PayPal, adapters localize these changes. The business logic remains stable while adapters evolve independently.
Systems requiring multiple interfaces to identical functionality thrive with this pattern. An application might need REST API access, GraphQL queries, CLI commands, and background job processing—all executing the same business operations. Hexagonal Architecture allows each interface to use a different primary adapter while sharing the core logic.
Testing requirements influence the decision. Applications demanding comprehensive test coverage without external dependencies need the adapter abstraction. The pattern enables testing all business logic paths using fast, in-memory test doubles instead of slow integration tests.
When to Avoid
Simple CRUD applications derive minimal benefit from hexagonal structure. When the application primarily moves data between a database and user interface with little business logic, the additional layers add complexity without corresponding value. The overhead of ports and adapters outweighs benefits when business rules remain trivial.
Prototype and proof-of-concept projects face different constraints. Speed of development often matters more than long-term maintainability. Hexagonal Architecture's upfront design investment slows initial development, making it inappropriate for throwaway code or time-sensitive experiments.
Small teams without experience in hexagonal patterns encounter a learning curve. The pattern requires discipline to maintain boundaries and understanding of dependency inversion. Teams lacking this background might create poorly structured hexagonal applications that combine the worst of both worlds: additional complexity without the benefits of proper separation.
Trade-offs
The pattern increases code volume. Each external interaction requires a port interface and at least one adapter implementation. A single database table might need a domain entity, a repository port, and a repository adapter. Simple applications using direct Active Record access complete the same functionality with fewer lines.
Initial development takes longer. Designing ports, defining domain models, and structuring adapters requires upfront thinking. Direct framework integration allows faster feature delivery initially, though this speed diminishes as coupling increases complexity over time.
Testing becomes faster and more comprehensive. Business logic tests run without databases, HTTP servers, or external services. This speed enables test-driven development and rapid feedback cycles. The trade-off shifts test burden from slow integration tests to fast unit tests.
Technology changes become localized. Swapping databases requires writing a new adapter implementing the existing repository port. The business logic, other adapters, and tests remain unchanged. In traditional architectures, such changes ripple throughout the codebase.
Alternative Approaches
Clean Architecture, proposed by Robert Martin, shares principles with Hexagonal Architecture but uses different terminology. Clean Architecture organizes code into concentric circles with entities at the center, surrounded by use cases, interface adapters, and frameworks. The fundamental concept matches Hexagonal Architecture: dependencies point inward, and business logic remains independent of infrastructure.
Onion Architecture presents another variation emphasizing the same dependency inversion principle. Layers form an onion structure with the domain model at the core. Each outer layer depends on inner layers but never vice versa. The main difference from Hexagonal Architecture lies in terminology and visualization rather than structure.
Layered Architecture (N-tier) separates concerns into horizontal layers: presentation, business logic, and data access. Unlike Hexagonal Architecture, traditional layered designs allow the business layer to depend directly on the data layer. This coupling prevents testing business logic without a database. Modern layered architectures incorporating dependency inversion converge with hexagonal principles.
Transaction Script pattern organizes business logic as procedures that handle single requests. Each script executes a complete business transaction using direct database access and external service calls. This pattern works well for simple domains but struggles as complexity grows. Hexagonal Architecture provides better structure for complex business logic.
Ruby Implementation
Ruby's dynamic nature and duck typing align well with Hexagonal Architecture's emphasis on interfaces. While Ruby lacks explicit interface definitions like Java or C#, modules and duck typing provide the necessary abstraction mechanisms.
Defining Ports
Ruby modules represent ports effectively. A module declares methods without implementation, establishing the contract between core and adapters. Adapters implement these methods, and the core depends on objects responding to the protocol defined by the module.
# Port defined as module
module Ports
module OrderRepository
def save(order)
raise NotImplementedError, "#{self.class} must implement save"
end
def find_by_id(id)
raise NotImplementedError, "#{self.class} must implement find_by_id"
end
def find_by_customer(customer_id)
raise NotImplementedError, "#{self.class} must implement find_by_customer"
end
end
end
Alternatively, abstract base classes serve the same purpose with more explicit structure. Classes inheriting from the base class must implement abstract methods.
# Port defined as abstract class
module Ports
class PaymentGateway
def charge(amount, payment_method)
raise NotImplementedError
end
def refund(transaction_id, amount)
raise NotImplementedError
end
def verify_payment_method(payment_method)
raise NotImplementedError
end
end
end
Duck typing allows ports to remain implicit. The core domain expects objects responding to specific methods without formal interface declarations. This approach reduces boilerplate but sacrifices explicit documentation of contracts.
# Implicit port - no module or base class
class PlaceOrder
def initialize(order_repository:, payment_gateway:, inventory:)
@order_repository = order_repository
@payment_gateway = payment_gateway
@inventory = inventory
end
def call(customer_id:, items:, payment_method:)
# Core expects these objects to respond to specific methods
@inventory.reserve(items)
total = calculate_total(items)
transaction = @payment_gateway.charge(total, payment_method)
order = build_order(customer_id, items, total, transaction.id)
@order_repository.save(order)
end
end
Structuring the Application Core
The core domain contains entities, value objects, domain services, and use cases. Entities represent business concepts with identity. Value objects represent descriptive aspects without identity. Domain services implement business logic spanning multiple entities. Use cases orchestrate domain objects to accomplish specific goals.
# Domain entity
module Domain
class Order
attr_reader :id, :customer_id, :items, :status, :total
def initialize(id:, customer_id:, items:, status: :pending, total:)
@id = id
@customer_id = customer_id
@items = items
@status = status
@total = total
end
def confirm
raise "Cannot confirm #{status} order" unless status == :pending
Order.new(
id: id,
customer_id: customer_id,
items: items,
status: :confirmed,
total: total
)
end
def cancel
raise "Cannot cancel #{status} order" if status == :shipped
Order.new(
id: id,
customer_id: customer_id,
items: items,
status: :cancelled,
total: total
)
end
end
end
Value objects implement equality based on attributes rather than identity. Ruby's Struct provides a convenient base for simple value objects.
# Value object
module Domain
class Money
attr_reader :amount, :currency
def initialize(amount, currency = 'USD')
@amount = amount
@currency = currency
freeze
end
def +(other)
raise "Currency mismatch" unless currency == other.currency
Money.new(amount + other.amount, currency)
end
def ==(other)
amount == other.amount && currency == other.currency
end
end
OrderItem = Struct.new(:product_id, :quantity, :price) do
def total
Money.new(price.amount * quantity, price.currency)
end
end
end
Use cases encapsulate application logic, coordinating domain objects and secondary ports to accomplish business goals. Each use case typically handles one user intention or system operation.
# Use case
module UseCases
class PlaceOrder
def initialize(order_repository:, payment_gateway:, inventory_service:, notification_service:)
@order_repository = order_repository
@payment_gateway = payment_gateway
@inventory_service = inventory_service
@notification_service = notification_service
end
def call(customer_id:, items:, payment_method:)
# Reserve inventory
@inventory_service.reserve(items)
# Calculate total
total = items.reduce(Domain::Money.new(0)) { |sum, item| sum + item.total }
# Process payment
transaction = @payment_gateway.charge(total, payment_method)
# Create order
order = Domain::Order.new(
id: nil,
customer_id: customer_id,
items: items,
status: :confirmed,
total: total
)
# Save order
saved_order = @order_repository.save(order)
# Send notification
@notification_service.notify_order_placed(saved_order)
saved_order
rescue StandardError => e
@inventory_service.release(items) if items
raise
end
end
end
Implementing Adapters
Secondary adapters implement repository ports, external service ports, and infrastructure concerns. Ruby on Rails applications typically use ActiveRecord adapters for database access.
# ActiveRecord adapter
module Adapters
class ActiveRecordOrderRepository
def save(order)
if order.id
update_existing(order)
else
create_new(order)
end
end
def find_by_id(id)
record = OrderRecord.find(id)
to_domain(record)
end
def find_by_customer(customer_id)
records = OrderRecord.where(customer_id: customer_id)
records.map { |r| to_domain(r) }
end
private
def create_new(order)
record = OrderRecord.create!(
customer_id: order.customer_id,
status: order.status,
total: order.total.amount,
currency: order.total.currency
)
order.items.each do |item|
record.items.create!(
product_id: item.product_id,
quantity: item.quantity,
price: item.price.amount
)
end
to_domain(record)
end
def update_existing(order)
record = OrderRecord.find(order.id)
record.update!(status: order.status)
to_domain(record)
end
def to_domain(record)
items = record.items.map do |item_record|
Domain::OrderItem.new(
item_record.product_id,
item_record.quantity,
Domain::Money.new(item_record.price)
)
end
Domain::Order.new(
id: record.id,
customer_id: record.customer_id,
items: items,
status: record.status.to_sym,
total: Domain::Money.new(record.total, record.currency)
)
end
end
end
External API adapters translate between the application's domain model and third-party service interfaces.
# Stripe payment gateway adapter
module Adapters
class StripePaymentGateway
def charge(amount, payment_method)
response = Stripe::Charge.create(
amount: (amount.amount * 100).to_i, # Stripe uses cents
currency: amount.currency.downcase,
source: payment_method.token
)
Domain::Transaction.new(
id: response.id,
amount: amount,
status: response.status.to_sym
)
rescue Stripe::CardError => e
raise Domain::PaymentError, e.message
end
def refund(transaction_id, amount)
Stripe::Refund.create(
charge: transaction_id,
amount: (amount.amount * 100).to_i
)
end
def verify_payment_method(payment_method)
Stripe::Token.retrieve(payment_method.token)
true
rescue Stripe::InvalidRequestError
false
end
end
end
Primary adapters translate external requests into use case calls. Rails controllers serve as primary adapters for HTTP requests.
# Rails controller as primary adapter
class OrdersController < ApplicationController
def create
use_case = build_place_order_use_case
order = use_case.call(
customer_id: current_user.id,
items: build_items(params[:items]),
payment_method: build_payment_method(params[:payment])
)
render json: serialize_order(order), status: :created
rescue Domain::PaymentError => e
render json: { error: e.message }, status: :payment_required
rescue Domain::InventoryError => e
render json: { error: e.message }, status: :conflict
end
private
def build_place_order_use_case
UseCases::PlaceOrder.new(
order_repository: Adapters::ActiveRecordOrderRepository.new,
payment_gateway: Adapters::StripePaymentGateway.new,
inventory_service: Adapters::InventoryServiceAdapter.new,
notification_service: Adapters::EmailNotificationService.new
)
end
def build_items(items_params)
items_params.map do |item|
Domain::OrderItem.new(
item[:product_id],
item[:quantity],
Domain::Money.new(item[:price])
)
end
end
end
Dependency Injection
Ruby's flexibility enables various dependency injection approaches. Constructor injection passes dependencies when creating objects.
# Constructor injection
use_case = UseCases::PlaceOrder.new(
order_repository: Adapters::ActiveRecordOrderRepository.new,
payment_gateway: Adapters::StripePaymentGateway.new,
inventory_service: Adapters::InventoryServiceAdapter.new,
notification_service: Adapters::EmailNotificationService.new
)
Dependency injection containers automate object construction. The dry-container and dry-auto_inject gems provide container functionality.
# Using dry-container
require 'dry/container'
require 'dry/auto_inject'
class Container
extend Dry::Container::Mixin
register(:order_repository) { Adapters::ActiveRecordOrderRepository.new }
register(:payment_gateway) { Adapters::StripePaymentGateway.new }
register(:inventory_service) { Adapters::InventoryServiceAdapter.new }
register(:notification_service) { Adapters::EmailNotificationService.new }
register(:place_order) do
UseCases::PlaceOrder.new(
order_repository: resolve(:order_repository),
payment_gateway: resolve(:payment_gateway),
inventory_service: resolve(:inventory_service),
notification_service: resolve(:notification_service)
)
end
end
Import = Dry::AutoInject(Container)
# Using auto-injection
class OrdersController < ApplicationController
include Import[:place_order]
def create
order = place_order.call(...)
# ...
end
end
Common Patterns
Hexagonal Architecture applications follow recurring patterns for structuring ports, adapters, and domain logic. These patterns address common design challenges while maintaining architectural boundaries.
Repository Pattern
Repositories abstract data persistence, allowing the domain to work with collections of entities without knowing storage details. The repository port defines collection-like operations, and adapters implement these using databases, APIs, or in-memory storage.
# Repository port
module Ports
class CustomerRepository
def save(customer)
raise NotImplementedError
end
def find_by_id(id)
raise NotImplementedError
end
def find_by_email(email)
raise NotImplementedError
end
def all
raise NotImplementedError
end
def delete(customer)
raise NotImplementedError
end
end
end
# In-memory adapter for testing
module Adapters
class InMemoryCustomerRepository
def initialize
@customers = {}
@next_id = 1
end
def save(customer)
if customer.id
@customers[customer.id] = customer
customer
else
id = @next_id
@next_id += 1
saved = Domain::Customer.new(id: id, name: customer.name, email: customer.email)
@customers[id] = saved
saved
end
end
def find_by_id(id)
@customers[id]
end
def find_by_email(email)
@customers.values.find { |c| c.email == email }
end
def all
@customers.values
end
def delete(customer)
@customers.delete(customer.id)
end
end
end
Event Publishing Pattern
Domain events communicate state changes without coupling components. The domain publishes events through a port, and adapters handle event distribution and subscription.
# Event publisher port
module Ports
class EventPublisher
def publish(event)
raise NotImplementedError
end
end
end
# Domain events
module Domain
class OrderPlaced
attr_reader :order_id, :customer_id, :total, :occurred_at
def initialize(order_id:, customer_id:, total:, occurred_at: Time.now)
@order_id = order_id
@customer_id = customer_id
@total = total
@occurred_at = occurred_at
end
end
end
# Use case publishes events
module UseCases
class PlaceOrder
def initialize(order_repository:, payment_gateway:, event_publisher:)
@order_repository = order_repository
@payment_gateway = payment_gateway
@event_publisher = event_publisher
end
def call(customer_id:, items:, payment_method:)
# Process order...
saved_order = @order_repository.save(order)
# Publish event
@event_publisher.publish(
Domain::OrderPlaced.new(
order_id: saved_order.id,
customer_id: saved_order.customer_id,
total: saved_order.total
)
)
saved_order
end
end
end
# Event adapter for async processing
module Adapters
class SidekiqEventPublisher
def publish(event)
EventWorker.perform_async(event.class.name, serialize(event))
end
private
def serialize(event)
event.instance_variables.each_with_object({}) do |var, hash|
hash[var.to_s.delete('@')] = event.instance_variable_get(var)
end
end
end
end
Query Object Pattern
Complex queries separate from repositories maintain repository simplicity. Query objects encapsulate filtering, sorting, and aggregation logic.
# Query port
module Ports
class OrderQueries
def find_recent_by_customer(customer_id, limit: 10)
raise NotImplementedError
end
def find_pending_over_amount(amount)
raise NotImplementedError
end
def calculate_total_revenue(start_date:, end_date:)
raise NotImplementedError
end
end
end
# SQL query adapter
module Adapters
class SqlOrderQueries
def find_recent_by_customer(customer_id, limit: 10)
records = OrderRecord
.where(customer_id: customer_id)
.order(created_at: :desc)
.limit(limit)
records.map { |r| to_domain(r) }
end
def find_pending_over_amount(amount)
records = OrderRecord
.where(status: :pending)
.where('total > ?', amount.amount)
records.map { |r| to_domain(r) }
end
def calculate_total_revenue(start_date:, end_date:)
sum = OrderRecord
.where(status: :confirmed)
.where(created_at: start_date..end_date)
.sum(:total)
Domain::Money.new(sum)
end
end
end
Specification Pattern
Specifications encapsulate business rules for filtering and validation. The pattern enables combining rules without coupling to specific persistence mechanisms.
# Specification interface
module Domain
class Specification
def satisfied_by?(candidate)
raise NotImplementedError
end
def and(other)
AndSpecification.new(self, other)
end
def or(other)
OrSpecification.new(self, other)
end
def not
NotSpecification.new(self)
end
end
class HighValueOrderSpecification < Specification
def initialize(threshold)
@threshold = threshold
end
def satisfied_by?(order)
order.total.amount > @threshold.amount
end
end
class PendingOrderSpecification < Specification
def satisfied_by?(order)
order.status == :pending
end
end
# Combinators
class AndSpecification < Specification
def initialize(left, right)
@left = left
@right = right
end
def satisfied_by?(candidate)
@left.satisfied_by?(candidate) && @right.satisfied_by?(candidate)
end
end
end
# Usage in use case
module UseCases
class FindOrdersForReview
def initialize(order_repository:)
@order_repository = order_repository
end
def call(threshold:)
spec = Domain::HighValueOrderSpecification.new(threshold)
.and(Domain::PendingOrderSpecification.new)
@order_repository.all.select { |order| spec.satisfied_by?(order) }
end
end
end
Anti-Corruption Layer Pattern
Anti-corruption layers protect the domain from external system quirks. When integrating with legacy systems or third-party APIs with poor design, an adapter translates between the external model and clean domain model.
# Clean domain model
module Domain
class Product
attr_reader :id, :name, :price, :available
def initialize(id:, name:, price:, available:)
@id = id
@name = name
@price = price
@available = available
end
end
end
# Legacy API with poor design
class LegacyInventoryApi
def get_item(sku)
# Returns hash with inconsistent structure
{
'SKU_CODE' => 'ABC123',
'ItemName' => 'Widget',
'COST' => '29.99 USD',
'InStock' => 'Y',
'LastUpdate' => '2025-01-15T10:30:00Z'
}
end
end
# Anti-corruption layer adapter
module Adapters
class LegacyInventoryAdapter
def initialize(api)
@api = api
end
def find_product(id)
response = @api.get_item(id)
translate_to_domain(response)
end
private
def translate_to_domain(response)
Domain::Product.new(
id: response['SKU_CODE'],
name: response['ItemName'],
price: parse_price(response['COST']),
available: response['InStock'] == 'Y'
)
end
def parse_price(price_string)
amount = price_string.split(' ').first.to_f
Domain::Money.new(amount)
end
end
end
Testing Approaches
Hexagonal Architecture enables comprehensive testing without external dependencies. Test doubles replace adapters during testing, allowing complete business logic coverage through fast unit tests.
Testing Use Cases
Use cases depend on port interfaces rather than concrete adapters. Tests inject test double implementations that verify interactions and return predetermined values.
# Test doubles
class FakeOrderRepository
attr_reader :saved_orders
def initialize
@saved_orders = []
@orders = {}
@next_id = 1
end
def save(order)
id = order.id || @next_id
@next_id += 1 unless order.id
saved = Domain::Order.new(
id: id,
customer_id: order.customer_id,
items: order.items,
status: order.status,
total: order.total
)
@orders[id] = saved
@saved_orders << saved
saved
end
def find_by_id(id)
@orders[id]
end
end
class SpyPaymentGateway
attr_reader :charged_amounts
def initialize
@charged_amounts = []
end
def charge(amount, payment_method)
@charged_amounts << amount
Domain::Transaction.new(
id: 'txn_123',
amount: amount,
status: :succeeded
)
end
end
class StubInventoryService
def reserve(items)
# Does nothing in tests
end
def release(items)
# Does nothing in tests
end
end
class FakeNotificationService
attr_reader :sent_notifications
def initialize
@sent_notifications = []
end
def notify_order_placed(order)
@sent_notifications << { type: :order_placed, order_id: order.id }
end
end
# Use case test
RSpec.describe UseCases::PlaceOrder do
let(:order_repository) { FakeOrderRepository.new }
let(:payment_gateway) { SpyPaymentGateway.new }
let(:inventory_service) { StubInventoryService.new }
let(:notification_service) { FakeNotificationService.new }
let(:use_case) do
described_class.new(
order_repository: order_repository,
payment_gateway: payment_gateway,
inventory_service: inventory_service,
notification_service: notification_service
)
end
describe '#call' do
let(:items) do
[
Domain::OrderItem.new('prod_1', 2, Domain::Money.new(10)),
Domain::OrderItem.new('prod_2', 1, Domain::Money.new(15))
]
end
let(:payment_method) { double('PaymentMethod', token: 'pm_123') }
it 'creates confirmed order' do
order = use_case.call(
customer_id: 'cust_1',
items: items,
payment_method: payment_method
)
expect(order.status).to eq(:confirmed)
expect(order.customer_id).to eq('cust_1')
expect(order.items).to eq(items)
end
it 'saves order to repository' do
use_case.call(
customer_id: 'cust_1',
items: items,
payment_method: payment_method
)
expect(order_repository.saved_orders.length).to eq(1)
expect(order_repository.saved_orders.first.customer_id).to eq('cust_1')
end
it 'charges payment gateway correct amount' do
use_case.call(
customer_id: 'cust_1',
items: items,
payment_method: payment_method
)
expect(payment_gateway.charged_amounts.length).to eq(1)
expect(payment_gateway.charged_amounts.first.amount).to eq(35)
end
it 'sends notification' do
order = use_case.call(
customer_id: 'cust_1',
items: items,
payment_method: payment_method
)
expect(notification_service.sent_notifications.length).to eq(1)
expect(notification_service.sent_notifications.first[:order_id]).to eq(order.id)
end
end
end
Testing Domain Logic
Domain entities and value objects contain business rules. These objects have no external dependencies, making them trivial to test without any infrastructure.
RSpec.describe Domain::Order do
describe '#confirm' do
it 'transitions pending order to confirmed' do
order = Domain::Order.new(
id: 1,
customer_id: 'cust_1',
items: [],
status: :pending,
total: Domain::Money.new(100)
)
confirmed = order.confirm
expect(confirmed.status).to eq(:confirmed)
end
it 'raises error when confirming non-pending order' do
order = Domain::Order.new(
id: 1,
customer_id: 'cust_1',
items: [],
status: :confirmed,
total: Domain::Money.new(100)
)
expect { order.confirm }.to raise_error(/Cannot confirm/)
end
end
describe '#cancel' do
it 'transitions pending order to cancelled' do
order = Domain::Order.new(
id: 1,
customer_id: 'cust_1',
items: [],
status: :pending,
total: Domain::Money.new(100)
)
cancelled = order.cancel
expect(cancelled.status).to eq(:cancelled)
end
it 'raises error when cancelling shipped order' do
order = Domain::Order.new(
id: 1,
customer_id: 'cust_1',
items: [],
status: :shipped,
total: Domain::Money.new(100)
)
expect { order.cancel }.to raise_error(/Cannot cancel/)
end
end
end
Testing Adapters
Adapter tests verify the translation between external systems and domain models. These tests use real infrastructure (databases, APIs) or test-specific versions (in-memory databases, API mocks).
# Repository adapter test
RSpec.describe Adapters::ActiveRecordOrderRepository do
let(:repository) { described_class.new }
describe '#save' do
context 'with new order' do
it 'persists order to database' do
order = Domain::Order.new(
id: nil,
customer_id: 'cust_1',
items: [Domain::OrderItem.new('prod_1', 2, Domain::Money.new(10))],
status: :pending,
total: Domain::Money.new(20)
)
saved = repository.save(order)
expect(saved.id).not_to be_nil
expect(OrderRecord.find(saved.id).customer_id).to eq('cust_1')
end
end
context 'with existing order' do
it 'updates order status' do
existing = Domain::Order.new(
id: nil,
customer_id: 'cust_1',
items: [],
status: :pending,
total: Domain::Money.new(20)
)
saved = repository.save(existing)
confirmed = saved.confirm
repository.save(confirmed)
expect(OrderRecord.find(saved.id).status).to eq('confirmed')
end
end
end
describe '#find_by_id' do
it 'retrieves domain order from database' do
record = OrderRecord.create!(
customer_id: 'cust_1',
status: :confirmed,
total: 50,
currency: 'USD'
)
order = repository.find_by_id(record.id)
expect(order.id).to eq(record.id)
expect(order.customer_id).to eq('cust_1')
expect(order.status).to eq(:confirmed)
expect(order.total).to eq(Domain::Money.new(50))
end
end
end
Integration Testing
Integration tests verify the complete system with real adapters. These tests run slower but confirm that all components work together correctly.
# Integration test with real adapters
RSpec.describe 'Order placement flow', type: :integration do
it 'places order end-to-end' do
# Use real adapters but with test configurations
use_case = UseCases::PlaceOrder.new(
order_repository: Adapters::ActiveRecordOrderRepository.new,
payment_gateway: Adapters::TestPaymentGateway.new, # Test-mode Stripe
inventory_service: Adapters::InventoryServiceAdapter.new,
notification_service: Adapters::TestNotificationService.new
)
items = [
Domain::OrderItem.new('prod_1', 1, Domain::Money.new(29.99))
]
payment_method = create_test_payment_method
order = use_case.call(
customer_id: 'cust_test',
items: items,
payment_method: payment_method
)
# Verify order saved to database
persisted = OrderRecord.find(order.id)
expect(persisted.status).to eq('confirmed')
# Verify payment processed
expect(payment_gateway_charges).to include(hash_including(
amount: 2999,
currency: 'usd'
))
end
end
Contract Testing
Contract tests verify adapters conform to port interfaces without requiring real external systems. These tests ensure any adapter implementation satisfies the port contract.
# Shared examples for repository contract
RSpec.shared_examples 'order repository' do
describe '#save' do
it 'assigns id to new order' do
order = Domain::Order.new(
id: nil,
customer_id: 'cust_1',
items: [],
status: :pending,
total: Domain::Money.new(100)
)
saved = subject.save(order)
expect(saved.id).not_to be_nil
end
it 'updates existing order' do
order = Domain::Order.new(
id: nil,
customer_id: 'cust_1',
items: [],
status: :pending,
total: Domain::Money.new(100)
)
saved = subject.save(order)
confirmed = saved.confirm
updated = subject.save(confirmed)
expect(updated.status).to eq(:confirmed)
end
end
describe '#find_by_id' do
it 'returns saved order' do
order = Domain::Order.new(
id: nil,
customer_id: 'cust_1',
items: [],
status: :pending,
total: Domain::Money.new(100)
)
saved = subject.save(order)
found = subject.find_by_id(saved.id)
expect(found.customer_id).to eq('cust_1')
end
it 'returns nil for nonexistent id' do
expect(subject.find_by_id(99999)).to be_nil
end
end
end
# Use shared examples with different implementations
RSpec.describe Adapters::ActiveRecordOrderRepository do
subject { described_class.new }
it_behaves_like 'order repository'
end
RSpec.describe Adapters::InMemoryOrderRepository do
subject { described_class.new }
it_behaves_like 'order repository'
end
Real-World Applications
Hexagonal Architecture appears in production systems where business logic complexity justifies the architectural investment. E-commerce platforms, financial applications, and healthcare systems commonly adopt this pattern.
E-commerce Platform
An e-commerce system processes orders, manages inventory, handles payments, and coordinates shipping. The business logic involves pricing rules, discount calculations, inventory allocation, and order fulfillment workflows. Hexagonal Architecture isolates these rules from database access, payment gateways, and shipping carriers.
The platform defines primary ports for order placement, product search, and customer management. Secondary ports handle order persistence, payment processing, inventory tracking, and shipment creation. Multiple primary adapters support the same business logic: a React web application, iOS mobile app, and REST API for partners all execute order placement through the same use case with different primary adapters.
Payment processing uses an anti-corruption layer adapter. The system integrates with multiple payment providers (Stripe, PayPal, regional processors) through a unified payment gateway port. Each provider adapter translates provider-specific responses into domain transaction objects. Switching providers or adding new options requires implementing a new adapter without touching business logic.
# Production configuration with multiple adapters
class OrderApplicationService
def self.build
case ENV['DEPLOYMENT_ENV']
when 'production'
build_production
when 'staging'
build_staging
else
build_development
end
end
def self.build_production
UseCases::PlaceOrder.new(
order_repository: Adapters::PostgresOrderRepository.new,
payment_gateway: Adapters::StripePaymentGateway.new(api_key: ENV['STRIPE_KEY']),
inventory_service: Adapters::InventoryServiceAdapter.new(base_url: ENV['INVENTORY_URL']),
notification_service: Adapters::SnsNotificationService.new
)
end
def self.build_development
UseCases::PlaceOrder.new(
order_repository: Adapters::InMemoryOrderRepository.new,
payment_gateway: Adapters::FakePaymentGateway.new,
inventory_service: Adapters::StubInventoryService.new,
notification_service: Adapters::LogNotificationService.new
)
end
end
Financial Transaction System
A financial platform processes transactions, enforces regulatory rules, and generates audit trails. Business rules include transaction validation, fraud detection, regulatory compliance checks, and double-entry bookkeeping. These rules change frequently based on regulatory updates and business requirements.
The system separates transaction processing logic from database access, external fraud detection services, and regulatory reporting systems. Domain entities represent accounts, transactions, and ledger entries with immutable history. Use cases orchestrate transaction execution, fraud checking, and audit logging.
Event sourcing combines with hexagonal architecture for audit requirements. Transaction commands flow through primary ports. Use cases generate domain events representing state changes. Event store adapters persist events to append-only logs. Read model adapters project events into queryable views. This separation allows independent evolution of write operations and read queries.
# Financial domain with strict rules
module Domain
class Transaction
attr_reader :id, :source_account, :destination_account, :amount, :status
def initialize(id:, source_account:, destination_account:, amount:, status: :pending)
@id = id
@source_account = source_account
@destination_account = destination_account
@amount = amount
@status = status
validate!
end
def approve
raise "Cannot approve #{status} transaction" unless status == :pending
Transaction.new(
id: id,
source_account: source_account,
destination_account: destination_account,
amount: amount,
status: :approved
)
end
private
def validate!
raise "Amount must be positive" if amount.amount <= 0
raise "Source and destination must differ" if source_account == destination_account
end
end
end
# Use case with fraud checking and audit trail
module UseCases
class ProcessTransaction
def initialize(transaction_repository:, fraud_detector:, audit_log:, event_publisher:)
@transaction_repository = transaction_repository
@fraud_detector = fraud_detector
@audit_log = audit_log
@event_publisher = event_publisher
end
def call(source_account_id:, destination_account_id:, amount:)
transaction = Domain::Transaction.new(
id: nil,
source_account: source_account_id,
destination_account: destination_account_id,
amount: amount
)
# Check fraud
fraud_result = @fraud_detector.analyze(transaction)
raise Domain::FraudDetected if fraud_result.suspicious?
# Save transaction
saved = @transaction_repository.save(transaction)
# Audit log
@audit_log.record(
action: :transaction_created,
transaction_id: saved.id,
timestamp: Time.now
)
# Publish event
@event_publisher.publish(
Domain::TransactionProcessed.new(transaction_id: saved.id)
)
saved
end
end
end
Healthcare Management System
A healthcare platform manages patient records, appointment scheduling, prescription tracking, and insurance claims. HIPAA compliance requires strict access controls and audit trails. Integration with electronic health record systems, insurance providers, and pharmacy networks complicates the domain.
Hexagonal Architecture maintains clean separation between healthcare business logic and integration complexity. Patient management, appointment scheduling, and prescription workflows exist in the core domain. Adapters handle HL7 message parsing, FHIR API integration, and legacy system communication.
Security adapters enforce access control policies defined in the domain. Authorization rules remain in business logic while authentication mechanisms live in adapters. This separation enables switching from password-based to SSO authentication without touching authorization logic.
Reference
Core Components
| Component | Purpose | Location |
|---|---|---|
| Domain Entities | Business objects with identity | Core domain |
| Value Objects | Descriptive immutable objects | Core domain |
| Use Cases | Application service orchestration | Application layer |
| Domain Services | Multi-entity business logic | Core domain |
| Ports | Interface definitions | Between core and adapters |
| Adapters | Technology implementations | Infrastructure layer |
Port Types
| Port Type | Direction | Purpose | Examples |
|---|---|---|---|
| Primary (Driving) | Inward | Expose application functionality | HTTP API, CLI, GraphQL |
| Secondary (Driven) | Outward | Define infrastructure needs | Repository, Gateway, Notifier |
Adapter Categories
| Category | Implements | Common Examples |
|---|---|---|
| Persistence | Repository ports | ActiveRecord, Sequel, Redis |
| External Services | Gateway ports | HTTP clients, SOAP adapters |
| Messaging | Event publisher ports | Kafka, RabbitMQ, SNS |
| Presentation | Primary entry points | Controllers, CLI parsers |
| Testing | All ports | Fakes, stubs, mocks |
Dependency Rules
| Layer | Can Depend On | Cannot Depend On |
|---|---|---|
| Domain | Nothing external | Frameworks, databases, adapters |
| Use Cases | Domain, ports | Adapters, frameworks |
| Adapters | Domain, ports, frameworks | Other adapters |
Ruby Implementation Patterns
| Pattern | Ruby Approach | Example |
|---|---|---|
| Port Definition | Module or abstract class | module Ports::Repository |
| Adapter Implementation | Class including port module | class ActiveRecordAdapter |
| Dependency Injection | Constructor parameters | initialize(repository:) |
| Value Objects | Struct or immutable class | Money = Struct.new(:amount) |
| Domain Events | Plain Ruby classes | class OrderPlaced |
Testing Strategy
| Test Type | Scope | Dependencies | Speed |
|---|---|---|---|
| Domain Tests | Entities, value objects | None | Very fast |
| Use Case Tests | Application logic | Fake adapters | Fast |
| Adapter Tests | Infrastructure integration | Real or test infrastructure | Moderate |
| Integration Tests | Full system | All real adapters | Slow |
| Contract Tests | Port compliance | Port interface only | Fast |
Common Port Interfaces
| Port Name | Methods | Purpose |
|---|---|---|
| Repository | save, find, all, delete | Data persistence |
| Gateway | call, fetch, execute | External API communication |
| Publisher | publish | Event distribution |
| Notifier | notify, send | User notifications |
| Logger | log, error, info | Application logging |
Adapter Implementation Checklist
| Step | Action | Verification |
|---|---|---|
| 1 | Define port interface | Module or abstract class exists |
| 2 | Implement adapter class | Class includes/extends port |
| 3 | Translate domain to external | Serialization methods present |
| 4 | Translate external to domain | Deserialization methods present |
| 5 | Handle errors | External errors converted to domain errors |
| 6 | Write adapter tests | Port contract verified |
Architecture Validation Questions
| Question | Expected Answer |
|---|---|
| Does domain code import framework code? | No |
| Do use cases depend on concrete adapters? | No |
| Can business logic run without database? | Yes |
| Can adapters be swapped without core changes? | Yes |
| Are all external interactions through ports? | Yes |
| Can core be tested without infrastructure? | Yes |