A small, complete, standard-library-only reference implementation of the Ports and Adapters architecture (a.k.a. Hexagonal Architecture) in Go, with a core modelled using the tactical patterns of Domain-Driven Design.
The example domain is deliberately boring, an order service that can place, fetch, list and cancel orders, so the architecture is the only thing to look at.
What you get:
| Piece | Where |
|---|---|
| A DDD-style core: aggregate, value objects, domain events, factory + rehydration | internal/domain |
| Explicit inbound and outbound ports | internal/port |
| Thin application services implementing the inbound port | internal/app |
| Two inbound adapters driving the same core: HTTP and CLI | internal/adapter/inbound |
| Two swappable outbound repository adapters: in-memory and JSON file | internal/adapter/outbound |
| A contract test suite every repository adapter must pass | internal/adapter/outbound/repotest |
| An architecture test that fails the build if a dependency points outwards | internal/arch_test.go |
| Two composition roots (binaries) that wire it all together | cmd/api, cmd/cli |
make test # everything, in well under a second
make build # bin/api and bin/cliThe idea, from Alistair Cockburn's original article: the application is a hexagon. Inside it lives your business logic, with no idea of the outside world. Around it are ports, interfaces that describe how the world may talk to the application (inbound) and how the application talks to the world (outbound). Adapters plug into the ports and translate between a specific technology (HTTP, a CLI, PostgreSQL, Kafka, a clock) and the port's language.
flowchart LR
subgraph in["Inbound adapters (driving)"]
HTTP["httpapi<br/>net/http"]
CLI["cli<br/>flag"]
end
subgraph core["The hexagon (internal/)"]
direction TB
IP["port.OrderService<br/><i>inbound port</i>"]
APP["app.OrderService<br/><i>application service</i>"]
DOM["domain.Order<br/><i>aggregate</i>"]
OP["port.OrderRepository<br/>port.EventPublisher<br/>port.Clock, port.IDGenerator<br/><i>outbound ports</i>"]
IP -. implemented by .-> APP
APP --> DOM
APP --> OP
end
subgraph out["Outbound adapters (driven)"]
MEM["memory"]
FILE["jsonfile"]
LOG["logevents<br/>log/slog"]
SYS["system<br/>time, crypto/rand"]
end
HTTP --> IP
CLI --> IP
OP -. implemented by .-> MEM
OP -. implemented by .-> FILE
OP -. implemented by .-> LOG
OP -. implemented by .-> SYS
Two kinds of ports:
- Inbound (driving, primary) ports say what the application offers.
The core implements them. Adapters call them.
Here:
port.OrderService. - Outbound (driven, secondary) ports say what the application needs.
The core calls them. Adapters implement them.
Here:
port.OrderRepository,port.EventPublisher,port.Clock,port.IDGenerator.
Source-code dependencies point inwards. Nothing inside the hexagon imports anything outside it.
domain -> (nothing in this module)
port -> domain
app -> domain, port
adapters -> domain, port never app, never another adapter
cmd -> everything the composition root
Go enforces the rule at compile time for the direction of interface
satisfaction (the core cannot accidentally call an adapter it does not
import). internal/arch_test.go enforces the rest by parsing the import
lists of every package; break the rule and go test ./... fails with the
offending import.
- Testability. The core is tested with tiny hand-written fakes
(
internal/app/order_service_test.go). No database, no network, no mocking framework. - Replaceability. Swap the JSON file for PostgreSQL by writing one
package and changing one line in
main. The HTTP handler and the domain do not change. The contract test tells you when the new adapter is done. - Multiple entry points for free.
cmd/apiandcmd/clidiffer only in which inbound adapter they plug in. - Delayed decisions. You can build and test all the business rules before choosing a database or a message broker.
.
├── cmd/
│ ├── api/main.go composition root: HTTP server
│ └── cli/main.go composition root: command-line tool
├── internal/
│ ├── domain/ THE MODEL. Aggregate, value objects, events, rules.
│ │ ├── order.go Order aggregate root, Snapshot, RehydrateOrder
│ │ ├── line_item.go LineItem value object + validation errors
│ │ ├── money.go Money value object
│ │ └── event.go OrderPlaced, OrderCancelled
│ ├── port/ INTERFACES. Nothing else.
│ │ ├── inbound.go OrderService + the PlaceOrder command
│ │ └── outbound.go OrderRepository, EventPublisher, Clock, IDGenerator
│ ├── app/ USE CASES. Implements inbound ports using outbound ports.
│ │ └── order_service.go
│ ├── adapter/
│ │ ├── inbound/
│ │ │ ├── httpapi/ JSON over HTTP -> port.OrderService
│ │ │ └── cli/ flags & stdout -> port.OrderService
│ │ └── outbound/
│ │ ├── memory/ port.OrderRepository backed by a map
│ │ ├── jsonfile/ port.OrderRepository backed by one JSON file
│ │ ├── repotest/ contract test shared by all repositories
│ │ ├── logevents/ port.EventPublisher that writes to slog
│ │ └── system/ port.Clock and port.IDGenerator using the real world
│ └── arch_test.go fails if any import points the wrong way
├── Makefile
└── go.mod no dependencies
Everything under internal/ is unimportable from other modules, so the
public surface of this repository is exactly the two binaries.
POST /orders with a JSON body. Follow the arrows.
- Inbound adapter (
httpapi.handler.placeOrder) decodes the JSON into aplaceOrderRequest, a wire type withjsontags that exists only in this package. It maps that to aport.PlaceOrdercommand, which is plain data, and calls the inbound port. - Application service (
app.OrderService.PlaceOrder) asks theIDGeneratorandClockports for an ID and a timestamp, buildsdomain.LineItemvalue objects from the command (this is where validation errors surface), and calls thedomain.NewOrderfactory. - Aggregate (
domain.NewOrder) checks its invariants, sets the initial state and records anOrderPlacedevent on itself. - Application service hands the aggregate to the
OrderRepositoryport, then pulls the recorded events off the aggregate and passes each to theEventPublisherport. - Outbound adapters do the technology-specific work:
jsonfilemaps the aggregate'sSnapshotonto its ownrecordstruct and rewrites the file;logeventswrites a structured log line. - Inbound adapter maps the returned aggregate onto an
orderResponseand writes201 Created. If anything returned an error, the singlewriteDomainErrorswitch maps it:ErrInvalidfamily to 400,ErrNotFoundto 404, state conflicts to 409, anything unknown to 500 without leaking the message.
Run the CLI instead and steps 2 to 5 are byte-for-byte the same code.
Ports & Adapters says where the boundary is. It says nothing about how to write the code inside. Domain-Driven Design's tactical patterns are one good answer, and the two fit together naturally: the hexagon protects the model, and the model gives the hexagon something worth protecting.
| DDD concept | In this repo | What it buys you |
|---|---|---|
| Ubiquitous language | Order, LineItem, place, cancel, OrderPlaced |
Code reads like the business talks. No OrderDTOManagerImpl. |
| Aggregate root | domain.Order |
One object owns the invariants for one consistency boundary. Fields are unexported; the only way in is through methods, so an Order can never be observed in an invalid state. |
| Value object | domain.LineItem, domain.Money |
Immutable, compared by value, no identity. NewLineItem is the only constructor, so a LineItem with a zero quantity cannot exist anywhere in the program. Money is an integer of cents with Add/Times, so no float rounding. |
| Entity identity | domain.OrderID |
Orders are equal if their IDs are equal, however much else changes. Generating IDs is an outbound concern (port.IDGenerator). |
| Invariants / business rules | NewOrder, Order.Cancel, CancellationWindow |
"An order needs a customer and at least one item", "cannot cancel twice", "cannot cancel after 24h" live in one place and are unit-tested in isolation. |
| Domain events | domain.OrderPlaced, domain.OrderCancelled, Order.PullEvents |
The aggregate raises events as part of the state change. The application service pulls them after saving and publishes them. The domain never knows what a broker is. |
| Factory | domain.NewOrder |
Creation logic and the first event live with the aggregate, not scattered across callers. |
| Reconstitution | Order.Snapshot, domain.RehydrateOrder |
Repositories need to read and write state without setters and without re-running creation logic or re-raising events. The snapshot is the narrow, explicit contract between aggregate and persistence. |
| Repository | port.OrderRepository (interface), memory, jsonfile (implementations) |
One repository per aggregate, speaking in whole aggregates: Save(order), never UpdateStatus(id, status). The interface is a port; the implementations are adapters. |
| Application service | app.OrderService |
One method per use case. Load, call one aggregate method, save, publish. No business rules here; if you find an if about the business in app, move it into domain. |
| Sentinel errors as domain vocabulary | domain.ErrInvalid, ErrNotFound, ErrAlreadyCancelled, ... |
The domain says what went wrong in its own words. Adapters decide what that means in HTTP or exit codes with errors.Is. |
Not every pattern earns its place in an example this size.
- Domain services hold business logic that does not belong to a single
aggregate (a transfer between two accounts, say). With one aggregate there
is nothing to put in one. They would live in
internal/domainas plain functions or small structs, and they would depend on nothing outside it. - Bounded contexts appear when a system has more than one model. This repository is one context. A second one would be a second hexagon, ideally a second module, talking to this one through events or an anti-corruption layer.
- Specifications, domain-level validation objects, event sourcing are all compatible with this layout and all overkill here.
The most common failure mode in "hexagonal Go" is a domain package of
plain structs with exported fields and a service package with all the
logic. That is a data model with a procedural layer on top; the hexagon
still helps with testing, but you get none of the DDD benefits. The
tell-tale sign: business if statements in app, and a repository that can
persist an invalid object. Keep the fields unexported and the rules on the
type.
Each ring is tested at the level that owns the behaviour. No test needs a database or a network.
| Test | What it exercises | Style |
|---|---|---|
internal/domain/*_test.go |
invariants, state transitions, events, snapshot round-trip | pure unit tests, no fakes needed |
internal/app/order_service_test.go |
use-case sequencing: validate, save, publish, error propagation | hand-written fakes of every outbound port |
internal/adapter/inbound/httpapi/handler_test.go |
routing, JSON, status-code mapping | httptest against a stub of the inbound port, so the real core is not involved |
internal/adapter/inbound/cli/cli_test.go |
flag parsing, output | drives the real core with in-memory adapters, doubling as a cheap end-to-end test |
internal/adapter/outbound/repotest |
the repository contract: not-found, round-trip, replace, ordering, isolation, no replayed events | one suite, run by both memory and jsonfile |
internal/adapter/outbound/jsonfile/repository_test.go |
adapter-specific behaviour (survives a restart) | on top of the contract |
internal/arch_test.go |
the dependency rule | parses imports with go/parser |
The contract test is the piece people most often skip and most often regret skipping. When you add a PostgreSQL adapter, its test file is three lines, and it proves the new adapter is indistinguishable from the old one from the core's point of view.
Requires Go 1.24 or later. No other dependencies.
make run-api # or: go run ./cmd/api -store=file -file=orders.json
curl -s -X POST localhost:8080/orders \
-d '{"customer":"alice","items":[{"sku":"sku-1","quantity":2,"unit_price":250}]}'
# {"id":"ord_...","customer":"alice","items":[...],"total":500,"status":"placed",...}
curl -s localhost:8080/orders # list, oldest first
curl -s localhost:8080/orders/ord_... # 200 or 404
curl -s -X POST localhost:8080/orders/ord_.../cancel # 200, then 409 on repeatEvery published event shows up on stderr as a structured log line, because
that is what the logevents adapter does.
Same core, different adapter. Defaults to the file store so state survives between invocations.
go build -o bin/ ./cmd/...
bin/cli place -customer alice -item sku-1:2:250 -item sku-2:1:1000
bin/cli list
bin/cli get ord_...
bin/cli cancel ord_...
bin/cli -v cancel ord_... # -v shows the published events
bin/cli -store=memory list # in-memory store: always empty, by design- Create
internal/adapter/outbound/postgres/with a type implementingport.OrderRepository. Mapdomain.Snapshotto your rows and back; usedomain.RehydrateOrderto load. Addvar _ port.OrderRepository = (*Repo)(nil). - Add a test file that calls
repotest.Runwith a factory that returns a fresh repository against a test database or container. - Add a case to
newRepositoryincmd/api/main.goandcmd/cli/main.go.
Nothing in domain, port, app or the inbound adapters changes.
- Domain first. Add
func (o *Order) AddItem(li LineItem, now time.Time) errorwith its rules (not cancelled, maybe a max-items limit) and anItemAddedevent. Unit-test it. - Port. Add
AddItem(ctx, id, item PlaceOrderItem) (*Order, error)toport.OrderService. - App. Implement it in
app.OrderService: find, callAddItem, save, publish. Test with the existing fakes. - Adapters. Add a route to
httpapi, a command tocli. Each is a few lines of translation.
Create internal/adapter/inbound/grpcapi/, depend only on port and
domain, translate proto messages to commands and back, and wire it in a
new cmd/. The existing adapters are untouched.
Why is port its own package instead of interfaces next to their consumer?
Idiomatic Go often defines an interface where it is used. That works well
for outbound ports (they are used in app). Inbound ports, however, are
implemented in app and used by several adapters, so they need a home
that both can import without cycles. Putting all ports in one place also
makes the hexagon's boundary visible in the tree. If you prefer, move the
outbound interfaces into app; the architecture is unchanged.
Why do commands carry primitives, not domain value objects? So adapters never construct domain objects. The adapter's job ends at "here is what the caller asked for"; deciding whether it is valid is the core's job, and the error the core returns is the single source of truth for all adapters.
Why Snapshot instead of exported fields or getters everywhere?
Exported fields let a repository, or anyone, create an invalid aggregate.
Getters alone do not let a repository load one without a giant
constructor that re-validates data that was already validated when written.
Snapshot/RehydrateOrder is one narrow, explicit door for persistence.
Why are events pulled from the aggregate rather than returned from methods?
Returning (events, error) from every method is noisy and leaks the
mechanism into every call site. Recording on the aggregate and pulling once,
after Save, keeps the application service to a fixed rhythm and makes it
impossible to publish events for a change that was never persisted.
Save and Publish are not atomic. Isn't that a bug?
It is a known trade-off, called out in app.OrderService.publish. Fixing it
properly means a transactional outbox, which is an adapter concern: the
repository adapter writes the events to an outbox table in the same
transaction and a relay publishes them. The ports as written allow that
without changing the core, which is the point.
Why no ORM, framework, or dependency-injection library?
Because none is needed to demonstrate the architecture, and every one of
them would blur the boundary this repository exists to make sharp. main is
the DI container, and it is a handful of constructor calls.
Isn't this a lot of structure for four endpoints?
Yes. That is the nature of a reference. In a real service the shape scales
well: more aggregates become more files in domain, more use cases become
more methods in app, more technologies become more adapter packages. What
does not scale is the alternative, where every new technology touches every
file.
- Alistair Cockburn, Hexagonal Architecture (2005), the original article.
- Eric Evans, Domain-Driven Design (2003), especially Part II on the tactical patterns.
- Vaughn Vernon, Implementing Domain-Driven Design (2013), for aggregates and domain events in practice.
- Robert C. Martin, Clean Architecture (2017), for the dependency rule stated generally.
- Ben Johnson, Standard Package Layout, for the "interfaces at the boundary" idiom in Go.
MIT. See LICENSE.