Free preview

Why this matters: logging libraries are where layering mistakes go to be discovered two years later — a formatter welded to a file writer, context added by mutating a shared logger, a message serialized and then thrown away by a level check. Every one of those bugs is a design decision made carelessly in this act. The model below makes each decision on purpose.

Three layers, because the requirements say so

Walk the requirements from lesson 01 and the architecture falls out. Several destinations consume the same message → something must dispatch one message to many consumers. Different formats per destination → formatting and writing are different jobs. Two filtering gates → one belongs to the logger, one to the destination. That's three layers:

Logger          named; owns a minimum level and bound fields;
                the API application code touches
   |
   v
dispatch        builds the event ONCE, walks the appenders
   |
   v
Appender        write(event) to one destination; owns its own
                optional stricter level filter
Formatter       event -> text/bytes; owned BY an appender,
                interchangeable across them

The Appender/Formatter split deserves its justification said aloud: the console wants terse single lines, the file wants full detail, the HTTP shipper wants JSON — but writing to a file is the same job regardless of format. One interface doing format-and-write forces every new destination to re-implement formatting; splitting them means any format can pair with any destination. That's the single-responsibility principle located exactly where it pays rent — and it's why adding a fourth destination later is a new Appender, nothing else touched (open/closed, earned by the requirement, not the pattern catalog).

The event is a value object, built once

When a message passes the gates, everything about it gets captured into one immutable value:

LogEvent: timestamp, level, loggerName, message,
          fields (merged), threadId

Built once, handed to every appender. Immutability is doing real work here, not ceremony: many threads log concurrently, and several appenders read the same event — an immutable value can be shared freely with no defensive copies and no locks around reading. Each appender's own write path handles its own synchronization; the event itself is safe by construction.

The level gate runs first — before anything exists

The hot-path requirement — a suppressed DEBUG costs ~nothing — dictates the order of operations inside log():

log(level, message, fields):
    if level < effectiveMinimum: return        // FIRST line
    event = buildEvent(...)                    // only now
    for each appender: filter, format, write

The gate must precede construction. If the event is built — fields merged, timestamp taken, message assembled — and then filtered, every suppressed DEBUG call pays nearly the full cost of an emitted one, and the requirement is quietly broken while every test passes. This one-line ordering decision is the difference between a library services tolerate and one they rip out; it's also the cleanest example in this course of a non-functional requirement dictating code structure.

There's a companion API decision: message text often costs something to build at the call site. Offering a lazy form — the message as a supplier the library only invokes past the gate — extends the same guarantee to the caller's side of the boundary:

debug(() -> "cart=" + cart.summarize())    // summarize() never runs if suppressed

Bound fields: child loggers, not mutation

The request-scoped requirement — a logger that stamps request_id on everything — has a clean shape and a trap. The clean shape:

requestLog = baseLog.with({request_id: "r-7f3"})

with(fields) returns a new logger carrying the bound fields; the original is untouched. The trap is the mutating version — baseLog.addField(...) — which changes a logger that other threads and other requests are sharing. Now one request's request_id bleeds into another's messages: an aliasing bug, in production, intermittent. Immutable child loggers make it unrepresentable. Merge order gets defined while you're here: per-call fields override bound fields on key collision — decided now, in one sentence, instead of discovered later in three implementations.

Two gates, two layers — kept straight

The logger's minimum answers "does this code path care about DEBUG?" The appender's optional stricter minimum answers "does this destination want that much?" They compose simply: the logger gate runs first (it protects the hot path and event construction); each appender then applies its own filter to already-built events. Console-at-INFO-while-file-takes-DEBUG falls out for free — and neither gate knows the other exists.

What we rejected, and why

One interface doing format + write. Buried above, but name it in the round: it's the design whose fourth destination copies formatting code from the third.

A global static logger with mutable context. The shared-mutation aliasing bug, plus untestability — nothing injected can be nothing replaced.

Filtering at the destination only. Preserves all behavior, breaks the cost requirement: every suppressed message still builds an event and walks the dispatch. Correct-looking, quietly wrong — the kind of rejection worth narrating because it shows you connect structure to cost.

Key takeaway

Three layers because three requirements force them: dispatch (many destinations, one message), the appender/formatter split (writing and formatting are different jobs), and two independent level gates. The event is an immutable value built once — but only after the logger's gate, because the suppressed-message cost budget dictates order of construction. Context is child loggers, never mutation. Each decision here is a named principle standing on a requirement, which is exactly how an interviewer wants to hear it.

Enjoying the preview?

Create a free account to unlock the rest of this course, the in-browser judge, and live AI mock interviews.

Sign up free to continue