Intermediate30 min readMachine Coding Practicelive prototype

Logging framework

The purest separation-of-concerns problem in the set. One log call has three completely independent questions hiding inside it — does this get through, where does it go, what does it look like — and the moment you weld them into one class, every new sink and every new format costs an edit to the code everybody depends on.

The idea

What it is

“Design a logging framework.” It is the friendliest-sounding prompt in the set, and it is the one most candidates fail without noticing. Everybody has used a logger. Almost nobody has been asked to take one apart.

Here is the naive answer, and it takes about ninety seconds to write: a Logger class with a log(level, message) method, a switch on the level, and a System.out.println at the bottom. It works. It also has no seams, and the interviewer is about to press on every one of the places where a seam should have been.

Because a single call — log.warn("disk 91% full") — is really three independent questions stacked on top of each other. Does this message get through at all? Where does it go? What does it look like when it gets there? Each one changes for a different reason, at a different time, decided by a different person. Weld them together and every one of them costs you an edit to the class the whole application depends on.

① WHAT gets through — the level threshold ② WHERE it goes — Appender ③ HOW it looks — Formatter (the small chips) log.info(msg) Logger level < threshold → return threshold gate LogEvent ts · level · logger thread · msg · ctx built ONCE fan-out 🖥 ConsoleAppender ≥ DEBUG 📝 Plain 📄 FileAppender ≥ WARN { } Json 🌐 NetworkAppender ≥ ERROR { } Json stdout app.log collector
Read it left to right and notice where each decision lives. The gate decides whether, the appender decides where, the chip on the appender decides what it looks like. Three decisions, three places, none of them inside the other.

The whole system in three sentences

A Logger checks one number — is my level at least the threshold? — and returns immediately if not. If it passes, it builds one immutable LogEvent and hands the same object to every Appender on its list. Each appender applies its own threshold, asks its own Formatter to turn the event into bytes, and writes them wherever it writes.

What is actually being graded

  1. Are the three axes separate? Level, sink and format must be three interfaces you can change independently. If any two live in the same class, the design has already failed and everything after is decoration.
  2. Are levels ordered? TRACE < DEBUG < INFO < WARN < ERROR < FATAL as an enum with an ordinal. Levels as strings make a threshold impossible, and that is the single most common wrong turn.
  3. Is the level check before the work? The filter must run before the message is formatted, before the event is built, before anything is allocated. Formatting first and filtering second is a real performance bug in a real system.
  4. Can you add a sink without touching the core? “Now also send errors to Slack.” The correct answer is one new class implementing Appender and one line of configuration. Any answer that edits Logger loses the point.
  5. Does it run under threads? Many threads log at once. Locking per appender, one event object shared by all of them, and an async option for the slow sink — that is the difference between a diagram and a framework.
✓ IN SCOPE — say this out loud in minute 3 ordered Level enum + threshold hierarchical logger names Appender: console · file · network Formatter: plain text · JSON immutable LogEvent LogManager registry (thread-safe) per-appender locking async appender, bounded queue eight nouns · all of them fit in 60 min ✗ OUT OF SCOPE — one sentence, then move on shipping logs off the box search, query, indexing dashboards and alerting parsing logs back into fields retention policy enforcement the collector's wire protocol config file format / reload these are a platform, not a framework — naming them shows you know the boundary
The left column is what you will build. The right column is what you will name in one sentence and then not build — saying it out loud is worth as much as the code, because it proves you know where the framework ends.

Mechanics

How it works

Step 1 · Clarify — 4 minutes

  • Are we building the framework or using one? — the framework. Say it back, because the whole round hinges on it: you are writing the thing that slf4j is, not the code that calls it.
  • How many destinations? — at least three: console, file, and something over the network. Fewer than two and the fan-out never appears, which kills the most interesting half of the design.
  • Can two destinations want different formats? — yes, and ask this deliberately. “Plain text on the console for humans, JSON in the file for machines” is the sentence that forces Formatter to be its own interface.
  • Can I turn on debug for one part of the app only? — yes. That is the hierarchical logger name, and it is the follow-up they always have ready.
  • Is it multi-threaded? — yes. Assume it, do not ask timidly. A logger that garbles lines when two threads write is not a logger.
  • Search, dashboards, shipping to a collector? — out of scope. One sentence, then move on.

Do not start coding at minute 5

This problem punishes the fast starter. If you open with class Logger and a switch, you will spend minutes 30 through 55 retrofitting seams into code that has none, in front of someone watching you do it. Spend four minutes drawing three boxes — gate, appender, formatter — and the code afterwards writes itself.

Step 2 · The welded version, and what it costs

