Back to the path
Level 6 expert 26 min #lld#design-patterns#logging#strategy#decorator#singleton#concurrency#interview

Design an Extensible Logging Library

A full LLD walkthrough for designing a log4j-style logging library: clarify requirements, model levels/loggers/appenders/formatters, wire up Strategy, Decorator, and Singleton/registry patterns, then make it thread-safe and async without losing logs. Ends with an extension design challenge.

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 Logger facade, pluggable Appender sinks, and Formatter layouts — 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.OrderService inherits config from com.shop from 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 ERROR log 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 KafkaAppender or a JSON format should be a new class, not an edit to existing ones (Open/Closed).

Questions to ask the interviewer (pick 2-3):

  1. “Single process, or do we need network/distributed sinks?” (Scopes appenders.)
  2. “Is throughput high enough to require async, or is synchronous fine?” (Decides whether you build the queue.)
  3. “Do we need the logger hierarchy with inheritance, or are named loggers with explicit config enough?”
  4. “On overload, do we drop logs or block the producer?” (Backpressure policy — a senior-level question.)
Tip

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

EntityResponsibilityPattern role
LogLevelOrdered enum; supports >= comparison for filteringValue type
LogRecordImmutable data: level, message, timestamp, thread, loggerNameDTO / value object
LoggerPublic API (info, error…); checks level; builds LogRecord; dispatches to its appendersFacade
Appender (interface)Where a record goes — append(record) + flush/closeStrategy (the sink algorithm)
ConsoleAppender, FileAppenderConcrete sinksConcrete strategies
Formatter / LayoutTurn a LogRecord into a stringStrategy (or Decorator when composed)
AsyncAppenderWraps another appender; queues records, drains on a worker threadDecorator + Producer/Consumer
LoggerRegistry / LogManagerSingle source of loggers; caches by name; holds configSingleton + 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.

Key idea

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.

Tip

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 threadName is 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 timestampMs from system_clock so its formatted output matches the TS/Python output. Don’t leave a literal 0.
  • The TS Appender.close() is typed void | Promise<void> so an async sink (the AsyncAppender below) can be honestly typed and awaited by callers, while a synchronous sink can still return void.
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 ConsoleAppender and FileAppender each 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:

  1. Bounded queue. Unbounded means a slow sink + fast producer = OOM. Bound it.
  2. 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.
  3. 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.

Watch out

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 emptyjoin 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();
}
}
Common pitfall

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();
}
}
Key idea

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 caseWhat good design does
Log call below thresholdisEnabled early-return before building the LogRecord — never allocate a record or call toISOString for a dropped DEBUG. This is the hot path.
Async queue fullApply the stated policy (drop-newest + counter, or block). Never silently grow unbounded. Surface the dropped count on shutdown.
Crash / exit without shutdownRegister 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 appenderOne 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-entrancyAn appender that itself logs can deadlock or recurse infinitely. Use a separate internal logger or guard against re-entry.
Formatter throwsA bad message object (circular JSON) shouldn’t crash logging. Catch and emit a degraded line rather than propagate.
Concurrent first getLoggerTwo threads creating the same named logger must yield one instance — hence the lock / Meyers singleton.
Common pitfall

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

PatternWhereWhy it appears
StrategyAppender, FormatterInterchangeable sink/layout algorithms chosen at config time
DecoratorAsyncAppender (and a FilterAppender, BufferingAppender)Add behavior (async, filtering, buffering) by wrapping an Appender without modifying it
Singleton + FactoryLoggerRegistryOne global access point; cached creation by name
FacadeLoggerHides level-check + record-build + fan-out behind info()/error()
Producer/ConsumerAsyncAppender queueDecouple 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 a BufferingAppender decorator. The Logger is untouched.
  • Per-level routing (ERROR→Slack, INFO→file)? A FilterAppender decorator wrapping each sink, or a composite appender that checks level. Decorator again.
  • The logger hierarchy (com.shop.OrderService inherits from com.shop)? Store loggers in the registry by dotted name; on getLogger, walk up to the nearest configured ancestor for effective level/appenders. The LogRecord/Appender/Formatter contracts 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, or Formatter. That Open/Closed property is the whole reason for the three-axis split.” That sentence is the one that gets you the senior checkmark.

Assessment

Pass mark: 70% on the concept questions unlocks the next lesson.

1. In the synchronous `Logger.log` path, why must the `isEnabled(level)` check happen *before* constructing the `LogRecord`?

2. An `AsyncAppender.close()` sets a stop flag, returns immediately, and the process then exits. Records are still in the queue. What is the defect and the fix?

3. Which statements about thread-safety of the sink appenders across the three implementations are correct? (Select all that apply.) (select all)

4. Why is the ascending enum ordering `TRACE=0 ... FATAL=5` essential to the filter `level >= threshold`?

5. In the single-threaded TS `AsyncAppender`, the drain loop parks on `await new Promise(res => this.resolveIdle = res)` once the queue empties. What must `append()` do to avoid a lost-wakeup bug, and how do the threaded versions avoid the same hazard?

Design problem 6

Extend the design to support **per-level routing with fan-out**: INFO and above go to a file, but ERROR and FATAL must ALSO be sent to a Slack/webhook sink — without modifying `Logger`, `Appender`, or `Formatter`. Additionally, the webhook can be slow and occasionally fail. Describe the classes/decorators you add, how they compose, the backpressure/failure handling for the webhook, and how shutdown stays correct. Provide key signatures or a short class sketch.