DEV Community

Cover image for A Logging Port in Go: Keeping slog at the Edge of Your Hexagon
Gabriel Anhaia
Gabriel Anhaia

Posted on

A Logging Port in Go: Keeping slog at the Edge of Your Hexagon


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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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...)}
}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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))
        })
}
Enter fullscreen mode Exit fullscreen mode

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))
Enter fullscreen mode Exit fullscreen mode

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 }
Enter fullscreen mode Exit fullscreen mode

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)
    }
}
Enter fullscreen mode Exit fullscreen mode

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.

Thinking in Go — the 2-book series on Go programming and hexagonal architecture

Top comments (0)