Write the naive one down. Do not skip this — showing the interviewer that you know what you are avoiding is worth more than quietly avoiding it.

the version almost everyone writes
class Logger {
    boolean toConsole = true;
    boolean toFile    = true;
    String  level     = "INFO";        // a String. remember this.

    void log(String level, String message) {
        String line = "[" + level + "] " + new Date() + " " + message;   // formatted FIRST
        switch (level) {
            case "DEBUG": if (!this.level.equals("DEBUG")) return; break;
            case "INFO":  if (this.level.equals("ERROR")) return; break;
            // ... and it grows every time somebody adds a level
        }
        if (toConsole) System.out.println(line);
        if (toFile)    writeToFile(line);
    }
}

It works today. Now the interviewer says the sentence they were always going to say: “Now also send errors to Slack, as JSON, but only in production.” Count what you touch.

✗ WELDED — one class does all three jobs class Logger { boolean toConsole, toFile; String level; switch (level) { ... } line = "[" + lvl + "] " + msg; if (toConsole) println(line); if (toFile) write(line); } ✓ LAYERED — three interfaces, three reasons to change enum Level { TRACE…FATAL } ordered → a threshold is possible interface Appender { append(e) } Console · File · Network · Slack interface Formatter { format(e) } PlainText · Json each varies without disturbing the others “now also send errors to Slack, as JSON, but only in production” edit Logger · add 2 flags · add a format branch files touched: 3 existing · redeploy the core new SlackAppender + the JsonFormatter you have files touched: 1 new · 0 existing edits
Look only at the bottom two boxes. 3 existing files versus 0 — that number is the entire argument for Separation of Concerns, and it is why Open/Closed (OCP) is phrased as open to extension, closed to modification. Three jobs in one class also means three reasons to change it, which is exactly what Single Responsibility (SRP) forbids.

Step 3 · Axis one — WHAT gets through

Levels are not labels. They are an ordered scale, and the ordering is the only reason a threshold can exist. Make the enum, and one line of code does the entire job:

the most important line in the system
enum Level { TRACE, DEBUG, INFO, WARN, ERROR, FATAL }   // order IS the meaning

// inside Logger.log(...)
if (level.ordinal() < threshold.ordinal()) return;      // before ANYTHING is built
threshold = INFO (ordinal 2) FATAL5 ✓ passes ERROR4 ✓ passes WARN3 ✓ passes INFO2 ✓ passes — the threshold itself threshold DEBUG1 ✗ dropped at the gate TRACE0 ✗ dropped at the gate if (event.level.ordinal() < threshold.ordinal()) return; ← the whole rule, one line
Notice what the ordinals buy you: one comparison replaces a six-branch switch. With levels as strings there is no <, so there is no threshold, so you end up with the switch that grows a case every time someone invents a level.

The check goes first, or it is not a check

if (isTooLow) return; must be the first statement, before the timestamp is read, before the event is allocated, before String.format runs. Filtering after formatting is the classic version of this bug: you pay the full cost of every disabled DEBUG line and then throw the result away. Real frameworks care about allocation on this path for exactly that reason.

The threshold is hierarchical — the follow-up they always have ready

“The database pool is misbehaving. Turn on DEBUG for it — but only it.” One global threshold cannot do this. What can is the logger name, which is a dotted path, and a threshold that is inherited down that path.

A logger named com.app.db.PoolManager asks itself: am I configured? No. So it asks com.app.db. Configured — DEBUG. Stop. That walk-up-until-someone-handles-it is Chain of Responsibility, and it is why turning on DEBUG for one package does not drown you in everything else.

root configured: INFO effective: INFO com configured: — effective: INFO com.app configured: — effective: INFO com.app.db configured: DEBUG effective: DEBUG com.app.db.PoolManager configured: — effective: DEBUG ← inherited not configured → ask my parent inherit from the nearest configured ancestor effectiveLevel(): while (node.configured == null) node = node.parent; ← Chain of Responsibility two configured nodes control the level of the entire tree — and DEBUG on the pool does not touch the web layer
Two configured nodes, five effective levels. Only the nodes somebody cared about carry a setting; everything else inherits, so a config file with two lines governs an application with two thousand loggers.

Step 4 · Axes two and three — WHERE it goes and HOW it looks

An Appender answers where. It has one method, append(LogEvent), and the logger holds a list of them. When an event passes the gate, the logger walks the list and hands the same event to each one. That fan-out — one source, N interested parties, added and removed without the source knowing — is Observer.

A Formatter answers what the bytes look like. It also has one method, format(LogEvent) -> String. The appender owns one, calls it, and writes the result. Two interchangeable implementations behind one method is Strategy in its most literal form.

