From ddd
Alistair Cockburn's hexagonal (ports-and-adapters) architecture, read pragmatically: the database is treated as an integral part of the domain model, and ports are reserved for genuinely variable external systems. Use when reviewing where framework code has bled into the domain core, whether controllers and jobs are thin driving adapters, whether external systems (HTTP APIs, mailers, object storage, payment gateways, message brokers) hide behind driven ports, how Application Services and Domain Services divide responsibility, and where the composition root should live. Provides per-concept intent, concrete detection signals for misuse and absence, Ruby/Rails idiom examples, remediation sketches, and pattern interactions.
How this skill is triggered — by the user, by Claude, or both
Slash command
/ddd:hexagonal-architectureThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
A reference for Alistair Cockburn's hexagonal architecture — also called **ports and adapters** — read pragmatically. The hexagon isolates a **domain core** from the technologies that drive it (controllers, CLIs, jobs) and the technologies it drives (databases, HTTP APIs, mailers, object storage). Everything outside the core enters or exits through a **port**: an interface owned by the domain, ...
A reference for Alistair Cockburn's hexagonal architecture — also called ports and adapters — read pragmatically. The hexagon isolates a domain core from the technologies that drive it (controllers, CLIs, jobs) and the technologies it drives (databases, HTTP APIs, mailers, object storage). Everything outside the core enters or exits through a port: an interface owned by the domain, implemented by an adapter owned by the infrastructure layer.
The pragmatic reading — most visible in Rails, but applicable wherever an Active Record-style ORM is idiomatic — treats the database as an integral part of the domain model. Active Record classes carrying domain behavior live inside the hexagon; no repository abstraction is imposed over them merely to satisfy hex purity. Ports are still essential, but only for seams that genuinely vary: third-party HTTP APIs, payment gateways, mailers, file/object storage, message brokers, SMS providers, and similar replaceable integrations.
Hexagonal architecture is not a universal requirement. It has a real cost in up-front plumbing and a learning curve for teams raised on Rails-style controllers-plus-models. It pays off when the application has several external integrations that change independently, when test costs at real boundaries are high, or when the team needs to swap infrastructure (mailer vendor, storage backend, payment provider) without touching business rules.
This document defines the six concepts below, each with the same structure:
Ruby/Rails idioms appear inline as illustrative examples. The concepts themselves are language-agnostic; the Ruby notes sharpen detection in the ecosystem this reference is most often used against.
Pinned terms — used consistently throughout this reference and in the paired review skill:
tactical-patterns §5.Intent. The domain core holds the business rules and is framework-independent. It does not know that HTTP exists, that jobs are processed by Sidekiq, that email goes through SendGrid, or that logs go to Datadog. Its only allowed collaborators are other domain objects and driven ports (as interfaces). The core changes when the business changes, not when the infrastructure changes.
Signals.
require "sidekiq", require "net/http", require "aws-sdk-s3", HTTParty, Faraday, ActionMailer::Base, or Rails.logger inside a domain file. The core has reached outside the hexagon directly.params hashes, ActionController::Parameters, request, response, or return rendered strings / JSON-shaped hashes. HTTP has leaked in.Sidekiq::Worker / ApplicationJob / ActiveJob::Base, or calling perform_async / deliver_later inline. The core has absorbed the transport.ActionDispatch::Request, a params hash, or a session, and reads transport-shaped attributes from it (request.remote_ip, request.user_agent, params[:foo]). The signature binds every call site to having a live HTTP request on hand. Common shape: SecurityEvent.record_event(event_type, request, ...), MagicLink#consume!(request:). Distinct from the params misuse above because the object is passed through, not just named — it is a persistent coupling, not a one-line ergonomic shortcut.app/services/*_service.rb files each instantiate HTTP clients, mailers, and AR models directly. There is no separation between business rule and integration.Not a violation when. Active Record models carrying real domain behavior live in the core by design under the pragmatic stance — they are the domain objects. The inheritance from ApplicationRecord does not count as framework leakage for the purposes of this review. What is still forbidden: HTTP, job scheduling, external SDKs, mailers, and logging inside those AR classes.
Remediation sketch. Push framework-bound code out of the domain file. Replace a direct Net::HTTP.get(uri) with a call on an injected driven port (see §3). Replace an inline OrderMailer.confirmation(order).deliver_later with notifier.order_confirmed(order) where notifier is a driven port wired by the composition root. Move perform_async calls from inside domain methods up into the driving adapter that triggered them, or behind an explicit JobScheduler driven port if the act of scheduling is itself a domain decision.
Interactions. The domain core's integrity is what makes driven ports (§3) meaningful — the hexagon is defined by the boundary between core and ports. Cross-references from the core to driven ports are fine (the core owns them); cross-references from the core to adapter implementations are the canonical dependency-direction violation (see §5).
Intent. Driving adapters translate external triggers — HTTP requests, CLI commands, queued-job invocations, webhooks, scheduler ticks — into calls on driving ports. A driving port is a small interface representing one use case the application offers ("confirm an order", "publish a post", "run the nightly billing job"). The driving adapter is thin: parse input, call the driving port, format the response. The logic lives on the other side of the port.
Signals.
perform method is the business logic, not a thin dispatcher to an Application Service. When the same logic needs to run synchronously (from a controller), it gets duplicated or the worker is called inline awkwardly.params hash, an HTTP verb, or returns a rendered response. The port has absorbed the transport instead of abstracting it.rake billing:run_monthly whose body is 80 lines of orchestration. Same problem as the fat worker: the task is the adapter, not the use case.Model.where(...), Model.find(...), and AR mutators directly. Signals that the use case is being composed inside the driving adapter.Not a violation when. A controller action that is genuinely a thin read — render json: Post.find(params[:id]) — does not need a driving port. The pattern pays off when the use case is a meaningful verb (confirm, publish, settle, refund, cancel), not for thin CRUD passthroughs.
Remediation sketch. Extract the use case into an Application Service (see §4) behind a driving port. The controller action parses params, constructs the inputs, calls application_service.call(...), and renders the result. The same Application Service is callable from a Sidekiq worker, a Rake task, or a test, all without re-implementing orchestration. In Rails this often looks like PostsController#create calling Posts::Publish.new(...).call(input), where Posts::Publish is the driving port's single implementation.
Interactions. Driving adapters are the mirror of driven adapters (§3); both sit outside the hexagon and depend on ports. A thin driving adapter is only possible when Application Services (§4) exist to implement the driving port.
Intent. Driven ports are interfaces the domain core depends on to reach the outside world — everything it cannot do alone. Driven adapters implement these interfaces against specific technologies. The core receives a notifier, a payment_gateway, a file_store, a clock, an idempotency_store — named for what they are in the domain, not for the technology behind them. Swapping SendGrid for Postmark, S3 for GCS, or Stripe for Adyen is an adapter change; the core doesn't notice.
Signals.
SendgridClient, S3Client, StripeGateway exposed to the domain by those names. The adapter's identity has leaked into the port. Ports should be named for the domain role (Notifier, FileStore, PaymentGateway) with adapter classes like SendgridNotifier, S3FileStore, StripePaymentGateway implementing them.Faraday::Response, Stripe::Charge, Aws::S3::Object, or raw vendor exceptions. The adapter's data shapes flow into the core.VertexAiService.generate_content that checks GoogleCloud.configured? and delegates to VertexAiSimulator is the canonical shape. The implementation-selection decision is exactly what a composition root should make — the adapter should be the real implementation only; the simulator should be a sibling adapter chosen at wiring time. Also couples the production adapter to its own test double.Net::HTTP.get(URI(...)), HTTParty.post(...), Stripe::Charge.create(...), Aws::S3::Client.new.get_object(...), OrderMailer.confirmation(order).deliver_later, Twilio::REST::Client.new.messages.create(...) in a domain file. The core has skipped the port and reached straight for the technology.Time.now / Time.current in domain logic that needs to be testable. The clock is a driven concern; pervasive direct calls make time-dependent rules awkward to test and impossible to shift for scenarios like backfills.ENV or Rails credentials inside domain code. The core is negotiating with the outside world on its own.Not a violation when. The dependency is on the idiomatic ORM (see §6). Under the pragmatic stance, the Active Record query interface is part of the domain's working vocabulary; imposing a Repository over it to satisfy hex purity is ceremony without payoff. The rule re-engages the moment the dependency is on an external system the ORM cannot mediate.
Remediation sketch. Define the port as an interface in the core, named for the domain role: Notifier#order_confirmed(order), PaymentGateway#charge(amount:, customer:), FileStore#put(key:, body:). Implement it in an adapter class in app/adapters/ (or equivalent). Inject the adapter through the Application Service's constructor; wire it in the composition root. The port's method names are domain-speak; the adapter translates them into whatever the vendor SDK wants. Return domain values or raise domain exceptions — never vendor types.
Interactions. Driven ports are the hexagon's contract with the outside world; their integrity is what keeps the domain core (§1) stable under infrastructure change. Driven ports are distinct from tactical-patterns Repositories — under the pragmatic stance, the ORM plays the Repository role and does not sit behind a driven port. Driven port tests benefit from the supple-design properties described in supple-design — a port shaped around a side-effect-free query half and a commanding half is easier to fake.
Intent. An Application Service is a use case: one public entry point, parameters flowing in, a result flowing out, and orchestration of domain objects and driven ports in between. It implements a driving port. A Domain Service is a stateless domain operation named in the ubiquitous language, usually spanning aggregates or computing something that doesn't sit naturally on any single entity. They solve different problems and belong at different layers.
Signals.
call method that delegates to a single AR method (Order.find(id).confirm!) with no orchestration, no coordination across aggregates, and no driven-port interaction. The service is ceremony around a method call; either inline it or the method deserves a real body.SomethingCalculator or SomethingPolicy that takes an HTTP client, a mailer, or an AR repository in its constructor. That's an Application Service mislabeled, or a driven-port consumer in disguise. Domain Services depend only on domain objects.TaxCalculator, PricingRules, EligibilityPolicy that happen to call driven ports and mutate records. The name is domain-shaped; the role is orchestration. Rename to a use case (QuoteTax, ApplyPricing, CheckEligibility) or collapse to a Domain Service and move the orchestration elsewhere.CheckoutService#call that validates, prices, reserves inventory, charges, notifies, and writes audit. Split by meaningful use case; a use case is typically one transactional intent.ApplicationService base class with a single call convention, used for both roles indiscriminately. The base class signals that services are the primary carrier of behavior; it often hides the distinction between orchestration and domain computation.app/services/, is named *Service, but its body calls a framework transport directly (Turbo::StreamsChannel.broadcast_*, ActionCable.server.broadcast, raw SDK methods) and/or renders views. That is a driven adapter wearing Application-Service clothing. Callers import a class whose name suggests domain reasoning but whose behavior is pure transport. Rename to *Broadcaster / *Adapter and move to app/adapters/, or extract a proper port when variance or test cost warrants it. The *Service suffix should be reserved for Domain Services and Application Services.Not a violation when. A single-line Application Service is justified when it exists to expose a domain method through a driving port — the ceremony is paying for the port, not for the line of code. Similarly, a Domain Service with no infrastructure dependencies but many inputs is still legitimate (TransferFunds.call(from:, to:, amount:) that coordinates two aggregates is the textbook case from tactical-patterns §5).
Remediation sketch. For each use case, define an Application Service with one public method, constructor-injected driven ports, and a clear return type. Keep Domain Services stateless and infrastructure-free; they receive domain objects and return domain values or raise domain errors. When a would-be Domain Service needs a driven port, that's the signal: promote it to an Application Service, or extract the domain calculation into a pure helper and keep the orchestration at the Application Service layer.
Interactions. Application Services implement driving ports (§2) and consume driven ports (§3). Domain Services live entirely inside the domain core (§1) and are described more fully in tactical-patterns §5. The Application Service / Domain Service split is the hexagonal counterpart of the driving port / driven port split: each axis separates orchestration from computation.
Intent. The cardinal rule of hexagonal architecture: dependencies point inward. Adapters depend on ports. Ports are owned by the domain core. The core depends on nothing from the outside layer — it can compile, load, and run without knowing which adapters exist. The wiring happens at a composition root: one place, outside the core, where concrete adapters are instantiated and injected into Application Services.
Signals.
SendgridNotifier, StripePaymentGateway, S3FileStore directly. The arrow points outward. Tests can't substitute a fake without monkey-patching.notifier = SendgridNotifier.new inside a domain method. Equivalent to the previous signal; just more easily missed because there's no require.app/adapters/.ENV and assembling HTTP clients. The composition root is implicit, which means there are several of them, inconsistent.news its adapters in private accessors (def s3_service; @s3_service ||= Aws::S3Service.new; end) or calls them statically (VertexAiService.generate_content(...)). Distinct from "no composition root at all": the codebase may have a perfectly good Application Service layer, but every use case assembles its own wiring. Tests must stub concrete classes globally; swapping an adapter means editing many files. Constructor-inject the ports with defaults pointing at a single composition root.new'd at the point of use. Wiring is ad hoc. Swapping an adapter for testing or for a new vendor requires editing every call site.config/initializers/adapters.rb (or equivalent) where Rails.application.config.x.notifier = SendgridNotifier.new(...) happens; no dry-container / dry-auto_inject setup; no factory module returning configured Application Services.Not a violation when. A pure-domain computation that depends only on domain objects has no adapters to wire and no composition-root concern. The rule is about the boundary, not about every class.
Remediation sketch. Establish one composition root. In a Rails app, that is typically a config/initializers/adapters.rb that reads configuration and builds adapter instances, exposing them via Rails.application.config.x.<role> or a small Dependencies module. Application Services accept their driven ports via the constructor: Posts::Publish.new(notifier:, clock:). Tests swap in fakes directly. In larger apps, dry-container / dry-auto_inject or a hand-rolled factory module formalizes the wiring, but the principle is unchanged: one place assembles, everywhere else receives.
Interactions. The dependency direction is what gives §1–§4 their meaning; without it, "driven port" is just a renamed variable. The composition root is where the pragmatic stance (§6) is made explicit: it's the file that decides which external dependencies get ports and which are consumed directly.
Intent. Not every seam deserves a port. A port costs an interface, an adapter class, a constructor parameter, a line in the composition root, and a place in every test's setup. The pragmatic stance is explicit about when that cost is worth paying and when it isn't. In Rails, this means the ORM is part of the domain and does not get a port; external systems that vary do.
Signals.
OrderRepository#find(id) → order = Order.find(id) implemented by an AR-backed adapter. The indirection adds nothing: the ORM is already the abstraction, and swapping it out is not a real scenario. Typical sign that someone has imported the hex rulebook literally without weighing the cost.Logger driven port wrapping Rails.logger; a Cache port wrapping Rails.cache. These primitives are the abstraction; introducing a port over them is indirection for indirection's sake unless you genuinely plan to swap the framework.Stripe::Charge.create, Net::HTTP.post, Aws::S3::Client.new.put_object, Twilio::REST::Client.new.messages.create, Faraday.post inside the domain core. The seam is exactly what a driven port is for: external, variable, vendor-bound, and expensive to test.Stripe::CardError, Faraday::ConnectionFailed, Aws::S3::Errors::NoSuchKey. The vendor's failure taxonomy has become part of the domain vocabulary.Not a violation when. The ORM is used in Rails idiomatically and there is no realistic plan to swap it. A scope chain in a query method on an AR model is not a hex violation under the pragmatic stance. A logger call on Rails.logger from a domain method is acceptable if the team has weighed and accepted that dependency.
Remediation sketch. Apply three criteria to every candidate external dependency:
Two yeses → port. One yes → judgment call, often worth portfolio consistency (if you have ports for other vendors, add one). Zero yeses → no port; consume the thing directly and save the indirection for cases that need it. For the ORM in a Rails app, all three answers are typically no, which is why the pragmatic stance leaves it alone.
Interactions. The pragmatic stance is the corrective to naive hex: it explains why the dependency direction rule in §5 does not force every collaborator into a port. It relies on the composition root (§5) to make the decisions explicit: the root is where you see which collaborators crossed the threshold and which didn't. It intersects with tactical-patterns Repository (§6 there): a driven port for an external data source is a Repository in the DDD sense; the ORM-as-domain stance means the internal repository role is already played by the ORM.
Notifier and PaymentGateway, not SendgridClient and StripeGateway. The point is that the core doesn't know which one is behind the port.tactical-patterns §5 for the Domain Service pattern; the distinction is easy to muddle in a codebase that uses "service" as a catch-all suffix.tactical-patterns §3). When an Application Service modifies two aggregates, the hex structure isn't wrong — but domain events or an explicit cross-aggregate policy should be weighed.Creates, edits, and optimizes skills for Claude Code, including drafting, evaluating with test prompts, iterating on performance, and improving skill descriptions for better triggering accuracy.
npx claudepluginhub rpazevedo/ddd-plugin --plugin ddd