Day 135 — Hexagonal Layering¶
Month 5 · Week 4 · ⬅ Day 134 · Day 136 ➡ · Journal index
🎯 Learning Objective¶
Re-shape the skeleton into ports & adapters: a domain core that defines the interfaces it needs, infrastructure adapters that implement them, and a thin gRPC edge — so the business logic is testable without any network or broker.
📚 Topics¶
- Hexagonal (ports & adapters) layering; dependency inversion
- Domain-owned interfaces (driven ports) vs exposed use cases (driving ports)
- Wiring adapters at the composition root; compile-time interface assertions
📖 Reading / Sources¶
- Effective Go — Interfaces
- Go Code Review Comments — Interfaces (define on the consumer side)
- Cosmic Python — Ports & Adapters (concept reference)
📝 Notes¶
- The rule that defines the style: dependencies point inward. The domain core never imports infrastructure; instead it declares interfaces (ports) and infrastructure conforms to them → [[dependency-inversion]] [[hexagonal]].
- Driven (outbound) ports are interfaces the core requires —
JobRepository,Queue,Clock. Driving (inbound) ports are the use cases the core exposes —Enqueuer.Submit(ctx, job). The gRPC handler is a driving adapter that translates protobuf into use-case calls → [[ports-adapters]]. - Define interfaces where they're consumed (the core), not where they're implemented. Keep them small — often one or two methods — so a fake is trivial and any adapter can satisfy them → [[accept-interfaces]].
- Context is the first parameter of every port method, so cancellation/deadline flows from the gRPC edge all the way down to the adapter → [[context]].
- Adapters translate errors at the boundary: a Redis
redis.Nilor SQLsql.ErrNoRowsbecomes a domainErrNotFound, so the core's error handling never leaks infrastructure types → [[error-translation]]. - Prove conformance for free with a compile-time assertion:
var _ Queue = (*RedisQueue)(nil). If the port changes, the build breaks at the adapter, not at runtime → [[interface-satisfaction]]. - Payoff: the core is unit-tested with in-memory fakes (microseconds, no Docker), and swapping memory→Redis touches only the adapter + one line in
main→ [[fakes]].
💻 Code Examples¶
// internal/core/job.go — the DOMAIN owns the ports and depends on nothing else.
package core
type Job struct {
ID string
Payload []byte
}
// Driven port: the core REQUIRES a queue but doesn't care if it's Redis or RAM.
type Queue interface {
Push(ctx context.Context, j Job) error
}
// Driving port + its implementation: the exposed use case.
type Enqueuer struct{ q Queue } // depends on the PORT, not a concrete type
func NewEnqueuer(q Queue) *Enqueuer { return &Enqueuer{q: q} }
func (e *Enqueuer) Submit(ctx context.Context, j Job) error {
if len(j.Payload) == 0 {
return errors.New("empty payload") // business rule lives in the core
}
return e.q.Push(ctx, j)
}
Runnable ports-&-adapters walkthrough (domain → in-memory adapter → driver) in the stdlib example:
examples/month-05/hexagonal· Run:go run ./examples/month-05/hexagonal· DI wiring inexamples/month-05/manualdi
🏋️ Exercises / Practice¶
| Exercise | Status | Link |
|---|---|---|
Ports & adapters walkthrough (compile-time var _ Port check) |
✅ | examples/month-05/hexagonal |
| Manual dependency injection at the composition root | ✅ | examples/month-05/manualdi |
🐛 Mistakes Made¶
- Defined the
Queueinterface in the Redis adapter package and imported it from the core → an import cycle waiting to happen and inverted the dependency. Moved the interface into the core. - Returned
redis.Nilstraight out of an adapter; the core then needed to import the redis package. Translated it tocore.ErrNotFoundat the boundary.
❓ Open Questions¶
- Where do cross-cutting concerns like idempotency keys live — in the core use case or a decorating adapter?
🧠 Active Recall (answer without looking)¶
- Q: In hexagonal architecture, which package should declare the
Queueinterface — the core or the Redis adapter?
A
The core. The domain owns the interfaces it needs (driven ports); the adapter package imports the core and implements them. This keeps dependencies pointing inward and avoids an import cycle.- Q: What does
var _ core.Queue = (*RedisQueue)(nil)buy you?
A
A compile-time assertion that `*RedisQueue` satisfies the `core.Queue` interface. If the port's method set changes, the build fails at the adapter instead of failing later at runtime when you wire it in.🪶 Feynman Reflection¶
Think of the core as a machine with labeled sockets (ports). It says "I need something I can Push a job into" without knowing whether a Redis box or a RAM map gets plugged in. Adapters are the plugs that fit those sockets. Because the core only knows the socket shape, I can test it by plugging in a fake, and I can change the real infrastructure by swapping the plug — never the machine.
🕳️ Knowledge Gaps¶
- Drawing the line between "fat" use cases and thin adapters when logic could live in either.
✅ Summary¶
I can layer a service hexagonally: domain-owned ports, error-translating adapters, a thin gRPC driving edge, and compile-time interface checks — yielding a core I can unit-test with fakes and re-back with Redis by changing one line.
⏭️ Next Steps / Prep for Tomorrow¶
- Day 136: implement the
Queueport for real with a Redis-backed job queue and a worker pool.
| Time spent | Difficulty | Confidence |
|---|---|---|
| 90 min | 🟦🟦⬜⬜⬜ | 🟦🟦🟦⬜⬜ |
Suggested commit: refactor(core): split service into hexagonal ports & adapters (day 135)