The point is that they compose. Each appender carries its own threshold and its own formatter, so console-at-DEBUG-in-plain-text and file-at-WARN-in-JSON is not a special case — it is just two objects configured differently. The two filters stack: the logger gate decides if the event exists at all, and each appender decides if it wants this one.

three dials — turn any one without touching the other two ① LEVEL TRACE DEBUG INFO ◀ set here WARN ERROR FATAL 6 values ② SINK 🖥 ConsoleAppender ◀ 📄 FileAppender 🌐 NetworkAppender 💬 SlackAppender 4 classes ③ FORMAT 📝 PlainTextFormatter ◀ { } JsonFormatter the appender never knows which one it is holding 2 classes 6 + 4 + 2 = 12 classes cover 6 × 4 × 2 = 48 combinations welded, the same 48 combinations need 48 branches inside one method addition instead of multiplication — that is what “orthogonal” buys you, and it is the sentence to say out loud
Addition, not multiplication. Adding a fifth sink adds one class and instantly works with both formats and all six levels — because none of the three dials knows the others exist.

Step 5 · The LogEvent — build it once, share it with everyone

The event is the value object that travels down the pipeline. It carries everything an appender could want, it is immutable, and — this matters — it is built exactly once per log call and handed to every appender. Building a fresh one per appender is a real bug: three appenders means three timestamps for one event, and now your file and your console disagree about when something happened.

LogEvent «immutable value object» timestamp : Instant level : Level loggerName : String message : String threadName : String throwable : Throwable? context : Map (MDC) traceId=7f2a · userId=42 all final · no setters · built ONCE 🖥 ConsoleAppender the SAME object 📄 FileAppender the SAME object 🌐 NetworkAppender the SAME object immutable → safe to share across threads build it per appender and one line gets three different timestamps — a bug you only find at 3am
The context map is the field candidates forget and interviewers love: it is where a trace id rides along, so one request can be followed across every log line it produced.
app code Logger ConsoleAppender Formatter FileAppender warn("disk 91% full") effectiveLevel() → INFO WARN ≥ INFO → continue new LogEvent(...) ×1 append(event) own threshold: DEBUG ✓ format(event) "10:12:04 WARN … disk 91% full" 🔒 own lock · write append(same event) own threshold: WARN ✓ its own JsonFormatter
Follow the two append arrows: the logger does not know or care what happens after them. Both appenders got the identical event and reached different bytes on different devices. Notation: Sequence diagrams. The class layout is Class diagrams.

The lazy-message trap — raise this unprompted

Here is a line that looks completely harmless and is not:

the cost you cannot see
log.debug("user " + user.expensiveToString() + " has " + orders.size() + " orders");

Arguments are evaluated before the call. So even with DEBUG switched off — even though debug() returns on its very first line — the concatenation and expensiveToString() have already run. On a hot path, called a million times, that is a million wasted strings for output nobody will ever see.

✗ DEBUG is OFF — and you paid for it anyway expensiveToString() build the string debug(...) → return every millisecond before the return was thrown away ✓ parameterised — the string is never built debug("user {} has {} orders", u, n) level check → return nothing allocated substitution happens only after the event passes the gate two other fixes worth naming: if (log.isDebugEnabled()) { … } · log.debug(() -> "user " + u) the guard is explicit, the supplier is lazy, the placeholder is both cheapest and tidiest
Bringing this up before you are asked is one of the highest-value twenty seconds in the round — it says you have thought about the log call as a hot path, not just as a feature.

Step 6 · Threads — lock per appender, never globally

Twenty threads log at once. Two of them are halfway through writing a line to the same file, and what lands on disk is the front of one line spliced onto the back of another. So writes must be serialised — but serialised per appender, not across the whole framework.

One global lock and you have made every appender wait for the slowest one: a network sink taking 200ms stalls the console. A lock inside each appender lets the console write while the file writes while the network waits on its socket. The events themselves need no protection at all, because they are immutable. Background: Locks, Mutex, Semaphore.

The registry is the one legitimately-global thing

LogManager.getLogger(name) must return the same logger object for the same name, from any thread, forever. That is a cache: ConcurrentHashMap.computeIfAbsent(name, ...). It is worth being precise here — the manager is a registry, not a Singleton per logger. One manager, many loggers, each keyed by name. Candidates who say “Logger is a singleton” usually mean this and get corrected.

Step 7 · The async appender, and the two questions after it

A network sink is slow, and right now the request thread is paying for it. The fix is a decorator: AsyncAppender wraps another appender, drops the event into a bounded queue, and returns. One writer thread drains the queue into the real appender. That is Producer–Consumer, and mentioning it by name is free marks.

