CrackedRuby CrackedRuby

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