Why a logging library is the perfect LLD problem
A logging library looks deceptively simple — “just write strings to a file” — but it is one of the highest-signal machine-coding prompts because every constraint you add pulls in a different pattern. Levels pull in filtering. Multiple destinations pull in Strategy. Pluggable output formats pull in Decorator. A single global access point pulls in Singleton / a registry. Throughput and “don’t block the request thread” pull in async + a bounded queue + thread-safety. The interviewer is watching whether you can layer these without turning the design into mud.
This lesson designs the library the way you should in a 45-minute interview: clarify, model the core entities, draw the UML, nail the method signatures, then stress-test with edge cases and extensions. The reference implementation is log4j / SLF4J-shaped because that vocabulary (logger, level, appender, layout) is what your interviewer already has in their head.
In an interview, say up front: “I’ll design this like log4j — a
Loggerfacade, pluggableAppendersinks, andFormatterlayouts — because that separation is what makes it extensible. Let me confirm requirements first.” This signals you know the domain and won’t reinvent vocabulary.
Step 1 — Clarify requirements
Spend the first 3-4 minutes here. Logging has a huge surface; you score points by narrowing it deliberately rather than building everything.
Functional (the ones that matter):
- Log levels with ordering:
TRACE < DEBUG < INFO < WARN < ERROR < FATAL. A logger has a threshold; messages below it are dropped cheaply. - Multiple destinations (appenders/sinks): console, file (later: rolling file, network, DB). One log call can fan out to several.
- Pluggable formatting:
2026-06-14T10:00:00Z [INFO] [thread-1] OrderService - placed order 42. Format must be swappable without touching the logger. - Configurability: set level, appenders, and format at startup (config object or file), not hardcoded.
- Hierarchy (optional, call it out):
com.shop.OrderServiceinherits config fromcom.shopfrom root. This is what makes log4j log4j; I’ll mention it but keep the core flat unless asked.
Non-functional (the ones that decide your grade):
- Thread-safety: many threads log concurrently into shared appenders. Interleaved/garbled lines are a bug.
- Performance / non-blocking: an
ERRORlog must not stall a request thread on slow disk I/O. Async with a bounded queue. - Reliability on shutdown: async means buffered records — you must flush before exit or lose logs.
- Extensibility: adding a
KafkaAppenderor a JSON format should be a new class, not an edit to existing ones (Open/Closed).
Questions to ask the interviewer (pick 2-3):
- “Single process, or do we need network/distributed sinks?” (Scopes appenders.)
- “Is throughput high enough to require async, or is synchronous fine?” (Decides whether you build the queue.)
- “Do we need the logger hierarchy with inheritance, or are named loggers with explicit config enough?”
- “On overload, do we drop logs or block the producer?” (Backpressure policy — a senior-level question.)
Explicitly state your scope cut: “I’ll build levels, Strategy-based appenders, Decorator/Layout formatting, a registry, and an async wrapper. I’ll describe the hierarchy and rolling-file rotation but not implement them unless we have time.” Naming what you’re skipping is a senior signal — it shows you saw the full space and chose.
Step 2 — Core entities and responsibilities
| Entity | Responsibility | Pattern role |
|---|---|---|
LogLevel | Ordered enum; supports >= comparison for filtering | Value type |
LogRecord | Immutable data: level, message, timestamp, thread, loggerName | DTO / value object |
Logger | Public API (info, error…); checks level; builds LogRecord; dispatches to its appenders | Facade |
Appender (interface) | Where a record goes — append(record) + flush/close | Strategy (the sink algorithm) |
ConsoleAppender, FileAppender | Concrete sinks | Concrete strategies |
Formatter / Layout | Turn a LogRecord into a string | Strategy (or Decorator when composed) |
AsyncAppender | Wraps another appender; queues records, drains on a worker thread | Decorator + Producer/Consumer |
LoggerRegistry / LogManager | Single source of loggers; caches by name; holds config | Singleton + Factory |
The critical separation: Logger decides whether and what; Appender decides where; Formatter decides how it looks. Three axes, three abstractions. If any two of these collapse into one class, the design fails the extensibility test.
The single most important design move is that Logger holds a list of Appender, and each Appender holds a Formatter. The Logger never knows it’s writing to a file, and never knows the date format. That decoupling is the whole point — adding a sink or a format never touches Logger.
Notice the enum is defined ascending: TRACE=0 ... FATAL=5. That direction is exactly what makes level >= threshold mean “this message is important enough to emit.” Juniors frequently declare the enum descending (or in alphabetical order) and then their filter silently inverts — a DEBUG threshold suppresses ERROR. Always sanity-check: with threshold INFO, does error() pass and debug() drop? If not, your ordering is backwards.
Step 3 — ASCII UML class diagram
+---------------------+
| LoggerRegistry | <<Singleton>>
|---------------------|
| - loggers: Map |
| + getLogger(name) |
| + configure(cfg) |
| + shutdown() |
+----------+----------+
| creates/caches
v
+-----------+ uses +---------------+ * +----------------+
| LogLevel |<----------| Logger |----------> | Appender | <<interface>>
|-----------| |---------------| appenders | (Strategy) |
| TRACE.. | | - name | |---------------|
| FATAL | | - level | | + append(rec) |
+-----------+ | + info(msg) | | + flush() |
| + error(msg) | | + close() |
builds | + isEnabled() | +-------+-------+
| +-------+-------+ ^
v | __________/ | \__________
+---------------+ | creates | | |
| LogRecord |<--------------+ +-------------+ +-----------+ +------------------+
|---------------| | Console | | File | | AsyncAppender |
| level | | Appender | | Appender | | (Decorator) |
| message | +------+------+ +-----+-----+ |------------------|
| timestamp | | | | - delegate: |
| threadName | | formatter | | Appender |
| loggerName | v v | - queue: Blocking|
+---------------+ +--------------------------+ | - worker: Thread |
| Formatter <<interface>> | +--------+---------+
| (Strategy/Decorator) | | wraps
| + format(rec): string | v
+-----------+--------------+ (any Appender)
^
_____________/ \______________
| |
+---------------------+ +-----------------------------+
| PatternFormatter | | JsonFormatter / decorators |
+---------------------+ +-----------------------------+
Step 4 — Key method signatures
Start with the contracts. In an interview, write the interfaces first — they prove the abstractions are clean before you spend time on bodies.
A couple of cross-language notes baked into the code below:
- The
threadNameis captured from the actual current thread in all three languages. For a problem whose whole non-functional thesis is concurrent logging, a hardcoded"main"makes the field meaningless — capture it where the log call originates. - C++ populates a real
timestampMsfromsystem_clockso its formatted output matches the TS/Python output. Don’t leave a literal0. - The TS
Appender.close()is typedvoid | Promise<void>so an async sink (theAsyncAppenderbelow) can be honestly typed and awaited by callers, while a synchronous sink can still returnvoid.
enum LogLevel { TRACE, DEBUG, INFO, WARN, ERROR, FATAL }
interface LogRecord {
readonly level: LogLevel;
readonly message: string;
readonly timestamp: number; // epoch millis
readonly threadName: string;
readonly loggerName: string;
}
interface Formatter {
format(record: LogRecord): string;
}
interface Appender {
append(record: LogRecord): void; // assume level already passed
flush(): void;
close(): void | Promise<void>; // flush + release resources (may be async)
}
class Logger {
constructor(
private readonly name: string,
private level: LogLevel,
private readonly appenders: Appender[],
) {}
isEnabled(level: LogLevel): boolean { return level >= this.level; }
log(level: LogLevel, message: string): void {
if (!this.isEnabled(level)) return; // cheap early-out
const record: LogRecord = {
level, message, timestamp: Date.now(),
// Node is single-threaded for JS; this is the worker/thread label
// (e.g. worker_threads name). "main" here is the actual value, not a stub.
threadName: currentThreadName(),
loggerName: this.name,
};
for (const a of this.appenders) a.append(record);
}
info(msg: string) { this.log(LogLevel.INFO, msg); }
error(msg: string) { this.log(LogLevel.ERROR, msg); }
}Step 5 — Strategy for sinks, with the formatter inside
The Appender is a Strategy: each concrete appender is an interchangeable algorithm for “where bytes go,” and the Logger is configured with whichever ones you want.
On thread-safety of the sinks — and a real cross-language difference you should call out:
- In Python and C++, multiple OS threads can hit the same appender concurrently, so
ConsoleAppenderandFileAppendereach take a per-appender lock to prevent interleaved/garbled lines. - In Node.js, JavaScript runs on a single thread — there is no shared-memory data race for these sinks, so no lock is needed (or possible) in the TS version. This is an intentional difference, not an oversight. Say this out loud in an interview: “Node’s single-threaded model means the synchronous-write appenders need no lock; in a JVM/C++ world I’d guard them.”
So the three implementations are equivalent in intent (a single line is never split by another), achieved by the runtime in Node and by an explicit lock in Python/C++.
class PatternFormatter implements Formatter {
format(r: LogRecord): string {
const ts = new Date(r.timestamp).toISOString(); // "2026-06-14T10:00:00.000Z"
// LogLevel[r.level] works because LogLevel is a *numeric* enum (reverse map).
// Caveat: a 'const enum' or string enum would break this reverse lookup;
// for those, keep an explicit number->name array instead.
const lvl = LogLevel[r.level];
return `${ts} [${lvl}] [${r.threadName}] ${r.loggerName} - ${r.message}`;
}
}
class ConsoleAppender implements Appender {
constructor(private fmt: Formatter) {}
append(r: LogRecord) {
const line = this.fmt.format(r);
// Call through console so 'this' stays bound (some runtimes bind console
// methods; extracting them detached is fragile). No lock: Node is single-threaded.
if (r.level >= LogLevel.ERROR) console.error(line);
else console.log(line);
}
flush() {}
close() {}
}
class FileAppender implements Appender {
// Node fs handle elided; pretend writeLineSync appends a line synchronously.
constructor(private fmt: Formatter, private fd: number) {}
append(r: LogRecord) {
const line = this.fmt.format(r) + "\n";
// No lock needed: JS runs on one thread, so append() calls cannot interleave.
// A single synchronous fd write also keeps the line atomic at the OS level.
writeLineSync(this.fd, line);
}
flush() { fsyncSync(this.fd); }
close() { this.flush(); closeSync(this.fd); }
}With the same record, all three now emit the equivalent line:
2026-06-14T10:00:00.000Z [INFO] [thread-1] OrderService - placed order 42
Step 6 — Async with a bounded queue (Decorator + Producer/Consumer)
This is the component interviewers actually care about, and the one most candidates get wrong. The requirement: an ERROR call must not block the request thread on slow disk I/O. The solution: AsyncAppender is a Decorator — it is an Appender and wraps an Appender — that enqueues records and drains them on a dedicated worker thread.
Three things must be correct, and they are the scoring rubric:
- Bounded queue. Unbounded means a slow sink + fast producer = OOM. Bound it.
- A stated backpressure policy when the queue is full: block the producer (lossless, but can stall) or drop (lossy, but never stalls). State which and why. Below I implement drop-newest with a dropped-count — common for logging because losing a log is better than stalling a request — and note how to switch to blocking.
close()must drain the queue AND join the worker before returning. If you return before the worker finishes, buffered logs are lost — which directly violates the “flush before exit” requirement.
There is one subtlety unique to the single-threaded TS version: the drain loop parks on a Promise when the queue empties, so append() must wake it on every enqueue, not only at close(). The threaded versions get this for free — Python’s queue.get(timeout=...) repolls and C++‘s cv_.notify_one() signals the consumer on each push. The TS code below mirrors that by capturing and invoking the parked resolveIdle callback inside append(). Forget that step and you get a classic lost-wakeup bug: records sit undrained until shutdown.
The number-one bug in async appenders: close() sets a “stop” flag and returns immediately, leaving records in the queue. On process exit those logs vanish. Correct shutdown is: stop accepting new records → signal the worker → wait for the queue to empty → join the worker → flush + close the delegate. Saying “and I join the worker thread” out loud is a senior tell. In the TS version, close() is async and the caller (registry.shutdown()) must await it — otherwise you’ve recreated the same return-before-drain defect at the registry layer.
// Node: no shared-memory threads, but I/O is async. We simulate a "worker"
// with an async drain loop; backpressure is by queue length. The semantics
// (bounded buffer, drop-on-full, drain-on-close) mirror the threaded versions.
class AsyncAppender implements Appender {
private queue: LogRecord[] = [];
private dropped = 0;
private closed = false;
private draining: Promise<void>;
private resolveIdle?: () => void;
constructor(
private delegate: Appender,
private capacity = 8192,
) {
this.draining = this.drainLoop();
}
append(r: LogRecord): void {
if (this.closed) return;
if (this.queue.length >= this.capacity) { this.dropped++; return; } // drop-newest
this.queue.push(r);
// Wake a parked drain loop so newly enqueued records are processed
// promptly (mirrors Python put_nowait / C++ cv_.notify_one on enqueue).
// Without this, a worker that went idle stays asleep until close() and
// buffered records are never drained in steady state — a lost-wakeup bug.
const wake = this.resolveIdle;
this.resolveIdle = undefined;
wake?.();
}
private async drainLoop(): Promise<void> {
while (!this.closed || this.queue.length > 0) {
const r = this.queue.shift();
if (r === undefined) {
if (this.closed) break;
await new Promise<void>(res => (this.resolveIdle = res)); // wait for work/close
continue;
}
this.delegate.append(r);
}
}
flush(): void { this.delegate.flush(); }
async close(): Promise<void> {
this.closed = true;
const wake = this.resolveIdle; // wake the loop so it can finish
this.resolveIdle = undefined;
wake?.();
await this.draining; // drain remaining records ("join")
if (this.dropped > 0) console.error(`AsyncAppender dropped ${this.dropped} records`);
this.delegate.flush();
this.delegate.close();
}
}In the C++ worker, do the actual delegate_->append(r) outside the unique_lock. If you hold the queue mutex during slow disk I/O, producers block on append() anyway and you’ve destroyed the whole point of going async. Lock only to mutate the queue; release before doing I/O.
Step 7 — Singleton registry (global access without globals)
Loggers are obtained by name and must be shared (two calls to getLogger("OrderService") return the same logger). That is a Singleton registry plus a Factory method, with a cache. Make creation thread-safe (double-checked or a lock) and have shutdown() close every appender — that is what triggers the async drain on exit. Because the TS AsyncAppender.close() is async, the TS shutdown() is itself async and awaits each close(); returning before those promises settle would discard in-flight records, the exact return-before-drain defect the warn callout above condemns.
class LoggerRegistry {
private static instance: LoggerRegistry;
private loggers = new Map<string, Logger>();
private defaultLevel = LogLevel.INFO;
private defaultAppenders: Appender[] = [];
private constructor() {}
static getInstance(): LoggerRegistry {
if (!LoggerRegistry.instance) LoggerRegistry.instance = new LoggerRegistry();
return LoggerRegistry.instance;
}
configure(level: LogLevel, appenders: Appender[]): void {
this.defaultLevel = level;
this.defaultAppenders = appenders;
}
getLogger(name: string): Logger {
let lg = this.loggers.get(name);
if (!lg) {
lg = new Logger(name, this.defaultLevel, this.defaultAppenders);
this.loggers.set(name, lg); // single-threaded: no lock needed
}
return lg;
}
async shutdown(): Promise<void> { // closes shared appenders -> async drain
// await each close so AsyncAppender.close() fully drains its queue
// before shutdown() returns (return-before-drain loses buffered logs).
for (const a of this.defaultAppenders) await a.close();
}
}The C++ Meyers singleton (static LoggerRegistry inst; inside a function) is the idiomatic, thread-safe-by-the-standard way — initialization of a function-local static is guaranteed to happen exactly once even under concurrent first calls (since C++11). Prefer it over hand-rolled double-checked locking in C++. Python and TS don’t get that guarantee, so Python uses explicit double-checked locking and TS relies on its single-threaded model.
Step 8 — Edge cases interviewers probe
| Edge case | What good design does |
|---|---|
| Log call below threshold | isEnabled early-return before building the LogRecord — never allocate a record or call toISOString for a dropped DEBUG. This is the hot path. |
| Async queue full | Apply the stated policy (drop-newest + counter, or block). Never silently grow unbounded. Surface the dropped count on shutdown. |
| Crash / exit without shutdown | Register a shutdown hook (process.on('exit'), atexit, signal handler) that calls registry.shutdown() so the async worker drains. Without it, buffered logs are lost. |
| Exception inside an appender | One failing appender (full disk) must not stop the others or kill the caller. Wrap each append in try/catch and continue; optionally route the failure to an internal/fallback logger. |
| Re-entrancy | An appender that itself logs can deadlock or recurse infinitely. Use a separate internal logger or guard against re-entry. |
| Formatter throws | A bad message object (circular JSON) shouldn’t crash logging. Catch and emit a degraded line rather than propagate. |
Concurrent first getLogger | Two threads creating the same named logger must yield one instance — hence the lock / Meyers singleton. |
The most common correctness miss is doing the isEnabled check after constructing the LogRecord (timestamp, thread name, formatted string). Disabled logging must be nearly free — build nothing until you know the level passes. In SLF4J this is also why you pass {} placeholders instead of pre-concatenated strings: don’t pay formatting cost for a suppressed line.
Step 9 — Patterns recap and extensibility
| Pattern | Where | Why it appears |
|---|---|---|
| Strategy | Appender, Formatter | Interchangeable sink/layout algorithms chosen at config time |
| Decorator | AsyncAppender (and a FilterAppender, BufferingAppender) | Add behavior (async, filtering, buffering) by wrapping an Appender without modifying it |
| Singleton + Factory | LoggerRegistry | One global access point; cached creation by name |
| Facade | Logger | Hides level-check + record-build + fan-out behind info()/error() |
| Producer/Consumer | AsyncAppender queue | Decouple producing threads from the I/O-bound consumer |
“What if they ask for…” — rehearse these; each is a clean extension, not a rewrite:
- A JSON format? New
JsonFormatter implements Formatter. Zero changes elsewhere — that’s the Strategy payoff. - Rolling files by size/date? New
RollingFileAppender(a sink variant), or aBufferingAppenderdecorator. TheLoggeris untouched. - Per-level routing (ERROR→Slack, INFO→file)? A
FilterAppenderdecorator wrapping each sink, or a composite appender that checks level. Decorator again. - The logger hierarchy (
com.shop.OrderServiceinherits fromcom.shop)? Store loggers in the registry by dotted name; ongetLogger, walk up to the nearest configured ancestor for effective level/appenders. TheLogRecord/Appender/Formattercontracts don’t change — only the registry’s resolution logic. - Structured/contextual logging (MDC)? Add a thread-local context map merged into each
LogRecord. New field, same flow.
Closing line for the interview: “Every extension here is a new class implementing an existing interface or a new decorator — never an edit to
Logger,Appender, orFormatter. That Open/Closed property is the whole reason for the three-axis split.” That sentence is the one that gets you the senior checkmark.