Then two questions arrive, and they arrive every single time.

request thread 1 request thread 2 request thread 3 return in microseconds bounded queue — capacity 8 6 of 8 used — two slots left writer thread exactly one 🌐 slow sink 200 ms the queue is full. now what? BLOCK the caller ✓ no event is ever lost ✗ logging can now stall a request DROP and count the drops ✓ the request never waits on logging ✗ events are lost — but the counter says how many and on shutdown: close() must DRAIN the queue — otherwise the last events, the interesting ones, are the ones you lose
Have an opinion on the fork. For logs, drop and count — a log line is worth less than the request it describes, and a counter of drops turns silent loss into a visible number. Say that, and say the opposite for an audit trail.

The shutdown question is the one people forget

Async means events are in a queue when the process dies. If close() does not drain and flush, the last few seconds of logs — the seconds around the crash you are trying to debug — are exactly the ones that never reach disk. A close() on the Appender interface plus a shutdown hook is the whole answer, and it takes ten seconds to say.

The extensibility test

the interviewer asks for one of these. count the files. CHANGE REQUEST WHAT YOU TOUCH CORE EDITS send errors to Slack new SlackAppender 0 emit XML instead of JSON new XmlFormatter 0 add a TRACE level one enum constant 0 DEBUG for one package only one config line — no code 0 make the network sink async wrap it in AsyncAppender 0 rotate the file daily inside FileAppender only 0 a trace id on every line context map — formatters already print it 0 a column of zeros is the point — that is what “closed to modification” looks like when you can count it
Sketch this column of zeros while you talk. It converts an abstract claim — “my design is extensible” — into a number the interviewer can check.

The 60 minutes

60 minutes, spent in this order 4m 6m 10m 16m 12m 12m 4m clarify — destinations, formats, per-package levels, threads 6m draw the three axes on the board and name the patterns 10m Level enum + LogEvent — small, and everything downstream depends on them 16m Appender + Formatter interfaces and their implementations — THE core of the round 12m Logger, effective level via the parent walk, LogManager registry 12m main() that prints real output, then per-appender locking and the async appender
The green block is where the marks are. If you are still explaining the Level enum at minute 25, you are behind — it is ten lines and it is not what they are grading.

The follow-ups

  • “The log file grows forever.” → rotation inside FileAppender: roll when it passes a size, or at midnight, and keep the last N. Say that rotation belongs to the file appender and nothing else knows it happens.
  • “Why JSON instead of plain text?” → because at scale nobody greps. One line per event with real fields lets you filter by level, traceId or userId without regexes that break the first time a message contains a space. Plain text stays on the console for humans.
  • “This log line fires ten thousand times a second.” → sampling: keep 1 in N, and record the sampling rate so the count can be reconstructed. It belongs in a filter, not in the caller.
  • “I need to follow one request across all its lines.” → the context map. Put a traceId in it at the edge of the request, and every formatter prints it because it is just another field on the event.
  • “What about secrets?” → never log passwords, tokens or card numbers. The framework's contribution is a masking filter and the discipline of logging identifiers rather than payloads. Worth ten seconds; it is a real-world scar.
  • “Reload the configuration without a restart.” → the loggers are already registry objects, so changing a level is a field write; the atom you swap is the config, not the logger. It works because the level was never baked into a switch.
  • “Two appenders both write to the same file.” → they each have their own lock, so their own locks do not help. Either share one appender instance, or make the file itself the locked resource — a good question to answer honestly rather than hand-wave.

How this round is lost

  • One god Logger with a switch. No interfaces, no seams, and every follow-up becomes an edit to the same method.
  • Levels as strings. No ordering means no threshold, and you are back to comparing text and growing the switch.
  • Formatting inside the appender. The String.format sits in ConsoleAppender, so JSON on the console means a new appender rather than a new formatter — the axes have been welded and nobody noticed.
  • The level check after the string is built. Correct output, wasted work on every disabled line, and it signals you have not thought about the log call as a hot path.
  • A single global lock. The slowest sink now sets the pace for all of them, and you have made logging a contention point across the whole process.
  • No way to add a sink without editing the core. This is the one the whole problem exists to test. If Logger has to change to gain a destination, nothing else you did will rescue it.

Interactive prototype

See it. Build it. Break it.

A sandboxed, hands-on simulation — no setup, no install. Play with it as you read.

About this simulation

