TL;DR: A CLI tool wants pretty, coloured logs in a terminal. The same tool, running as a daemon in a container, wants structured JSON. If your packages import a concrete logger, switching between those two means touching every package. go-tool-base’s packages depend only on a logger.Logger interface, never a backend. Three backends ship (charmbracelet, slog JSON, and a no-op for tests), and swapping them changes one line at the top of the tool, not a hundred imports underneath.

The same tool wants two different logs

Think about where a tool built on go-tool-base actually runs.

On a developer’s machine it’s a CLI. You want logs that are pleasant to read in a terminal: colour, alignment, human-friendly timestamps. The charmbracelet logger does that beautifully.

Then the very same tool grows a serve command and gets deployed as a daemon in a container. Now coloured terminal output is worse than useless. The log aggregator wants structured JSON, one object per line, machine-parseable. slog does that.

And in tests you want neither. You want the logger to exist, satisfy the interface, and stay silent.

That’s three different logging backends, wanted by one tool in three different lives. The question is what that costs you when you switch.

What it costs depends on what your packages imported

If your packages import a concrete logger, if pkg/config and pkg/setup and twenty others each have import "github.com/charmbracelet/log" and take a *log.Logger, then the backend is welded into the entire codebase. Switching to JSON for the container build means editing the import and the parameter type in every one of those packages. The backend has leaked. A detail that should have been one decision became a property of a hundred files.

go-tool-base doesn’t let it leak. Every package in the framework accepts a logger.Logger, an interface, and nothing else. No package anywhere imports a concrete logging library. A package states, in its types, “I need something I can log through,” and stops there. It has no idea, and no way to find out, what’s on the other end.

// what every package depends on
type Logger interface {
    Debug(msg string, args ...any)
    Info(msg string, args ...any)
    Warn(msg string, args ...any)
    Error(msg string, args ...any)
    // ...
}

The backend is chosen once, at the top, when the tool builds its Props. It travels down to every package as the interface, through the Props container. The packages underneath never see the concrete type, so the concrete type can change without them noticing.

Three backends, and the swap is one line

go-tool-base ships three implementations of that interface:

  • charmbracelet (logger.NewCharm(w, opts...)). Coloured, styled, for humans at a terminal. The CLI default.
  • slog JSON, a slog-backed backend emitting structured JSON, for daemons and containers feeding a log aggregator.
  • noop, which does nothing, for tests that want a real Logger and total silence.

Switching the tool from a friendly CLI logger to container-ready JSON is a change to the one line in main() that constructs the logger. That’s it. pkg/config doesn’t change. pkg/setup doesn’t change. None of the twenty packages change, because none of them ever knew which backend they had. The decision was always one line; the interface is what kept it one line.

The noop backend deserves a specific mention because it’s the one people underrate. A test for a command shouldn’t spray log output across the test run, but the command still needs a non-nil Logger to function. logger.NewNoop() gives you exactly that: the interface satisfied, the output discarded, the test quiet. Because it’s just another implementation of the same interface, no test needs special logging machinery. It passes a different backend, the way the container build does.

The general shape

There’s nothing exotic here. It’s “depend on interfaces, not implementations,” which every Go developer has heard. The point worth holding onto is where the rule pays out, and it’s at the seams between a stable core and a detail you’ll want to vary.

A logging backend is exactly such a detail. You will want it different in a terminal, in a container, and in a test. So the thing your code depends on must be the interface, and the concrete backend must be chosen at one well-known point and nowhere else. Get that boundary right and “we need JSON logs in production” is a one-line change. Get it wrong and it’s a refactor.

What it comes down to

One tool legitimately wants three different logging backends across its life: coloured output in a terminal, structured JSON in a container, silence in a test. The cost of moving between them is decided entirely by whether your packages imported a concrete logger or an interface.

go-tool-base’s packages depend only on logger.Logger, never a backend. Three implementations ship (charmbracelet, slog JSON, noop) and the backend is chosen once, in main(), then carried everywhere as the interface through Props. Switching is one line at the top, because the detail was never allowed to leak into the hundred files below it.