Skip to content

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

📝 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 requiresJobRepository, Queue, Clock. Driving (inbound) ports are the use cases the core exposesEnqueuer.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.Nil or SQL sql.ErrNoRows becomes a domain ErrNotFound, 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 in examples/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 Queue interface 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.Nil straight out of an adapter; the core then needed to import the redis package. Translated it to core.ErrNotFound at 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)

  1. Q: In hexagonal architecture, which package should declare the Queue interface — the core or the Redis adapter?
AThe 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.
  1. Q: What does var _ core.Queue = (*RedisQueue)(nil) buy you?
AA 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 Queue port 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)