A live pipeline you can rewire. Press ERROR and watch one event pass the threshold gate, fan out, and land on all three appender cards at once; press WARN and only two of them take it. Flip 📄 File to { } JSON and press ERROR again — the same event is on screen as plain text and as JSON at the same time. Set the root threshold to FATAL and press INFO: the gate says ✗ DROP and no formatter ever runs. Press ➕ Add Slack appender — a fourth sink joins the fan-out and the counter still reads core logger edited: 0 lines. Then switch the network card to ⚡ async and press 🔥 Burst 20 to watch the five-slot queue fill and the dropped counter start ticking.

Hands-on

Try these yourself

Open the prototype above, predict what happens, then verify.

try 01

Send one event through the whole pipeline

Press ERROR. The event passes the threshold gate, hits the fan-out, and lands on all three appender cards at once — one call, three writes, and the logger did not know any of the three existed. Now press WARN: it passes the gate just the same, but only two cards take it. The callline shows the real call each time — log.warn("disk 91% full").

try 02

Close the gate and watch nothing get formatted

The selected logger is com.app.db.PoolManager and the tree shows its effective level coming from com.app.db. Click 🏷 com.app instead — the tree marker jumps to root, because com.app is not configured. Now set the root threshold to FATAL and press INFO: the gate reads ✗ DROP, the passed gate counter does not move, and no card changes. Nothing was built, nothing was formatted, nothing was allocated.

try 03

Make the same event look three different ways

Reset the threshold to INFO and press ERROR — all three cards fill, two of them in plain text. Now flip 📄 File to { } JSON and press ERROR again: the same event is on screen simultaneously as a plain line and as two JSON objects. The formatter changed; the appender did not; the logger has no idea either happened.

try 04

Stack the two filters

Press INFO with the chips as they come: it passes the gate, reaches all three cards, and only 🖥 Console (≥ DEBUG) accepts it — the other two show ✗ dropped — below its own ≥ WARN / ≥ ERROR. Now click the console's own ≥ DEBUG chip round to ≥ ERROR and press INFO again: it still passes the gate and now lands nowhere. Two independent filters, composing without knowing about each other.

try 05

Add a sink and count the edits

Press ➕ Add Slack. A fourth card slides into the fan-out and immediately receives the next event. Look at the counter in the top row: core logger edited: 0 lines. That zero is the entire argument for the design — and it is what Open/Closed (OCP) means when you can measure it.

try 06

Fill a bounded queue and lose events on purpose

Press 🔥 Burst 20 with the network card on ⚡ sync: twenty errors go through one at a time and the card flashes ⏳ caller blocked on every write. Now click it to ⚡ async and burst again — events land in a five-slot queue instantly, one writer drains it at its own pace, and the moment the queue is full the dropped counter starts ticking. Decide which behaviour you would ship, and be ready to defend it.

try 07

Build it from memory

Blank file, in this order: Level enum → LogEvent (all fields final) → interface Formatter with plain and JSON → interface Appender with a threshold, a formatter and its own lock → Logger with the parent walk for the effective level → LogManager with a ConcurrentHashMap → a main() that configures console-at-DEBUG-plain and file-at-WARN-JSON and logs from three different logger names. If adding a fourth sink requires editing Logger, start again.

In practice

When to use it — and what trips people up

The shape you just learned

Strip the logging away and this is one event, three independent decisions, three interfaces: a filter that decides whether the event lives, a set of sinks that decide where it goes, and a rendering step that decides what it looks like. Once you can see that shape, you find it everywhere — and you find it welded together everywhere too.

  • Metrics and tracing — the same pipeline with different payloads: a sampler instead of a threshold, exporters instead of appenders, wire formats instead of layouts.
  • Notification systems — one event, N channels (email, SMS, push), each with its own audience filter and its own template. Identical structure, different nouns.
  • Audit trails — same fan-out, but the full-queue answer flips from drop to block, because losing an audit record is not acceptable.
  • Analytics event pipelines — collect, filter, enrich, fan out to several destinations in several encodings.
  • Any render step at all — the moment you catch yourself putting String.format inside the thing that does the I/O, you are welding axis three onto axis two.

The three-sentence version to say out loud

“Levels are an ordered enum so one comparison filters everything, and that comparison runs before anything is built. The logger builds one immutable event and fans it out to a list of appenders, so a new sink is a new class and no edit. Each appender owns its own threshold, its own formatter and its own lock, so where it goes, what it looks like and how it is serialised all vary independently.” Twenty-five seconds, and it is the whole design.

