Skip to content

Latest commit

 

History

1 Commit

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Ports & Adapters in Go

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/cli

1. Ports & Adapters in five minutes

The 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
Loading

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.

The one rule

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.

Why bother?

  • 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/api and cmd/cli differ 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.

2. Layout

.
├── 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.


3. One request, end to end

POST /orders with a JSON body. Follow the arrows.

  1. Inbound adapter (httpapi.handler.placeOrder) decodes the JSON into a placeOrderRequest, a wire type with json tags that exists only in this package. It maps that to a port.PlaceOrder command, which is plain data, and calls the inbound port.
  2. Application service (app.OrderService.PlaceOrder) asks the IDGenerator and Clock ports for an ID and a timestamp, builds domain.LineItem value objects from the command (this is where validation errors surface), and calls the domain.NewOrder factory.
  3. Aggregate (domain.NewOrder) checks its invariants, sets the initial state and records an OrderPlaced event on itself.
  4. Application service hands the aggregate to the OrderRepository port, then pulls the recorded events off the aggregate and passes each to the EventPublisher port.
  5. Outbound adapters do the technology-specific work: jsonfile maps the aggregate's Snapshot onto its own record struct and rewrites the file; logevents writes a structured log line.
  6. Inbound adapter maps the returned aggregate onto an orderResponse and writes 201 Created. If anything returned an error, the single writeDomainError switch maps it: ErrInvalid family to 400, ErrNotFound to 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.


4. DDD inside the hexagon

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.

Deliberately absent

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/domain as 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.

A note on the anaemic-domain trap

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.


5. Testing strategy

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.


6. Run it

Requires Go 1.24 or later. No other dependencies.

HTTP API

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 repeat

Every published event shows up on stderr as a structured log line, because that is what the logevents adapter does.

CLI

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

7. Extending it

Add a persistence adapter (e.g. PostgreSQL)

  1. Create internal/adapter/outbound/postgres/ with a type implementing port.OrderRepository. Map domain.Snapshot to your rows and back; use domain.RehydrateOrder to load. Add var _ port.OrderRepository = (*Repo)(nil).
  2. Add a test file that calls repotest.Run with a factory that returns a fresh repository against a test database or container.
  3. Add a case to newRepository in cmd/api/main.go and cmd/cli/main.go.

Nothing in domain, port, app or the inbound adapters changes.

Add a use case (e.g. "add item to order")

  1. Domain first. Add func (o *Order) AddItem(li LineItem, now time.Time) error with its rules (not cancelled, maybe a max-items limit) and an ItemAdded event. Unit-test it.
  2. Port. Add AddItem(ctx, id, item PlaceOrderItem) (*Order, error) to port.OrderService.
  3. App. Implement it in app.OrderService: find, call AddItem, save, publish. Test with the existing fakes.
  4. Adapters. Add a route to httpapi, a command to cli. Each is a few lines of translation.

Add an inbound adapter (e.g. gRPC)

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.


8. Design decisions, and the arguments against them

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.


Further reading

  • 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.

License

MIT. See LICENSE.

About

Ports & Adapters (Hexagonal) architecture in Go with a DDD core. Stdlib only.

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages