Skip to content

Low Level DesignWorked problems

Design a logging framework

Design a logging framework step by step for a low level design interview. Levels, records, formatters and appenders, a logger tree with inherited configuration, lazy messages, and an async appender that never loses a record on shutdown, with code in Java, Python, C#, Go and TypeScript.

Foundation29 min core · 45 min full depth

“Design a logging framework like Log4j.” Levels, several outputs, formats you can configure, and it must not slow the application down.

A logging framework is the library that code calls to write lines like 12:00:01 WARN app.db.pool pool almost full. Developers read those lines to understand what the application did, especially when something went wrong.

Everyone has used a logger, so the parts sound familiar: levels, outputs, formats. The design question is how they fit together, and what each one costs. How does setting one package to DEBUG reach every class in it, without configuring each one? Why does a debug line that’s switched off still cost time? And what happens to lines waiting in memory when the process shuts down?

If you only have a minute, this is the design. The rest of the page explains how we get here.

  1. Each log call makes one record, and three separate parts handle it. The level decides whether to write it. A formatter decides how it looks. An appender decides where it goes (the console, a file). Each one changes without touching the others.
  2. Loggers form a tree by name. app.db.pool sits under app.db, which sits under app. Settings made on a branch apply to everything below it. So a few settings configure thousands of loggers.
  3. A switched-off call costs almost nothing. The message is built only after the level check says yes, using placeholders like "user {}", or a function that’s only called when needed.
  4. Writing happens on a background thread. Callers drop the record into a queue with a size limit, and one writer thread saves it. A slow disk no longer slows down requests.
  5. Nothing is lost on shutdown. Closing the logger waits until every queued record is written. When the queue is full, low-importance lines are dropped and counted, but warnings and errors wait.

We’ll build this in four stages, then deal with many threads and a slow disk.

Before drawing anything, we ask a few questions. Each answer changes a class. Anything that doesn’t, we assume and move on.

Interviewerminute 0

Design a logging framework.

What they are testing

Whether you ask about outputs, settings per package, and whether writes can block, in the first few minutes, since those shape the whole design. Starting with an enum of levels and a print is a one-class answer to a framework question.

Here are the questions that matter:

  • Where do logs go? The console and files now, others later. With one fixed output, there’s no need for an appender interface.
  • What format? Plain text for people, and JSON for tools that collect logs. With one format, the formatting can stay inside the output.
  • Are settings per class, or per package? Per package, and everything below inherits them. With per-class settings only, there’s no tree.
  • Can logging make a request wait? No. Writing should happen away from the request. If waiting were fine, stage 5’s background writer goes away.
  • Is it ever OK to lose a line? DEBUG and INFO lines, under heavy load. Never WARN or ERROR. If the answer were “never”, the background writer must make callers wait instead of dropping.

These answers give us one promise that matters more than the others: logging must never hurt the application. A switched-off call costs one comparison. A slow disk doesn’t block requests. A shutdown doesn’t lose what was already logged. Two smaller promises come with it: lines from different threads never mix, and time is passed in, so tests can check the exact output.

We leave out sending logs to a central store (a separate system), splitting files by size or day, and adding a request ID to every line (both are extensions).

Go deeperThree more questions, and what each changes1 min
Question We assume If the answer were different
Can levels change at runtime? Yes, to debug a live issue Fixed at startup lets the settings be plain fields
Request IDs in every line? An extension (context fields) Required now adds a per-thread or per-request context map
New file by size or day? An extension Required now adds a rolling file appender

We’ll build the design in four stages. Each stage adds only the classes that a new requirement needs. In each diagram, the new parts are outlined, and the colors show what each class does (the key is under every diagram).

Interviewerminute 4

Start with the simplest thing: a logger with levels.

What they are testing

Whether levels are ordered so a check is one comparison, and whether you name what’s fixed in place and will have to move. Spending time on the print format here is the warning sign.

Start with the smallest thing that works. There are four levels, from least to most serious: DEBUG, INFO, WARN, ERROR. Each logger has a threshold level. Because the levels are in order, “should I write this?” is one comparison: is the record’s level at or above the threshold? If so, the logger prints it with the time, the level and the logger’s name.

Stage 1: a logger and its levels

Logger

-name

-threshold: Level

+log(Level, message)

+debug(message)

+info(message)

«enum»

Level

DEBUG

INFO

WARN

ERROR

Logger

-name

-threshold: Level

+log(Level, message)

+debug(message)

+info(message)

«enum»

Level

DEBUG

INFO

WARN

ERROR

Scroll or pinch to zoom, drag to pan
  • Color key
  • Facade
  • Entity
  • Value object
  • Enum
  • Interface
  • Implementation
  • New in this step
Logger.javalogger.pyLogger.cslogger.gologger.ts
// Ordered from least to most severe, so "is this enabled?" is one comparison.
enum Level { DEBUG, INFO, WARN, ERROR }
final class Logger {
private final String name;
private Level threshold;
Logger(String name, Level threshold) {
this.name = name;
this.threshold = threshold;
}
void setLevel(Level level) { this.threshold = level; }
void log(Level level, String message) {
if (level.compareTo(threshold) < 0) return; // below the threshold: skip
// flaw 1: the destination (stdout) and the layout are fixed. Stage 2.
// flaw 2: every logger is configured on its own; no way to say "all of app.db". Stage 3.
System.out.println(Instant.now() + " " + level + " " + name + " - " + message);
}
// flaw 3: the caller builds the message string before we can say no. Stage 4.
void debug(String message) { log(Level.DEBUG, message); }
void info(String message) { log(Level.INFO, message); }
void warn(String message) { log(Level.WARN, message); }
void error(String message) { log(Level.ERROR, message); }
}

It works, but three things are wrong, and the next three stages fix one each.

  • The output is fixed. It always prints to the console, in one layout.
  • Each logger is set up on its own. Turning on DEBUG for the database code means finding every logger in it.
  • A switched-off call still costs time. log.debug("user " + user) builds the string before the logger can say no. So a debug line that’s off still joins strings and calls toString().
Go deeperWhat about TRACE, FATAL and OFF?1 min

Real frameworks add more levels at both ends. TRACE sits below DEBUG, for very detailed lines like “entered method”. FATAL sits above ERROR, for “the application is about to stop”. And OFF is a level above everything, so setting a logger to OFF switches it off completely.

None of them changes the design. Each level is a number in order, so adding one is adding a number between two others. The check stays one comparison. That’s why levels are stored as ordered numbers (an enum with a rank), not as a list of names to search.

Some teams also add custom levels, like AUDIT. Be careful: a custom level must still fit somewhere in the order, or “everything at WARN and above” stops having a clear meaning. Often a separate logger named audit is the better answer.

Stage 2: Records, formatters and appenders

Section titled “Stage 2: Records, formatters and appenders”
Interviewerminute 7

We need the console in plain text and a file in JSON, at the same time.

What they are testing

Whether “how it looks” and “where it goes” become two separate interfaces, and whether the event is captured once as a value. One class per combination (JsonFileLogger) is the answer that doesn’t scale.

We split one log call into three ideas:

  • A LogRecord captures the event once: the time, level, logger name, thread, message and error. It never changes after it’s made. So every output can share it, and later, another thread can read it safely.
  • A Formatter turns a record into text. TextFormatter writes a line for people. JsonFormatter writes one JSON object per line for log tools. It escapes quotes and line breaks, so a message can’t break one record into two.
  • An Appender sends a record somewhere, and has its own formatter. So the console can use text while the file uses JSON, from the same record.

A logger holds a list of appenders and gives the record to each. Formatters and appenders are both the Strategy pattern: one interface, and a class for each choice.

Stage 2: the record, its format and its destination

*

creates

Logger

-name

-threshold: Level

-clock

-appenders: List<Appender>

+log(Level, message, error)

+addAppender(Appender)

«value»

LogRecord

+time

+level

+logger

+thread

+message

+error

«interface»

Appender

+append(LogRecord)

+close()

ConsoleAppender

FileAppender

-file

«interface»

Formatter

+format(LogRecord) : String

TextFormatter

JsonFormatter

*

creates

Logger

-name

-threshold: Level

-clock

-appenders: List<Appender>

+log(Level, message, error)

+addAppender(Appender)

«value»

LogRecord

+time

+level

+logger

+thread

+message

+error

«interface»

Appender

+append(LogRecord)

+close()

ConsoleAppender

FileAppender

-file

«interface»

Formatter

+format(LogRecord) : String

TextFormatter

JsonFormatter

Scroll or pinch to zoom, drag to pan
  • Color key
  • Facade
  • Entity
  • Value object
  • Enum
  • Interface
  • Implementation
  • New in this step
JsonFormatter.javalogger.pyFileAppender.cslogger.gologger.ts
// One log event, captured once and handed to every appender. A value: appenders on other
// threads can read it without locks.
record LogRecord(Instant time, Level level, String logger, String thread, String message, Throwable error) {}
// Turns a record into text. Separate from where the text goes, so the same JSON layout can go
// to a file or a socket, and the console can stay human-readable.
interface Formatter {
String format(LogRecord r);
}
// 2026-01-01T09:30:00Z WARN app.db [main] - pool exhausted
final class TextFormatter implements Formatter {
public String format(LogRecord r) {
String line = r.time() + " " + r.level() + " " + r.logger() + " [" + r.thread() + "] - " + r.message();
if (r.error() == null) return line;
StringWriter trace = new StringWriter();
r.error().printStackTrace(new PrintWriter(trace));
return line + System.lineSeparator() + trace.toString().stripTrailing();
}
}
// One JSON object per line, for log shippers. Escaping matters: a message with a quote or a
// newline must not break the line into two records or into invalid JSON.
final class JsonFormatter implements Formatter {
public String format(LogRecord r) {
StringBuilder b = new StringBuilder("{");
field(b, "time", r.time().toString()).append(',');
field(b, "level", r.level().name()).append(',');
field(b, "logger", r.logger()).append(',');
field(b, "thread", r.thread()).append(',');
field(b, "message", r.message());
if (r.error() != null) field(b.append(','), "error", r.error().toString());
return b.append('}').toString();
}
private static StringBuilder field(StringBuilder b, String key, String value) {
b.append('"').append(key).append("\":\"");
for (char c : value.toCharArray()) {
switch (c) {
case '"' -> b.append("\\\"");
case '\\' -> b.append("\\\\");
case '\n' -> b.append("\\n");
case '\r' -> b.append("\\r");
case '\t' -> b.append("\\t");
default -> {
if (c < 0x20) b.append(String.format("\\u%04x", (int) c)); // other control characters
else b.append(c);
}
}
}
return b.append('"');
}
}
// Where formatted records go. Each appender owns its formatter: the console can be plain text
// while the file is JSON.
interface Appender {
void append(LogRecord r);
default void close() {} // flush and release files; the async appender needs it
}
final class ConsoleAppender implements Appender {
private final PrintStream out;
private final Formatter formatter;
ConsoleAppender(Formatter formatter) { this(System.out, formatter); }
ConsoleAppender(PrintStream out, Formatter formatter) { this.out = out; this.formatter = formatter; }
public void append(LogRecord r) { out.println(formatter.format(r)); }
}
final class FileAppender implements Appender {
private final BufferedWriter out;
private final Formatter formatter;
FileAppender(Path file, Formatter formatter) throws IOException {
// Append, never truncate: a restart must not wipe the last run's logs.
this.out = Files.newBufferedWriter(file, StandardOpenOption.CREATE, StandardOpenOption.APPEND);
this.formatter = formatter;
}
public void append(LogRecord r) {
try {
out.write(formatter.format(r));
out.newLine();
// Flush per record so a crash loses nothing that was logged. It costs a system call
// per line; the async appender takes that cost off the caller's thread.
out.flush();
} catch (IOException e) {
// Logging must never take the application down. Report on stderr and carry on.
System.err.println("FileAppender failed: " + e);
}
}
public void close() {
try { out.close(); } catch (IOException e) { System.err.println("FileAppender close failed: " + e); }
}
}
final class Logger {
private final String name;
private final Clock clock; // injected so tests get stable timestamps
private final List<Appender> appenders = new ArrayList<>();
private Level threshold;
Logger(String name, Level threshold, Clock clock) {
this.name = name;
this.threshold = threshold;
this.clock = clock;
}
void setLevel(Level level) { this.threshold = level; }
void addAppender(Appender a) { appenders.add(a); }
void log(Level level, String message, Throwable error) {
if (level.compareTo(threshold) < 0) return;
// Build the record once; every appender gets the same one.
LogRecord r = new LogRecord(clock.instant(), level, name, Thread.currentThread().getName(), message, error);
for (Appender a : appenders) a.append(r);
}
void debug(String message) { log(Level.DEBUG, message, null); }
void info(String message) { log(Level.INFO, message, null); }
void warn(String message) { log(Level.WARN, message, null); }
void error(String message, Throwable error) { log(Level.ERROR, message, error); }
}

Two details in the file appender matter. It opens the file to add to the end, never to replace it, so a restart keeps the previous run’s logs. And it catches its own errors, reporting them on the error output. A full disk must never turn every log.info into an exception that breaks a request.

What does the split buy? Any format with any output: three formats and four appenders are seven classes, not twelve. And a formatter is a plain function from record to text, so it’s tested with no files at all. The cost is small: two appenders with the same formatter format the record twice.

Go deeperWhy not give each appender a level too?1 min

Real frameworks do: the console shows WARN and up, while the file keeps everything. It’s one threshold check at the top of append, or a filter in front of the appender. We leave it out to keep the stages small. But it fits without changing anything else, which is the point of the split.

Interviewerminute 13

We have 400 classes. I want DEBUG for everything in app.db, and WARN everywhere else.

What they are testing

Whether you arrange loggers in a tree by name, with levels and appenders inherited, and whether you know additivity. A map from class name to level, checked with string prefixes on every call, is the answer they mark down.

Loggers are named after the code that uses them, like app.db.pool. The dots make a tree: app.db.pool is a child of app.db, which is a child of app, which is a child of the root logger. Code never builds a logger itself. It asks a LoggerRegistry for one by name. The registry creates any missing parents on the way, so the tree is always complete.

Two things are inherited down the tree:

  • The level. A logger’s level can be left unset, meaning “use my parent’s”. Its real level is the first one set on the way up. The root always has one.
  • The appenders. A record goes to the logger’s own appenders, then to its parent’s, and so on up to the root. Each logger handles the record and passes it up. This is the Chain of Responsibility pattern. A logger can be marked non-additive, which stops the record going further up. That flag is called additivity.

So the whole setup is a few lines: the root at WARN with a console appender, and app.db at DEBUG. All 400 loggers inherit the rest.

A logger tree, with levels and appenders set on two branches

root
WARN, console

app
(inherits WARN)

audit
INFO, audit file, non-additive

app.db
DEBUG

app.web
(inherits WARN)

app.db.pool
(inherits DEBUG)

root
WARN, console

app
(inherits WARN)

audit
INFO, audit file, non-additive

app.db
DEBUG

app.web
(inherits WARN)

app.db.pool
(inherits DEBUG)

Scroll or pinch to zoom, drag to pan
  • Color key
  • Database
Stage 3: the registry and the tree

1

*

parent

*

LoggerRegistry

-loggers: Map<String, Logger>

-root: Logger

+get(name) : Logger

+root() : Logger

Logger

-name

-parent: Logger

-level: Level or none

-additive

-appenders: List<Appender>

+effectiveLevel() : Level

+isEnabled(Level) : bool

+log(Level, message, error)

«interface»

Appender

+append(LogRecord)

1

*

parent

*

LoggerRegistry

-loggers: Map<String, Logger>

-root: Logger

+get(name) : Logger

+root() : Logger

Logger

-name

-parent: Logger

-level: Level or none

-additive

-appenders: List<Appender>

+effectiveLevel() : Level

+isEnabled(Level) : bool

+log(Level, message, error)

«interface»

Appender

+append(LogRecord)

Scroll or pinch to zoom, drag to pan
  • Color key
  • Facade
  • Entity
  • Value object
  • Enum
  • Interface
  • Implementation
  • New in this step
Logger.javalogger.pyLogger.cslogger.gologger.ts
// Loggers form a tree by name: "app.db.pool" is a child of "app.db", a child of "app", a child
// of the root. Configuration is set on a few branches and inherited by everything below.
final class Logger {
private final String name;
private final Logger parent; // null only for the root
private final Clock clock;
private final List<Appender> appenders = new ArrayList<>();
private Level level; // null means "inherit from my parent"
private boolean additive = true; // also send my records to my parent's appenders?
Logger(String name, Logger parent, Level level, Clock clock) {
this.name = name;
this.parent = parent;
this.level = level;
this.clock = clock;
}
void setLevel(Level level) { this.level = level; } // null goes back to inheriting
void setAdditive(boolean additive) { this.additive = additive; }
void addAppender(Appender a) { appenders.add(a); }
// The nearest level set on the way up. The root always has one, so this always ends.
Level effectiveLevel() {
for (Logger l = this; ; l = l.parent) {
if (l.level != null) return l.level;
}
}
boolean isEnabled(Level level) { return level.compareTo(effectiveLevel()) >= 0; }
void log(Level level, String message, Throwable error) {
// The level is checked once, here, against this logger's effective level. Parents'
// levels don't filter it again on the way up: setting "app" to WARN means app's own
// records, and those of children that don't override it.
if (!isEnabled(level)) return;
LogRecord r = new LogRecord(clock.instant(), level, name, Thread.currentThread().getName(), message, error);
// Chain of responsibility: my appenders, then my parent's, and so on to the root,
// until a logger marked non-additive stops the walk.
for (Logger l = this; l != null; l = l.parent) {
for (Appender a : l.appenders) a.append(r);
if (!l.additive) break;
}
}
void debug(String message) { log(Level.DEBUG, message, null); }
void info(String message) { log(Level.INFO, message, null); }
void warn(String message) { log(Level.WARN, message, null); }
void error(String message, Throwable error) { log(Level.ERROR, message, error); }
}
// Hands out one logger per name, creating the parents on the way. Code asks for a logger by
// name and never builds one, so the tree is always whole.
final class LoggerRegistry {
private final Map<String, Logger> loggers = new HashMap<>();
private final Logger root;
private final Clock clock;
LoggerRegistry(Clock clock) {
this.clock = clock;
this.root = new Logger("root", null, Level.INFO, clock); // the root always has a level
}
Logger root() { return root; }
Logger get(String name) {
Logger existing = loggers.get(name);
if (existing != null) return existing;
// Not computeIfAbsent: creating "app.db" first creates "app", which would modify the
// map from inside its own compute call.
int dot = name.lastIndexOf('.');
Logger parent = dot < 0 ? root : get(name.substring(0, dot));
Logger created = new Logger(name, parent, null, clock);
loggers.put(name, created);
return created;
}
}

Here is one record’s journey. app.db.pool logs a WARN. Its own level is unset, so it walks up and finds DEBUG on app.db. WARN is above DEBUG, so the record is made. It goes to app.db’s file appender, then up to the root’s console appender.

Sequence: app.db.pool logs a WARN
rootappapp.dbapp.db.poolCoderootappapp.dbapp.db.poolCodeWARN >= DEBUG: build one recordwarn("pool exhausted")effectiveLevel() walks up: DEBUG (from app.db)own appenders (none)pass up (additive)pass up (none of its own)pass upconsole.append(record)rootappapp.dbapp.db.poolCoderootappapp.dbapp.db.poolCodeWARN >= DEBUG: build one recordwarn("pool exhausted")effectiveLevel() walks up: DEBUG (from app.db)own appenders (none)pass up (additive)pass up (none of its own)pass upconsole.append(record)
Scroll or pinch to zoom, drag to pan
  • Color key
  • Database

Notice that the level is checked once, at the logger where the record starts. Parents’ levels don’t filter it again on the way up. That’s how Log4j and Logback behave, and it’s what makes “DEBUG for app.db” work. If the root’s WARN filtered again, app.db’s debug lines would never reach the root’s console.

What does the tree cost? Every call walks up the tree to find its real level, even calls that end up switched off. The walk is short, since trees are shallow. Logback avoids it by storing the real level in each logger, and pushing changes down to the children when a level is set. That makes the check one field read, but updates get more complex.

Go deeperWhy can't the registry use computeIfAbsent?1 min

Creating app.db first creates its parent app, which adds to the same map from inside the computeIfAbsent call. Java’s HashMap throws a ConcurrentModificationException for that, and ConcurrentHashMap can hang, or throw IllegalStateException: Recursive update. A plain get, then create the parent, then put, avoids it. Stage 5 wraps those steps in one lock.

Interviewerminute 20

Profiling shows 8% of CPU in debug logging, and debug is off in production.

What they are testing

Whether you see that the cost is where the call is made (building the message string), not inside the logger, and whether you offer both placeholders and a lazy function. “Remove the debug lines” is the answer they don’t want.

Look closely at log.debug("request " + request). The language joins the strings, and calls request.toString(), before debug even runs. Then the logger says no, and the work is thrown away. Three tools fix this, each for a different cost:

  • Placeholders. log.debug("user {} from {}", id, ip) passes the values as they are. The string is built only if the level is on. The values are still worked out, but passing them costs almost nothing.
  • A function. log.debug(() -> expensiveSummary()) passes a function instead of a string. When the level is off, the function is never called. Use it for messages that are costly to build.
  • isEnabled. if (log.isEnabled(DEBUG)) { ... } for setup that takes more than one line.

One convention, from the SLF4J library: if the last value after the placeholders is an error, it becomes the record’s error, not a value in the message.

Stage 4: lazy messages

Logger

+isEnabled(Level) : bool

+log(Level, template, args...)

+log(Level, Supplier)

+debug(template, args...)

+debug(Supplier)

-emit(Level, message, error)

-fill(template, args) : String

«interface»

Supplier

+get() : String

Logger

+isEnabled(Level) : bool

+log(Level, template, args...)

+log(Level, Supplier)

+debug(template, args...)

+debug(Supplier)

-emit(Level, message, error)

-fill(template, args) : String

«interface»

Supplier

+get() : String

Scroll or pinch to zoom, drag to pan
  • Color key
  • Facade
  • Entity
  • Value object
  • Enum
  • Interface
  • Implementation
  • New in this step