Where this design stops working

  • When throughput is extreme. At millions of events a second, even building the LogEvent object is too much allocation. Real frameworks go to ring buffers and object reuse — which trades away the immutability you just relied on. Know the trade; do not build it in an interview.
  • When ordering across appenders must be exact. An async appender reorders events relative to the synchronous ones. If a downstream consumer needs a strict global order, async is off the table or needs sequence numbers.
  • When logs are the system of record. Dropping on a full queue is right for diagnostics and wrong for anything you will be audited on. Then you block, or you write to a durable local queue first.
  • When configuration must change constantly. Per-package levels solve “debug this component”. They do not solve “sample 1% of this specific customer's traffic” — that wants a filter chain evaluated per event, which is a bigger design.

If you only remember one thing

Three axes, three interfaces, never welded. Whether is an ordered level and a comparison that runs first. Where is an Appender you can add without editing anything. What it looks like is a Formatter the appender holds but does not know. Every follow-up in this round is answered by pointing at one of those three.

What it gives you

  • Three orthogonal interfaces mean N sinks and M formats cost N+M classes instead of N×M branches, and a new destination is one new class with zero edits to the core.
  • An ordered level enum turns the entire filtering rule into a single comparison that runs before any allocation, so disabled log lines cost almost nothing.
  • Hierarchical logger names let two lines of configuration control the level of thousands of loggers, and let you enable DEBUG for one package without drowning in the rest.
  • One immutable LogEvent per call, shared by every appender, means every sink agrees on the timestamp and the object is safe to hand across threads without copying.
  • A per-appender lock keeps writes non-interleaved while still letting a fast console and a slow network sink proceed in parallel.

Common mistakes

  • The layered version is genuinely more code than the one-class logger, and for a script that prints to stdout the extra structure earns nothing.
  • The parent walk on every log call costs a few pointer hops; real frameworks cache the resolved level and then have to invalidate that cache when configuration changes.
  • Async appenders break the ordering between sinks and can lose events on a hard crash, so the diagnostics you most want are the ones most at risk.
  • Allocating a LogEvent per call is fine at normal rates and is exactly the thing that stops being fine at extreme throughput, where reuse and ring buffers take over.
  • Per-appender locking silently fails when two appender instances point at the same file — the lock protects the object, not the resource.

Reference

Code & further reading

A minimal reference implementation and pointers worth bookmarking.

import java.time.Instant;
import java.util.*;
import java.util.concurrent.*;

/** Axis 1: WHAT gets through. Order IS the meaning — that is what makes a threshold possible. */
enum Level { TRACE, DEBUG, INFO, WARN, ERROR, FATAL }

/** The value object. Immutable, built ONCE per log call, shared by every appender. */
final class LogEvent {
    final Instant timestamp;
    final Level level;
    final String loggerName;
    final String message;
    final String threadName;
    final Throwable throwable;
    final Map<String, String> context;

    LogEvent(Level level, String loggerName, String message, Throwable throwable, Map<String, String> context) {
        this.timestamp = Instant.now();
        this.level = level;
        this.loggerName = loggerName;
        this.message = message;
        this.threadName = Thread.currentThread().getName();
        this.throwable = throwable;
        this.context = Map.copyOf(context);
    }
}

/** Axis 3: HOW it looks. One method, two implementations, zero knowledge of where it lands. */
interface Formatter { String format(LogEvent e); }

class PlainTextFormatter implements Formatter {
    public String format(LogEvent e) {
        StringBuilder sb = new StringBuilder()
            .append(e.timestamp).append(' ')
            .append(String.format("%-5s", e.level)).append(" [").append(e.threadName).append("] ")
            .append(e.loggerName).append(" - ").append(e.message);
        e.context.forEach((k, v) -> sb.append(' ').append(k).append('=').append(v));
        if (e.throwable != null) sb.append(" | ").append(e.throwable);
        return sb.toString();
    }
}

class JsonFormatter implements Formatter {
    public String format(LogEvent e) {
        StringBuilder sb = new StringBuilder("{");
        sb.append(q("ts")).append(':').append(q(e.timestamp.toString())).append(',');
        sb.append(q("level")).append(':').append(q(e.level.name())).append(',');
        sb.append(q("logger")).append(':').append(q(e.loggerName)).append(',');
        sb.append(q("thread")).append(':').append(q(e.threadName)).append(',');
        sb.append(q("msg")).append(':').append(q(e.message));
        e.context.forEach((k, v) -> sb.append(',').append(q(k)).append(':').append(q(v)));
        return sb.append('}').toString();
    }
    private static String q(String s) { return '"' + s.replace("\"", "\\\"") + '"'; }
}

/** Axis 2: WHERE it goes. Its own threshold, its own formatter, its OWN lock. */
interface Appender extends AutoCloseable {
    void append(LogEvent e);
    default void close() {}
}

