Week 17 - DDD in Go: Hexagonal Architecture, Bounded Contexts¶
17.1 Conceptual Core¶
- Domain-Driven Design in Go starts with one observation: Go's package system is a bounded-context tool. A package can hide types, expose only the interfaces consumers need, and the import graph enforces direction.
- The hexagonal pattern in Go:
- Domain package: pure types, behaviors, ports (interfaces) for external dependencies. No imports from
net/http,database/sql, etc. This is the dependency-direction rule. - Adapter packages: one per external system (postgres, kafka, http-client). Each implements the ports the domain defines.
- Application package: use cases-methods that orchestrate domain operations across adapters.
- Cmd package: composition root. Wires adapters into a runnable binary.
- The three Go-specific hazards:
- Anaemic domain: types are bags of fields with all logic in services. Push behavior into the type.
- Receiver-method abuse: mutating methods on value receivers (compile-pass, semantic-fail). Pick
Tvs*Tdeliberately. internal/not used: Go'sinternal/directory restricts imports to subtrees. Use it aggressively to enforce layering.
17.2 Mechanical Detail¶
- Layout for a hexagonal Go service:
Note
service/ cmd/ api/main.go # composition root internal/ domain/ # pure types + ports (interfaces) application/ # use cases adapter/ postgres/ # impl PostgresUserRepo kafka/ # impl EventBus http/ # impl HTTP handlers platform/ observability/ # slog, otel, prom wiring pkg/ # exported (rare; most things are internal/)internal/: nothing outsideservice/...can import it. This is the architectural test. - Defining ports as interfaces:
Define interfaces where they are consumed (in
package domain type UserRepo interface { ByID(ctx context.Context, id UserID) (User, error) Save(ctx context.Context, u User) error }domainorapplication), not where they are implemented. This is "consumer-defined interfaces," the Go counterpart to dependency inversion. - Errors as domain values: a domain error like
ErrUserNotFound = errors.New("user not found")plusvar ErrUserNotFoundforerrors.Ismatching. Adapter packages translatesql.ErrNoRowstodomain.ErrUserNotFoundat the seam. - Avoiding leakage: never let a
*sql.Txor a*http.Requestcross intodomain. The compiler will not stop you; the architectural test will.
17.3 Lab-"A Hexagonal URL Shortener"¶
Build a workspace implementing a URL shortener:
- internal/domain -ShortURLaggregate,URLRepoandHasherports.
-internal/application - Shorten and Resolve use cases.
- internal/adapter/postgres - implementsURLRepoagainst a real Postgres (usepgxnotdatabase/sql).
-internal/adapter/http - REST handlers using application.
- internal/adapter/memory - in-memoryURLRepofor tests.
-cmd/api - wires everything.
The architectural test (a Go test) walks the import graph and fails if internal/domain imports any adapter package or stdlib networking package.
17.4 Idiomatic & golangci-lint Drill¶
depguard(forbids cross-layer imports),revive: empty-block,gocritic: dupCase. Thedepguardrules become the executable architecture documentation.
17.5 Production Hardening Slice¶
- Add
depguardrules forbiddinginternal/domainfrom importingnet/http,database/sql,context.Background, and any third-party adapter packages. CI fails on a violation. This is the architectural test in lint form.