Logger.javalogger.pyLogger.cslogger.gologger.ts
// A named logger in the tree, with ways to say no before any work is done. A disabled debug call should
// cost one comparison: no string building, no toString() on big objects, no record.
final class Logger {
private final String name;
private final Logger parent;
private final Clock clock;
private final List<Appender> appenders = new ArrayList<>();
private Level level;
private boolean additive = true;
Logger(String name, Logger parent, Level level, Clock clock) {
this.name = name;
this.parent = parent;
this.level = level;
this.clock = clock;
}
void setLevel(Level level) { this.level = level; }
void setAdditive(boolean additive) { this.additive = additive; }
void addAppender(Appender a) { appenders.add(a); }
Level effectiveLevel() {
for (Logger l = this; ; l = l.parent) {
if (l.level != null) return l.level;
}
}
// For callers with expensive setup: if (log.isEnabled(DEBUG)) { ...compute... }
boolean isEnabled(Level level) { return level.compareTo(effectiveLevel()) >= 0; }
// Placeholders: log.info("user {} logged in from {}", id, ip). The args are already
// computed, but the string is only assembled if the level is enabled.
void log(Level level, String template, Object... args) {
if (!isEnabled(level)) return;
Throwable error = args.length > 0 && args[args.length - 1] instanceof Throwable t ? t : null;
emit(level, fill(template, args), error);
}
// A supplier: the message isn't even computed when disabled. For messages that call
// something costly, like serializing a request.
void log(Level level, Supplier<String> message) {
if (isEnabled(level)) emit(level, message.get(), null);
}
private void emit(Level level, String message, Throwable error) {
LogRecord r = new LogRecord(clock.instant(), level, name, Thread.currentThread().getName(), message, error);
for (Logger l = this; l != null; l = l.parent) {
for (Appender a : l.appenders) a.append(r);
if (!l.additive) break;
}
}
// Each "{}" takes the next argument; extra placeholders stay as "{}". A trailing
// Throwable is the record's error, not a placeholder value.
static String fill(String template, Object[] args) {
StringBuilder b = new StringBuilder();
int from = 0, next = 0;
for (int at; (at = template.indexOf("{}", from)) >= 0 && next < args.length; from = at + 2) {
if (args[next] instanceof Throwable && next == args.length - 1) break;
b.append(template, from, at).append(args[next++]);
}
return b.append(template, from, template.length()).toString();
}
void debug(String template, Object... args) { log(Level.DEBUG, template, args); }
void debug(Supplier<String> message) { log(Level.DEBUG, message); }
void info(String template, Object... args) { log(Level.INFO, template, args); }
void warn(String template, Object... args) { log(Level.WARN, template, args); }
void error(String template, Object... args) { log(Level.ERROR, template, args); }
}

Go uses its own style (Debugf with fmt verbs, plus DebugFunc), since {} placeholders would look out of place there. And the placeholder filler is deliberately not String.format or str.format. A message that happens to contain braces, like a piece of JSON, must be logged as written, not throw an error.

What does it cost? A method that takes “any number of values” makes a small array on every call, even when the level is off. SLF4J avoids that with fixed versions for one and two values, debug(String, Object) and debug(String, Object, Object).

This is enough for a mid-level answer. The record, formatter and appender are separate, and the logger tree inherits levels. The rest of the page is what makes it a senior answer.

Here is everything together. Each class is here because a requirement needed it.

Logging framework: full class diagram

1

*

parent

*

creates

wraps

LoggerRegistry

-loggers: Map<String, Logger>

-root: Logger

-clock

+get(name) : Logger

Logger

-name

-parent: Logger

-level: Level or none

-additive

-appenders: List<Appender>

+isEnabled(Level) : bool

+log(Level, template, args)

+log(Level, Supplier)

«enum»

Level

DEBUG

INFO

WARN

ERROR

«value»

LogRecord

+time

+level

+logger

+thread

+message

+error

«interface»

Appender

+append(LogRecord)

+close()

ConsoleAppender

FileAppender

-file

-lock

AsyncAppender

-inner: Appender

-queue

-overflow: Overflow

-worker

«enum»

Overflow

BLOCK

DROP_BELOW_WARN

«interface»

Formatter

+format(LogRecord) : String

TextFormatter

JsonFormatter

1

*

parent

*

creates

wraps

LoggerRegistry

-loggers: Map<String, Logger>

-root: Logger

-clock

+get(name) : Logger

Logger

-name

-parent: Logger

-level: Level or none

-additive

-appenders: List<Appender>

+isEnabled(Level) : bool

+log(Level, template, args)

+log(Level, Supplier)

«enum»

Level

DEBUG

INFO

WARN

ERROR

«value»

LogRecord

+time

+level

+logger

+thread

+message

+error

«interface»

Appender

+append(LogRecord)

+close()

ConsoleAppender

FileAppender

-file

-lock

AsyncAppender

-inner: Appender

-queue

-overflow: Overflow

-worker

«enum»

Overflow

BLOCK

DROP_BELOW_WARN

«interface»

Formatter

+format(LogRecord) : String

TextFormatter

JsonFormatter

Scroll or pinch to zoom, drag to pan
  • Color key
  • Facade
  • Entity
  • Value object
  • Enum
  • Interface
  • Implementation