abstract class AbstractAppender implements Appender {
    final String appenderName;
    private final Level threshold;
    private final Formatter formatter;
    private final Object writeLock = new Object();      // per appender — NOT global

    AbstractAppender(String appenderName, Level threshold, Formatter formatter) {
        this.appenderName = appenderName; this.threshold = threshold; this.formatter = formatter;
    }

    public final void append(LogEvent e) {
        if (e.level.ordinal() < threshold.ordinal()) return;   // second filter, composes with the logger's
        String line = formatter.format(e);                     // format OUTSIDE the lock
        synchronized (writeLock) { write(line); }              // only the write is serialised
    }

    protected abstract void write(String line);
}

class ConsoleAppender extends AbstractAppender {
    ConsoleAppender(Level threshold, Formatter formatter) { super("console", threshold, formatter); }
    protected void write(String line) { System.out.println("[console] " + line); }
}

/** Rotation lives HERE and nowhere else — nothing outside knows the file rolls. */
class FileAppender extends AbstractAppender {
    private final long maxBytes;
    private long written = 0;
    private int rollCount = 0;

    FileAppender(Level threshold, Formatter formatter, long maxBytes) {
        super("file", threshold, formatter);
        this.maxBytes = maxBytes;
    }

    protected void write(String line) {
        if (written + line.length() > maxBytes) {
            rollCount++;
            written = 0;
            System.out.println("[file] -- rolled to app.log." + rollCount + " --");
        }
        written += line.length();
        System.out.println("[file] " + line);
    }
}

class NetworkAppender extends AbstractAppender {
    NetworkAppender(Level threshold, Formatter formatter) { super("network", threshold, formatter); }
    protected void write(String line) {
        try { Thread.sleep(40); } catch (InterruptedException ie) { Thread.currentThread().interrupt(); }
        System.out.println("[network] " + line);
    }
}

/** A decorator: the caller returns immediately, one writer thread drains the queue. */
class AsyncAppender implements Appender {
    private final Appender delegate;
    private final BlockingQueue<LogEvent> queue;
    private final Thread writer;
    private volatile boolean running = true;
    private int dropped = 0;

    AsyncAppender(Appender delegate, int capacity) {
        this.delegate = delegate;
        this.queue = new ArrayBlockingQueue<>(capacity);
        this.writer = new Thread(this::drain, "log-writer");
        this.writer.setDaemon(true);
        this.writer.start();
    }

    public void append(LogEvent e) {
        // FULL QUEUE POLICY: drop and count. A log line is worth less than the request it describes.
        if (!queue.offer(e)) dropped++;
    }

    private void drain() {
        while (running || !queue.isEmpty()) {
            try {
                LogEvent e = queue.poll(20, TimeUnit.MILLISECONDS);
                if (e != null) delegate.append(e);
            } catch (InterruptedException ie) { Thread.currentThread().interrupt(); return; }
        }
    }

    /** Without this, the last events — the interesting ones — never reach the sink. */
    public void close() {
        running = false;
        try { writer.join(2000); } catch (InterruptedException ie) { Thread.currentThread().interrupt(); }
        delegate.close();
        System.out.println("[async] flushed; dropped=" + dropped);
    }
}

/** The logger: a threshold, a parent, and a list of appenders. It knows nothing about formats. */
class Logger {
    private final String loggerName;
    private final Logger parent;                    // null for root
    private volatile Level configured;              // null = inherit
    private final List<Appender> appenders = new CopyOnWriteArrayList<>();

    Logger(String loggerName, Logger parent) { this.loggerName = loggerName; this.parent = parent; }

    void setLevel(Level level) { this.configured = level; }
    void addAppender(Appender a) { appenders.add(a); }

    /** Chain of Responsibility: walk up until somebody is configured. */
    Level effectiveLevel() {
        for (Logger node = this; node != null; node = node.parent)
            if (node.configured != null) return node.configured;
        return Level.INFO;
    }

    boolean isEnabled(Level level) { return level.ordinal() >= effectiveLevel().ordinal(); }

    void log(Level level, String message, Throwable t, Map<String, String> context) {
        if (level.ordinal() < effectiveLevel().ordinal()) return;      // FIRST statement. nothing built yet.
        LogEvent e = new LogEvent(level, loggerName, message, t, context);   // built ONCE
        for (Logger node = this; node != null; node = node.parent)
            for (Appender a : node.appenders) a.append(e);             // the SAME object to everyone
    }

