- Book: Hexagonal Architecture in Go
- Also by me: The Complete Guide to Go Programming — the companion book in the Thinking in Go series
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
You add log/slog to a Go service on day one. It ships in the
standard library since Go 1.21, it does structured logging, and
there is nothing to install. So import "log/slog" shows up in
the HTTP handler, then in the service layer, then in the domain
type that validates an order. Six months later you want to swap
the handler for a JSON one that ships to your log pipeline, and
you find slog.Logger referenced in forty files, half of them
in code that has no business knowing what a logger is.
That is not a slog problem. slog is a good design. The problem is
where you let it live. A logging library is an outbound
dependency, the same category as your database driver and your
HTTP client. In a hexagonal layout those live at the edge, behind
a port. Your domain talks to the port. slog sits in an adapter on
the far side of it.
This post builds that port. A narrow logging interface the domain
owns, a slog adapter that satisfies it, structured context that
survives the boundary, and the reason importing slog in the core
costs you more than it looks like.
What importing slog in the core actually couples
slog.Logger is a concrete struct, not an interface. When a
domain function takes a *slog.Logger, it depends on that exact
type, its method set, and the slog.Attr value type its methods
speak. Your business rule now compiles against a logging library.
// domain/order.go — the version that couples
package domain
import "log/slog"
func (o *Order) Confirm(log *slog.Logger) error {
if o.Status != StatusPending {
log.Warn("confirm on non-pending order",
slog.String("order_id", o.ID),
slog.String("status", string(o.Status)))
return ErrNotPending
}
o.Status = StatusConfirmed
return nil
}
Read what this domain package now imports. log/slog. To unit
test Confirm you construct a *slog.Logger. To reason about
the order lifecycle you carry logging vocabulary. Say you want to
move to a different logger later. A build that routes through
OpenTelemetry, a test double that records calls, a no-op in a
hot path: any of those forces you to touch every signature that
names *slog.Logger.
The domain does not need slog. It needs to say "this happened,
here are the fields." That sentence is an interface.
The port: a narrow interface the domain owns
Define the logger where it is used, not where it is implemented.
The interface lives in the domain (or a port package the domain
imports), and it names only what the core needs to say.
// port/logger.go
package port
import "context"
type Logger interface {
Debug(ctx context.Context, msg string, kv ...any)
Info(ctx context.Context, msg string, kv ...any)
Warn(ctx context.Context, msg string, kv ...any)
Error(ctx context.Context, msg string, kv ...any)
With(kv ...any) Logger
}
Five methods. No slog import, no slog.Attr, no handler, no
level type. The kv ...any pairs echo slog's own key/value
style, so the adapter is a thin pass-through, but the signature
belongs to you. With returns a Logger so a component can bind
fields once and pass the narrowed logger down.
The domain function now depends on this and nothing else.
// domain/order.go — the version that stays clean
package domain
import (
"context"
"yourapp/port"
)
func (o *Order) Confirm(
ctx context.Context, log port.Logger,
) error {
if o.Status != StatusPending {
log.Warn(ctx, "confirm on non-pending order",
"order_id", o.ID,
"status", string(o.Status))
return ErrNotPending
}
o.Status = StatusConfirmed
return nil
}
The import list of domain/order.go is now context and your
own port package. Nothing from the standard library's logging
tree, nothing from a third-party logger. The order lifecycle
compiles against a sentence, not a library.
The slog adapter at the edge
The adapter lives in adapter/logging, on the outside of the
hexagon, and it is the only file in the codebase that imports
log/slog.
// adapter/logging/slog.go
package logging
import (
"context"
"log/slog"
"yourapp/port"
)
type SlogLogger struct {
l *slog.Logger
}
func NewSlog(l *slog.Logger) *SlogLogger {
return &SlogLogger{l: l}
}
func (s *SlogLogger) Info(
ctx context.Context, msg string, kv ...any,
) {
s.l.InfoContext(ctx, msg, kv...)
}
func (s *SlogLogger) Warn(
ctx context.Context, msg string, kv ...any,
) {
s.l.WarnContext(ctx, msg, kv...)
}
func (s *SlogLogger) Error(
ctx context.Context, msg string, kv ...any,
) {
s.l.ErrorContext(ctx, msg, kv...)
}
func (s *SlogLogger) Debug(
ctx context.Context, msg string, kv ...any,
) {
s.l.DebugContext(ctx, msg, kv...)
}
func (s *SlogLogger) With(kv ...any) port.Logger {
return &SlogLogger{l: s.l.With(kv...)}
}
Note the Context variants: InfoContext, WarnContext, and
friends. They pass the context.Context through to the slog
handler, which matters for the next section. A compile-time
assertion keeps the adapter honest as the port grows.
// adapter/logging/slog.go
var _ port.Logger = (*SlogLogger)(nil)
Wiring happens once, in main, where every adapter is
constructed and injected.
// cmd/server/main.go
package main
import (
"log/slog"
"os"
"yourapp/adapter/logging"
)
func main() {
h := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelInfo,
})
log := logging.NewSlog(slog.New(h))
// inject `log` (a port.Logger) into services, handlers...
_ = log
}
main is the composition root. It is allowed to know about
slog, JSON handlers, and stdout. Everything it wires up sees only
port.Logger. Swap NewJSONHandler for a text handler in
development, or a handler that fans out to your collector in
production, and not one line of domain code changes.
Structured context that survives the boundary
Here is where the context.Context in every method earns its
place. A request handler wants to bind a request ID, a tenant,
maybe a trace ID, once — and have every downstream log line carry
those fields without threading a logger through every call.
slog has the mechanism: a custom Handler can read values off
the context and attach them as attributes. Because your adapter
calls the Context variants, those values reach the handler.
// adapter/logging/context_handler.go
package logging
import (
"context"
"log/slog"
)
type ctxKey string
const fieldsKey ctxKey = "log_fields"
// WithField returns a context carrying an extra log attr.
func WithField(
ctx context.Context, key, val string,
) context.Context {
prev, _ := ctx.Value(fieldsKey).([]slog.Attr)
next := append(prev, slog.String(key, val))
return context.WithValue(ctx, fieldsKey, next)
}
// ContextHandler wraps a handler and injects ctx fields.
type ContextHandler struct {
slog.Handler
}
func (h ContextHandler) Handle(
ctx context.Context, r slog.Record,
) error {
if attrs, ok := ctx.Value(fieldsKey).([]slog.Attr); ok {
r.AddAttrs(attrs...)
}
return h.Handler.Handle(ctx, r)
}
Middleware binds the request-scoped fields onto the context once.
// adapter/http/middleware.go
func RequestID(next http.Handler) http.Handler {
return http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
id := r.Header.Get("X-Request-ID")
if id == "" {
id = uuid.NewString()
}
ctx := logging.WithField(
r.Context(), "request_id", id)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
Now log.Info(ctx, "order confirmed", "order_id", id) from deep
in the service layer emits a line that already carries
request_id, because the handler pulled it off the context. The
domain never saw the request ID. It flowed through context, the
one channel that is already crossing every boundary in idiomatic
Go, and the handler at the edge stitched it back into the log
record.
Wire the context handler in main by wrapping the base handler.
base := slog.NewJSONHandler(os.Stdout, nil)
h := logging.ContextHandler{Handler: base}
log := logging.NewSlog(slog.New(h))
Testing without a logger at all
Because the domain depends on port.Logger, tests inject a
double instead of standing up slog and capturing stdout.
// port/logger_test_double.go
package port
import "context"
type NopLogger struct{}
func (NopLogger) Debug(context.Context, string, ...any) {}
func (NopLogger) Info(context.Context, string, ...any) {}
func (NopLogger) Warn(context.Context, string, ...any) {}
func (NopLogger) Error(context.Context, string, ...any) {}
func (n NopLogger) With(...any) Logger { return n }
Order tests read straight through.
func TestConfirm_RejectsNonPending(t *testing.T) {
o := &domain.Order{Status: domain.StatusShipped}
err := o.Confirm(context.Background(), port.NopLogger{})
if !errors.Is(err, domain.ErrNotPending) {
t.Fatalf("got %v, want ErrNotPending", err)
}
}
No handler, no buffer, no parsing JSON out of a bytes.Buffer to
assert a field. If a test does need to assert what got logged,
write a recording double that appends each call to a slice and
check the slice. The point stands: the domain test names
port.Logger, and slog is nowhere in the test binary.
Where to draw the line
The port stays narrow on purpose. Do not add SetLevel, do not
expose slog.Handler, do not let a slog.Level leak into the
signature. Every method you add to the port is a method every
adapter must implement and every test double must stub. Level
configuration is a composition-root concern; it belongs in
main, next to the handler.
The rule that keeps this honest: grep your domain and service
packages for log/slog. In a clean hexagonal layout that grep
returns exactly one directory, adapter/logging, plus main.
Anything else importing slog is a boundary leak, and it will cost
you the day you swap loggers.
slog earns its spot in the standard library. Keep it there — at
the edge, behind a port, where a logging library is one more
outbound adapter and not a dependency welded to your business
rules.
If this was useful
Keeping a standard-library type like slog behind a port is a
small instance of the pattern the whole hexagonal layout runs on:
outbound dependencies live in adapters, the domain speaks
interfaces. Hexagonal Architecture in Go walks that boundary
for databases, queues, and third-party clients, and The Complete
Guide to Go Programming covers the slog, context, and interface
mechanics these adapters lean on.

Top comments (0)