logging-framework· read only
AllInOne.java
AllInOne.java
// The complete design, built from every stage on this page.
import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.atomic.*;
import java.util.function.*;
import java.time.*;
import java.io.*;
import java.nio.file.*;
// Ordered from least to most severe, so "is this enabled?" is one comparison.
enum Level { DEBUG, INFO, WARN, ERROR }
// The logger's configuration can change while other threads log (an operator turns on DEBUG
// for one package). volatile makes a level change visible to every thread at once, and a
// copy-on-write list lets threads walk the appenders while one is added. Both suit data
// that's read millions of times and written a handful.
final class Logger {
private final String name;
private final Logger parent;
private final Clock clock;
private final List<Appender> appenders = new CopyOnWriteArrayList<>();
private volatile Level level;
private volatile boolean additive = true;
Logger(String name, Logger parent, Level level, Clock clock) {
this.name = name;
this.parent = parent;
this.level = level;
this.clock = clock;
}
void setLevel(Level level) { this.level = level; }
void setAdditive(boolean additive) { this.additive = additive; }
void addAppender(Appender a) { appenders.add(a); }
// A logger with no level of its own uses its parent's, and so on up to the root, which
// always has one.
Level effectiveLevel() {
for (Logger l = this; ; l = l.parent) {
Level own = l.level; // read the volatile once
if (own != null) return own;
}
}
boolean isEnabled(Level level) { return level.compareTo(effectiveLevel()) >= 0; }
// The template form. The "{}" placeholders are filled only if the level is on. A Throwable
// as the last argument is the error, not a value to fill in.
void log(Level level, String template, Object... args) {
if (!isEnabled(level)) return;
Throwable error = args.length > 0 && args[args.length - 1] instanceof Throwable t ? t : null;
emit(level, fill(template, args), error);
}
// The lazy form: the message is built only if the level is on.
void log(Level level, Supplier<String> message) {
if (isEnabled(level)) emit(level, message.get(), null);
}
// One record, given to this logger's appenders, then to each parent's, until a logger that
// isn't additive stops it.
private void emit(Level level, String message, Throwable error) {
LogRecord r = new LogRecord(clock.instant(), level, name, Thread.currentThread().getName(), message, error);
for (Logger l = this; l != null; l = l.parent) {
for (Appender a : l.appenders) a.append(r);
if (!l.additive) break;
}
}
// Replaces each "{}" with the next argument, left to right. Placeholders with no argument
// left stay as they are.
static String fill(String template, Object[] args) {
StringBuilder b = new StringBuilder();
int from = 0, next = 0;
for (int at; (at = template.indexOf("{}", from)) >= 0 && next < args.length; from = at + 2) {
if (args[next] instanceof Throwable && next == args.length - 1) break;
b.append(template, from, at).append(args[next++]);
}
return b.append(template, from, template.length()).toString();
}
void debug(String template, Object... args) { log(Level.DEBUG, template, args); }
void debug(Supplier<String> message) { log(Level.DEBUG, message); }
void info(String template, Object... args) { log(Level.INFO, template, args); }
void warn(String template, Object... args) { log(Level.WARN, template, args); }
void error(String template, Object... args) { log(Level.ERROR, template, args); }
}
// One log event, captured once and handed to every appender. A value: appenders on other
// threads can read it without locks.
record LogRecord(Instant time, Level level, String logger, String thread, String message, Throwable error) {}
// Turns a record into text. Separate from where the text goes, so the same JSON layout can go
// to a file or a socket, and the console can stay human-readable.
interface Formatter {
String format(LogRecord r);
}
// 2026-01-01T09:30:00Z WARN app.db [main] - pool exhausted
final class TextFormatter implements Formatter {
public String format(LogRecord r) {
String line = r.time() + " " + r.level() + " " + r.logger() + " [" + r.thread() + "] - " + r.message();
if (r.error() == null) return line;
StringWriter trace = new StringWriter();
r.error().printStackTrace(new PrintWriter(trace));
return line + System.lineSeparator() + trace.toString().stripTrailing();
}
}
// One JSON object per line, for log shippers. Escaping matters: a message with a quote or a
// newline must not break the line into two records or into invalid JSON.
final class JsonFormatter implements Formatter {
public String format(LogRecord r) {
StringBuilder b = new StringBuilder("{");
field(b, "time", r.time().toString()).append(',');
field(b, "level", r.level().name()).append(',');
field(b, "logger", r.logger()).append(',');
field(b, "thread", r.thread()).append(',');
field(b, "message", r.message());
if (r.error() != null) field(b.append(','), "error", r.error().toString());
return b.append('}').toString();
}
private static StringBuilder field(StringBuilder b, String key, String value) {
b.append('"').append(key).append("\":\"");
for (char c : value.toCharArray()) {
switch (c) {
case '"' -> b.append("\\\"");
case '\\' -> b.append("\\\\");
case '\n' -> b.append("\\n");
case '\r' -> b.append("\\r");
case '\t' -> b.append("\\t");
default -> {
if (c < 0x20) b.append(String.format("\\u%04x", (int) c)); // other control characters
else b.append(c);
}
}
}
return b.append('"');
}
}
// Where formatted records go. Each appender owns its formatter: the console can be plain text
// while the file is JSON.
interface Appender {
void append(LogRecord r);
default void close() {} // flush and release files; the async appender needs it
}
// Appenders called directly from many threads lock around the write, so two records' lines
// never interleave in the file. (Not needed behind AsyncAppender, but cheap when uncontended.)
final class FileAppender implements Appender {
private final BufferedWriter out;
private final Formatter formatter;
FileAppender(Path file, Formatter formatter) throws IOException {
this.out = Files.newBufferedWriter(file, StandardOpenOption.CREATE, StandardOpenOption.APPEND);
this.formatter = formatter;
}
public void append(LogRecord r) {
String text = formatter.format(r); // format outside the lock: it's the slow part
synchronized (this) {
try {
out.write(text);
out.newLine();
out.flush();
} catch (IOException e) {
System.err.println("FileAppender failed: " + e);
}
}
}
public synchronized void close() {
try { out.close(); } catch (IOException e) { System.err.println("FileAppender close failed: " + e); }
}
}
// Loggers are usually fetched once per class, into a static field, so a synchronized get
// costs nothing that matters, and it keeps "create the parents first" one atomic step.
final class LoggerRegistry {
private final Map<String, Logger> loggers = new HashMap<>();
private final Logger root;
private final Clock clock;
LoggerRegistry(Clock clock) {
this.clock = clock;
this.root = new Logger("root", null, Level.INFO, clock);
}
Logger root() { return root; }
synchronized Logger get(String name) { // re-entrant, so the recursive call is fine
Logger existing = loggers.get(name);
if (existing != null) return existing;
int dot = name.lastIndexOf('.');
Logger parent = dot < 0 ? root : get(name.substring(0, dot));
Logger created = new Logger(name, parent, null, clock);
loggers.put(name, created);
return created;
}
}
// What a full queue does with one more record.
enum Overflow {
BLOCK, // the caller waits: nothing is lost, but a slow disk slows the app
DROP_BELOW_WARN // DEBUG and INFO are dropped (and counted); WARN and ERROR still wait
}
// A decorator: wraps any appender and moves its work onto one background thread. Callers only
// pay for putting the record in a queue, and the wrapped appender is only ever called from
// that one thread, so it needs no lock of its own.
final class AsyncAppender implements Appender {
// A record that means "stop": the worker sees it after everything queued before it.
private static final LogRecord STOP = new LogRecord(Instant.EPOCH, Level.ERROR, "", "", "", null);
private final Appender inner;
private final Overflow overflow;
private final BlockingQueue<LogRecord> queue;
private final AtomicLong dropped = new AtomicLong();
private final Thread worker;
private volatile boolean closed;
AsyncAppender(Appender inner, int capacity, Overflow overflow) {
this.inner = inner;
this.overflow = overflow;
// Bounded: if the disk stalls, memory doesn't grow until the process dies.
this.queue = new ArrayBlockingQueue<>(capacity);
this.worker = new Thread(this::drain, "log-writer");
this.worker.setDaemon(true); // never keeps the process alive on its own
this.worker.start();
}
// Called on the caller's thread. Usually it just drops the record in the queue; only a full
// queue makes the Overflow rule matter.
public void append(LogRecord r) {
if (closed || queue.offer(r)) return; // the fast path: there was room
if (overflow == Overflow.DROP_BELOW_WARN && r.level().compareTo(Level.WARN) < 0) {
dropped.incrementAndGet(); // counted, so it can be reported as "N records dropped"
return;
}
try {
// Wait for room, but give up if the appender closes meanwhile; a plain put()
// could block forever once the worker has stopped.
while (!closed && !queue.offer(r, 100, TimeUnit.MILLISECONDS)) { }
} catch (InterruptedException e) {
Thread.currentThread().interrupt(); // keep the caller's interrupt; drop this record
}
}
// The worker: writes records in order until STOP, then closes the wrapped appender.
private void drain() {
try {
for (LogRecord r = queue.take(); r != STOP; r = queue.take()) {
try {
inner.append(r);
} catch (RuntimeException e) {
System.err.println("appender failed: " + e); // one bad record must not kill the worker
}
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
inner.close();
}
}
long dropped() { return dropped.get(); }
// Flush on shutdown: everything queued before close() is written before it returns.
// Call it from a shutdown hook, or the last seconds of logs before a crash are lost.
public void close() {
if (closed) return;
closed = true;
try {
queue.put(STOP); // waits for room, which the worker is making
worker.join();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
Read onlyJava 21Every file builds in CI12 files283 lines
all_in_one.py
all_in_one.py
Read onlyPython 3.12Every file builds in CI13 files256 lines
AllInOne.cs
AllInOne.cs
Read onlyC# · .NET 10Every file builds in CI12 files257 lines
all_in_one.go
all_in_one.go
Read onlyGo 1.24Every file builds in CI12 files267 lines
all-in-one.ts
all-in-one.ts
Read onlyTypeScript, strictEvery file builds in CI11 files188 lines

Three ideas hold it together:

  • Whether, how and where are separate. The logger decides whether to write (level) and passes the record up the tree (Chain of Responsibility). The formatter decides how it looks, and the appender where it lands (both Strategy). Each one changes alone.
  • Settings are made on branches and inherited. A few settings configure thousands of loggers, and code never configures its own. A branch standing for everything below it is the Composite pattern.
  • Speed is a wrapper. The background writer in the next section wraps any appender (a Decorator), so the file and console appenders stay simple. And code just asks the registry for a logger by name, never seeing the tree being built (a facade).

The design works for one thread. But every request thread in a server logs. And the file might be on a network disk that sometimes stops for a second.

Interviewerminute 26

Every request thread logs. The file is on a network disk that sometimes stalls for a second.

What they are testing

Whether you move writes off the request thread with a queue that has a size limit, say what happens when it’s full, and write out everything on shutdown. A queue with no limit is the warning sign: it turns a slow disk into an out-of-memory crash.

Three problems appear with many threads:

  • Lines mix. Two threads writing to one file at the same moment can produce half of one line followed by half of another.
  • Writes block requests. If the appender writes right away, every request waits for the disk. When the disk stalls, the whole service stalls.
  • Settings change while threads log. An operator turns on DEBUG while thousands of threads are reading the level.

The clearly correct fix is a lock per appender. Each appender writes under its own lock, so lines never mix. But the request thread still waits for the disk, and a stalled disk still stalls every request.

The better fix is a background writer. We wrap the appender in an AsyncAppender. Its append only puts the record into a queue with a size limit, and returns at once. One background thread takes records off the queue and passes them to the real appender. Since the real appender only ever runs on that one thread, it doesn’t need a lock, and lines can’t mix.

Option How The request thread waits when
1. Lock per appender Each appender writes under its own lock Always, for the disk
2. Background writer (our choice) A queue with a size limit; one background thread writes Only when the queue is full
3. Queue with no limit The same, but the queue never fills Never, until memory runs out
4. Lock-free ring (Log4j 2) A fixed circular buffer and counters (LMAX Disruptor) Only when the ring is full

What happens when the queue is full? That’s the overflow policy. BLOCK makes the caller wait: nothing is lost, but a stalled disk slows requests. DROP_BELOW_WARN drops DEBUG and INFO records and counts them, while WARN and ERROR still wait. We pick the second, matching the answer from scoping: losing a debug line is acceptable, losing an error is not.

And shutdown? close puts a special stop marker at the end of the queue, and waits for the writer thread to reach it. The queue is first in, first out. So everything logged before close is written before close returns.

Logger.javaasync_appender.pyAsyncAppender.csasync_appender.goasync-file-appender.ts
// What a full queue does with one more record.
enum Overflow {
BLOCK, // the caller waits: nothing is lost, but a slow disk slows the app
DROP_BELOW_WARN // DEBUG and INFO are dropped (and counted); WARN and ERROR still wait
}
// A decorator: wraps any appender and moves its work onto one background thread. Callers only
// pay for putting the record in a queue, and the wrapped appender is only ever called from
// that one thread, so it needs no lock of its own.
final class AsyncAppender implements Appender {
// A record that means "stop": the worker sees it after everything queued before it.
private static final LogRecord STOP = new LogRecord(Instant.EPOCH, Level.ERROR, "", "", "", null);
private final Appender inner;
private final Overflow overflow;
private final BlockingQueue<LogRecord> queue;
private final AtomicLong dropped = new AtomicLong();
private final Thread worker;
private volatile boolean closed;
AsyncAppender(Appender inner, int capacity, Overflow overflow) {
this.inner = inner;
this.overflow = overflow;
// Bounded: if the disk stalls, memory doesn't grow until the process dies.
this.queue = new ArrayBlockingQueue<>(capacity);
this.worker = new Thread(this::drain, "log-writer");
this.worker.setDaemon(true); // never keeps the process alive on its own
this.worker.start();
}
// Called on the caller's thread. Usually it just drops the record in the queue; only a full
// queue makes the Overflow rule matter.
public void append(LogRecord r) {
if (closed || queue.offer(r)) return; // the fast path: there was room
if (overflow == Overflow.DROP_BELOW_WARN && r.level().compareTo(Level.WARN) < 0) {
dropped.incrementAndGet(); // counted, so it can be reported as "N records dropped"
return;
}
try {
// Wait for room, but give up if the appender closes meanwhile; a plain put()
// could block forever once the worker has stopped.
while (!closed && !queue.offer(r, 100, TimeUnit.MILLISECONDS)) { }
} catch (InterruptedException e) {
Thread.currentThread().interrupt(); // keep the caller's interrupt; drop this record
}
}
// The worker: writes records in order until STOP, then closes the wrapped appender.
private void drain() {
try {
for (LogRecord r = queue.take(); r != STOP; r = queue.take()) {
try {
inner.append(r);
} catch (RuntimeException e) {
System.err.println("appender failed: " + e); // one bad record must not kill the worker
}
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
inner.close();
}
}
long dropped() { return dropped.get(); }
// Flush on shutdown: everything queued before close() is written before it returns.
// Call it from a shutdown hook, or the last seconds of logs before a crash are lost.
public void close() {
if (closed) return;
closed = true;
try {
queue.put(STOP); // waits for room, which the worker is making
worker.join();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
// Appenders called directly from many threads lock around the write, so two records' lines
// never interleave in the file. (Not needed behind AsyncAppender, but cheap when uncontended.)
final class FileAppender implements Appender {
private final BufferedWriter out;
private final Formatter formatter;
FileAppender(Path file, Formatter formatter) throws IOException {
this.out = Files.newBufferedWriter(file, StandardOpenOption.CREATE, StandardOpenOption.APPEND);
this.formatter = formatter;
}
public void append(LogRecord r) {
String text = formatter.format(r); // format outside the lock: it's the slow part
synchronized (this) {
try {
out.write(text);
out.newLine();
out.flush();
} catch (IOException e) {
System.err.println("FileAppender failed: " + e);
}
}
}
public synchronized void close() {
try { out.close(); } catch (IOException e) { System.err.println("FileAppender close failed: " + e); }
}
}
// The logger's configuration can change while other threads log (an operator turns on DEBUG
// for one package). volatile makes a level change visible to every thread at once, and a
// copy-on-write list lets threads walk the appenders while one is added. Both suit data
// that's read millions of times and written a handful.
final class Logger {
private final String name;
private final Logger parent;
private final Clock clock;
private final List<Appender> appenders = new CopyOnWriteArrayList<>();
private volatile Level level;
private volatile boolean additive = true;
Logger(String name, Logger parent, Level level, Clock clock) {
this.name = name;
this.parent = parent;
this.level = level;
this.clock = clock;
}
void setLevel(Level level) { this.level = level; }
void setAdditive(boolean additive) { this.additive = additive; }
void addAppender(Appender a) { appenders.add(a); }
// A logger with no level of its own uses its parent's, and so on up to the root, which
// always has one.
Level effectiveLevel() {
for (Logger l = this; ; l = l.parent) {
Level own = l.level; // read the volatile once
if (own != null) return own;
}
}
boolean isEnabled(Level level) { return level.compareTo(effectiveLevel()) >= 0; }
// The template form. The "{}" placeholders are filled only if the level is on. A Throwable
// as the last argument is the error, not a value to fill in.
void log(Level level, String template, Object... args) {
if (!isEnabled(level)) return;
Throwable error = args.length > 0 && args[args.length - 1] instanceof Throwable t ? t : null;
emit(level, fill(template, args), error);
}
// The lazy form: the message is built only if the level is on.
void log(Level level, Supplier<String> message) {
if (isEnabled(level)) emit(level, message.get(), null);
}
// One record, given to this logger's appenders, then to each parent's, until a logger that
// isn't additive stops it.
private void emit(Level level, String message, Throwable error) {
LogRecord r = new LogRecord(clock.instant(), level, name, Thread.currentThread().getName(), message, error);
for (Logger l = this; l != null; l = l.parent) {
for (Appender a : l.appenders) a.append(r);
if (!l.additive) break;
}
}
// Replaces each "{}" with the next argument, left to right. Placeholders with no argument
// left stay as they are.
static String fill(String template, Object[] args) {
StringBuilder b = new StringBuilder();
int from = 0, next = 0;
for (int at; (at = template.indexOf("{}", from)) >= 0 && next < args.length; from = at + 2) {
if (args[next] instanceof Throwable && next == args.length - 1) break;
b.append(template, from, at).append(args[next++]);
}
return b.append(template, from, template.length()).toString();
}
void debug(String template, Object... args) { log(Level.DEBUG, template, args); }
void debug(Supplier<String> message) { log(Level.DEBUG, message); }
void info(String template, Object... args) { log(Level.INFO, template, args); }
void warn(String template, Object... args) { log(Level.WARN, template, args); }
void error(String template, Object... args) { log(Level.ERROR, template, args); }
}
// Loggers are usually fetched once per class, into a static field, so a synchronized get
// costs nothing that matters, and it keeps "create the parents first" one atomic step.
final class LoggerRegistry {
private final Map<String, Logger> loggers = new HashMap<>();
private final Logger root;
private final Clock clock;
LoggerRegistry(Clock clock) {
this.clock = clock;
this.root = new Logger("root", null, Level.INFO, clock);
}
Logger root() { return root; }
synchronized Logger get(String name) { // re-entrant, so the recursive call is fine
Logger existing = loggers.get(name);
if (existing != null) return existing;
int dot = name.lastIndexOf('.');
Logger parent = dot < 0 ? root : get(name.substring(0, dot));
Logger created = new Logger(name, parent, null, clock);
loggers.put(name, created);
return created;
}
}

Why is this correct? The queue is the only thing callers and the writer share, and it’s built for many threads. The record never changes after it’s made, so the writer reads it without a lock. The stop marker sits behind every record logged before close, so the writer saves them all before it stops. A caller waiting on a full queue checks a “closed” flag while it waits, so it can’t wait forever after the writer has gone.

What does it cost? When log returns, the line isn’t on disk yet. A hard crash (kill -9, a power cut) loses whatever was in the queue. That’s exactly the last few lines before the crash, often the ones you wanted most. Some teams keep ERROR lines written right away for that reason.

Go deeperSettings that change while threads log1 min

A logger’s settings are read on every call, and changed rarely. So Java and Go store the level and the additive flag in fields that every thread sees right away (atomic or volatile fields).

The appender list needs more care. If an operator adds an appender while a thread is walking the list, the thread could see it half changed. So we never change the list in place. We build a new list with the change, and swap it in with one assignment. A thread already walking the old list finishes with the old list. This is called copy on write.

Appenders used without the background writer take their own lock around the write. They format the record before taking the lock, since formatting is the slow part.

Go deeperHow each language builds the background writer1 min

Java uses a BlockingQueue with a size limit and one thread. Python uses queue.Queue and a thread. C# uses a Channel with a size limit. Go uses a channel with a buffer, plus a read lock that stops Append from sending on a channel that’s already closed.

TypeScript in Node has one thread, so lines never mix. Its stage 5 groups writes together instead: one disk write per turn of the event loop. Past the limit it drops DEBUG and INFO, since Node can’t make one caller wait without blocking everything.

Go deeperWhy one writer thread, not a pool?1 min

A pool of threads would write records out of order, and the file appender would need its lock back. One writer keeps the order and needs no lock. And disk writes happen one after another anyway, so more threads wouldn’t make the disk faster. If one thread can’t keep up, group several records into one write, which is what the TypeScript stage 5 does.

Each test protects one promise:

  • A switched-off record is skipped, and its function is never called.
  • A child inherits its parent’s level and sends records to its parent’s appenders.
  • A non-additive logger keeps its records to itself.
  • JSON output stays one valid line, even with a quote and a line break in the message.
  • The background writer loses nothing under 8 threads with a tiny queue, and close returns only after every record is written.

The tests use a list appender (records go into a list in memory) and a clock that never moves, so each line can be compared exactly. The background writer test uses a queue of just 16 records, so callers really do hit a full queue. Then it checks that all 8,000 different lines arrived.

Go deeperThe tests in five languages2 min
ListAppender.javalist_appender.pyListAppender.cslist_appender.golist-appender.ts
// An appender that keeps formatted lines in memory, for assertions.
static final class ListAppender implements Appender {
final List<String> lines = Collections.synchronizedList(new ArrayList<>());
private final Formatter formatter;
ListAppender(Formatter formatter) { this.formatter = formatter; }
public void append(LogRecord r) { lines.add(formatter.format(r)); }
}
// A clock that never moves, so every timestamp in a test is the same known value.
final Clock clock = Clock.fixed(Instant.parse("2026-01-01T09:30:00Z"), ZoneOffset.UTC);
final LoggerRegistry registry = new LoggerRegistry(clock);
final ListAppender out = new ListAppender(new TextFormatter());
{ registry.root().addAppender(out); }
@Test
void disabledRecordsAreSkippedAndTheirMessagesNeverBuilt() {
int[] built = {0};
registry.get("app.db").debug(() -> { built[0]++; return "expensive"; }); // root is INFO
assertEquals(0, built[0]);
assertTrue(out.lines.isEmpty());
}
@Test
void childrenInheritTheirParentsLevelAndAppenders() {
registry.get("app").setLevel(Level.WARN);
Logger db = registry.get("app.db"); // no level, no appenders of its own
db.info("connected"); // app says WARN: skipped
db.warn("pool {} of {} in use", 10, 10);
assertEquals(List.of("2026-01-01T09:30:00Z WARN app.db [main] - pool 10 of 10 in use"), out.lines);
}
@Test
void aNonAdditiveLoggerKeepsItsRecordsToItself() {
Logger audit = registry.get("audit");
ListAppender auditFile = new ListAppender(new JsonFormatter());
audit.addAppender(auditFile);
audit.setAdditive(false);
audit.info("user \"ana\" signed in\nfrom 10.0.0.1");
assertTrue(out.lines.isEmpty()); // never reached the root's console
// One line of valid JSON, even with a quote and a newline in the message.
assertEquals(List.of("{\"time\":\"2026-01-01T09:30:00Z\",\"level\":\"INFO\",\"logger\":\"audit\",\"thread\":\"main\","
+ "\"message\":\"user \\\"ana\\\" signed in\\nfrom 10.0.0.1\"}"), auditFile.lines);
}
@Test
void anAsyncAppenderLosesNothingAndFlushesOnClose() throws Exception {
// A tiny queue, so callers really do wait on a full queue, and 8 threads x 1,000 records.
ListAppender sink = new ListAppender(new TextFormatter());
AsyncAppender async = new AsyncAppender(sink, 16, Overflow.BLOCK);
Logger log = registry.get("load");
log.setAdditive(false);
log.addAppender(async);
ExecutorService pool = Executors.newFixedThreadPool(8);
for (int t = 0; t < 8; t++) {
int id = t;
pool.submit(() -> { for (int i = 0; i < 1_000; i++) log.info("t{} n{}", id, i); });
}
pool.shutdown();
pool.awaitTermination(30, TimeUnit.SECONDS);
async.close(); // returns only after the queue is drained
assertEquals(8_000, sink.lines.size());
assertEquals(8_000L, sink.lines.stream().distinct().count());
}
Go deeperEvery edge case, and where it is handled2 min
Case What happens Where
Message contains a quote or line break Escaped in JSON; one line per record JsonFormatter
Message contains {} but no values Left as written fill
More values than placeholders Extras ignored; a last error becomes the record’s error fill, log
Appender throws (disk full) Reported on the error output; the application never sees it appenders, background writer
A logger asked for before its parent Parents created on the way LoggerRegistry.get
Same appender on a logger and its parent Line written twice setup (additivity)
Logging during close Dropped; close won’t wait forever for it AsyncAppender.append
close called twice The second call does nothing AsyncAppender.close
Logging from inside an appender Can loop; frameworks guard with a per-thread “in logging” flag extension
Decision We chose We gave up
Event One unchanging LogRecord per call One object made per enabled call
Format and output Formatter and Appender interfaces Formatting once per appender
Settings A logger tree; level and appenders inherited A walk up the tree per call (or a cache to update)
Passing records up Chain of Responsibility with additivity The duplicate-line trap
Switched-off calls Placeholders, functions and isEnabled Small arrays, unless fixed versions exist
Slow disks A background writer with a size limit, one thread Lines in memory are lost on a hard crash
Full queue Drop DEBUG and INFO and count them; WARN and ERROR wait Some low-importance lines under heavy load
Shutdown close empties the queue behind a stop marker A shutdown hook must be registered
Go deeperGenuinely different designs, and when to pick them2 min

Log to the console, and let the platform handle the rest. In containers, a common approach is to write JSON lines to standard output and let the platform (Docker, Kubernetes, or a helper like Fluent Bit) handle files, rotation and shipping. The framework shrinks to levels, formatting and context, and the appender tree mostly disappears.

Garbage-free logging (Log4j 2). Reuse record objects and string builders from a ring, so a log call creates no new objects. It matters in systems where a garbage collection pause costs more than the log line is worth.

Structured events instead of sentences. Log event=payment_failed amount=42 reason=declined as fields, not a sentence. Searches and dashboards work on fields, and the message template becomes a label. OpenTelemetry’s log model goes this way, linking each record to the request trace it belongs to.

  • 0 to 3. Outputs, formats, settings by package, background writing, can lines be lost.
  • 3 to 5. The promises. Name “logging must never hurt the application” as the hard one.
  • 5 to 25. The level check, then record, formatter and appender, then the logger tree with additivity (code it fully), then placeholders and functions.
  • 25 to 27. Walk through the full diagram: whether, how and where, set on branches.
  • 27 to 36. Mixed lines and slow disks, the background writer, the overflow policy, flushing on close.
  • 36 to 45. One extension, usually request IDs or rotation, then your own questions.

If time is short, describe the JSON formatter in a sentence. Always code the logger tree and the walk up the parents. That’s the part that gets checked line by line.

Interviewerminute 37

Every line from one HTTP request should carry its request ID, without passing it to every method.

What they are testing

Whether you know the context map idea (often called MDC), where it lives (per thread, or per async flow), and that the background writer needs it copied into the record, since the writer thread has a different context.

Most extensions fit into one place:

Extension What changes Touches the logger?
Request IDs (context map) A per-thread map, copied into each LogRecord when it’s made One field
File rotation A RollingFileAppender: a new file by size or by day, old ones renamed No
Levels per appender A threshold check in the appender, or a filter in front of it No
Settings from a file A loader that reads levels and appenders and calls the same setters No
Sampling A filter that passes 1 in N DEBUG records No
Structured fields log.info("paid", kv("amount", 42)); the record carries fields, JSON writes them The record
Go deeperAdding request IDs, step by step2 min

We want every line written while handling one request to include that request’s ID, like req=7f3a. Passing the ID into every method that logs would touch hundreds of methods. Instead, we keep it somewhere the logger can find it.

  1. A context map per thread. It’s a small map of extra fields, like {req: "7f3a"}. Each thread has its own, so two requests running at once don’t see each other’s ID. In Node and .NET, where one request can jump between threads, it’s kept per async flow instead (AsyncLocalStorage, AsyncLocal).
  2. Set it at the start of the request, clear it at the end. A small piece of code at the front of the web layer puts the ID in, and takes it out when the request is done. Forgetting to clear it is a real bug: threads are reused, so the next request would log the old ID.
  3. Copy it into the record. When a LogRecord is made, it takes a copy of the current map. This is the important step. With the background writer, the record is formatted later, on the writer thread, which has its own empty context. If the formatter read the context then, it would find nothing, or worse, another request’s ID.
  4. The formatter writes the fields. The text formatter adds req=7f3a to the line, and the JSON formatter adds a "req" field.

Only the record changed (one new field). The levels, the tree and the appenders didn’t notice.

Go deeperAdding file rotation, step by step2 min

A log file that only grows will fill the disk sooner or later. Rotation means starting a new file now and then, and keeping only a few old ones. It’s a new appender, RollingFileAppender. No other class changes.

It has two questions to answer, and each is a small interface of its own:

  1. When do we start a new file? That’s the trigger. A size trigger says “when the file passes 100 MB”. A time trigger says “at midnight”. The appender asks the trigger before each write: “should I roll now?”
  2. What happens to the old files? That’s the policy. A common one renames app.log to app.2026-10-29.log, compresses it, and deletes files older than 14 days.

Rolling itself is four steps, done inside the appender: close the current file, rename it, open a new empty app.log, then let the policy clean up older files.

Three details make it safe:

  • Only one thread rolls. With the background writer, the real appender runs on one thread, so rolling needs no extra lock. Without it, rolling happens under the appender’s own lock, so no thread writes to a file that’s being renamed.
  • Clean up away from the write. Compressing an old file can take seconds. Doing it on the writer thread would let the queue fill up. So the clean-up runs on its own small thread.
  • Time comes from the clock we pass in. Then a test can move the clock past midnight and check that a new file was started, without waiting a day.

Notice what stayed the same. The formatter doesn’t know files rotate. The logger tree doesn’t know either. The new appender just goes where the old file appender was.

Go deeperThree more follow-up questions2 min
Interviewer asksHow would you change a level in production without a restart?

Make the registry’s setLevel available through an admin page or a watched settings file. Because the real level is worked out from the tree (or pushed down on change), setting app.db to DEBUG takes effect on the next call in every thread. Add a time limit, so a forgotten DEBUG setting goes back after an hour, since debug logging in production is expensive.

Interviewer asksA log line contains a user's password. How do you stop that?

Stop it at the source with types: a Secret wrapper whose toString returns ***. Then add a masking step in the formatter for known patterns (card numbers, tokens) as a safety net. Never rely on masking alone: patterns miss things, and checking every line costs CPU.

Interviewer asksWhat was the Log4Shell vulnerability, in design terms?

Log4j 2 looked up ${...} expressions inside logged messages, including lookups that could load code from another server. So logging user input (a header, a username) ran lookups the attacker chose. The design lesson: a logger must treat the message as data and never interpret it. Our placeholder filler only replaces {} in the template, never anything inside the values.