    /** Parameterised message: the substitution happens only after the gate. */
    void log(Level level, String pattern, Object... args) {
        if (level.ordinal() < effectiveLevel().ordinal()) return;
        String message = pattern;
        for (Object arg : args) message = message.replaceFirst("\\{\\}", String.valueOf(arg));
        log(level, message, null, Map.of());
    }

    void trace(String p, Object... a) { log(Level.TRACE, p, a); }
    void debug(String p, Object... a) { log(Level.DEBUG, p, a); }
    void info(String p, Object... a)  { log(Level.INFO, p, a); }
    void warn(String p, Object... a)  { log(Level.WARN, p, a); }
    void error(String p, Object... a) { log(Level.ERROR, p, a); }
}

/** A REGISTRY, not a singleton per logger: one manager, many loggers, cached by name. */
class LogManager {
    private static final Map<String, Logger> CACHE = new ConcurrentHashMap<>();
    private static final Logger ROOT = new Logger("root", null);
    static { ROOT.setLevel(Level.INFO); CACHE.put("root", ROOT); }

    static Logger root() { return ROOT; }

    static Logger getLogger(String loggerName) {
        return CACHE.computeIfAbsent(loggerName, n -> {
            int dot = n.lastIndexOf('.');
            Logger parent = dot < 0 ? ROOT : getLogger(n.substring(0, dot));
            return new Logger(n, parent);
        });
    }

    static void shutdown() { /* a real one walks every logger; here the demo closes explicitly */ }
}

public class Main {
    public static void main(String[] args) throws Exception {
        // ---- configuration: three sinks, three thresholds, two formats ----
        Logger root = LogManager.root();
        root.setLevel(Level.INFO);
        root.addAppender(new ConsoleAppender(Level.DEBUG, new PlainTextFormatter()));
        root.addAppender(new FileAppender(Level.WARN, new JsonFormatter(), 200));
        AsyncAppender net = new AsyncAppender(new NetworkAppender(Level.ERROR, new JsonFormatter()), 8);
        root.addAppender(net);

        // ---- turn on DEBUG for ONE package only ----
        LogManager.getLogger("com.app.db").setLevel(Level.DEBUG);

        Logger web  = LogManager.getLogger("com.app.web.Handler");
        Logger pool = LogManager.getLogger("com.app.db.PoolManager");

        System.out.println("web effective  = " + web.effectiveLevel());    // inherits root
        System.out.println("pool effective = " + pool.effectiveLevel());   // inherits com.app.db

        web.debug("this never appears — web inherits INFO");
        pool.debug("pool size={} idle={}", 8, 3);                          // DEBUG is on here
        web.info("user {} signed in", 42);
        pool.warn("disk {}% full", 91);
        web.error("connection refused");

        // ---- the lazy-message guard ----
        if (pool.isEnabled(Level.TRACE)) pool.trace("expensive: " + expensiveDump());

        Thread.sleep(300);
        net.close();
    }

    static String expensiveDump() { return "…a very costly string…"; }
}

/* expected output (ordering of the async lines may vary):
web effective  = INFO
pool effective = DEBUG
[console] …Z DEBUG [main] com.app.db.PoolManager - pool size=8 idle=3
[console] …Z INFO  [main] com.app.web.Handler - user 42 signed in
[console] …Z WARN  [main] com.app.db.PoolManager - disk 91% full
[file] {"ts":"…","level":"WARN","logger":"com.app.db.PoolManager","thread":"main","msg":"disk 91% full"}
[console] …Z ERROR [main] com.app.web.Handler - connection refused
[file] {"ts":"…","level":"ERROR","logger":"com.app.web.Handler","thread":"main","msg":"connection refused"}
[network] {"ts":"…","level":"ERROR","logger":"com.app.web.Handler","thread":"main","msg":"connection refused"}
[async] flushed; dropped=0
*/

References & further reading

7 sources

Knowledge check

Did it land?

Quick questions, answers revealed on submit. Sign in to save your best score.

question 01 / 08

Why must log levels be an ordered enum rather than strings?

question 02 / 08

The interviewer says: “now also send errors to Slack, as JSON, but only in production.” In a correctly layered design, what do you touch?

question 03 / 08

log.debug("user " + user.expensiveToString()) runs with DEBUG switched off. What actually happens?

question 04 / 08

A logger named com.app.db.PoolManager has no configured level. com.app.db is set to DEBUG and root is set to INFO. What is its effective level, and what pattern is that?

question 05 / 08

Where should the level check happen relative to formatting the message?

question 06 / 08

Should the framework take one global lock, or a lock per appender?

question 07 / 08

An async appender's bounded queue is full. What should append() do, and why?

question 08 / 08

Why should the LogEvent be built once and handed to every appender, rather than built per appender?

0/8 answered