LLD Series: Logger Framework (Low-Level Design)
Overview
- 1. Problem statement and how it is asked
- 2. Requirements clarification
- 3. Entities and class diagram
- 4. Design patterns and why
- 5. Code
- 6. Key flows
- 7. Extensibility
- 8. Concurrency and edge cases
- 9. Follow-ups and model answers
- 10. Common mistakes and rubric
Part of the Interview Prep series. Dotted-underline words are glossary terms: hover for the meaning or open the Glossary. Track your progress on the Interview Prep tracker. Series index: Start here.
1. Problem statement and how it is asked
Prompt: "Design a logging library like Log4j/SLF4J: log levels, multiple destinations (console, file), configurable formatting, and it must not slow down the application. Make it thread-safe."
Asked at: Visa, Mastercard, Amex, PayU (classic machine-coding warm-up; checks patterns knowledge). Interviewers expect: Singleton, Chain of Responsibility, Strategy for formatter/appender, and a discussion of async logging.
2. Requirements clarification
Functional
- Levels DEBUG < INFO < WARN < ERROR < FATAL; messages below the configured level are dropped.
- Multiple appenders (console, file), each with its own minimum level and formatter (plain text, JSON).
- Named loggers via a manager (
getLogger("PaymentService")). - Async mode for slow sinks; flush on shutdown.
- Change the root level at runtime.
Non-functional: thread-safe, low latency on the caller thread, never throws into the application, lines are not interleaved.
Assumptions / out of scope: no XML/properties config parsing, no log rotation, no MDC, no per-logger hierarchy inheritance (all discussed in follow-ups).
3. Entities and class diagram
+-----------------+ 1 +---------------+ getLogger +----------+
| LoggerManager |<--| (Singleton) |----------->| Logger |--creates--> LogRecord
| rootLevel,chain | (name) (level,ts,thread,msg,err)
+-------+---------+
| chain head
v
+------------+ next +------------+ next +------------+
| LogHandler |----->| LogHandler |----->| LogHandler | (Chain of Responsibility)
| min,Append.| +------------+ +------------+
+-----+------+
| has-a
v
<<interface>> Appender <------ AsyncAppender (Decorator, wraps another Appender)
^ ^
ConsoleAppender FileAppender each has-a <<interface>> LogFormatter
^ ^
SimpleFormatter JsonFormatter
4. Design patterns and why
| Pattern | Where | Why / rejected alternative |
|---|---|---|
| Singleton (holder idiom) | LoggerManager | One global config/registry; lazy and thread-safe without synchronized on reads. Rejected: double-checked locking (more error-prone), static mutable god-class (untestable; keep constructor private but inject manager into Logger). |
| Chain of Responsibility | LogHandler | Each handler decides by its own level and forwards; add/remove destinations by re-linking. Rejected: a List<Appender> also works for simple fan-out (mention it); the chain wins when handlers can stop propagation or transform. |
| Strategy | LogFormatter, Appender | Format and destination vary independently. |
| Decorator | AsyncAppender | Adds async to any appender without changing it. Rejected: async flag inside each appender (duplication). |
| Factory/Registry | getLogger(name) | One Logger per name via computeIfAbsent. |
5. Code
Default package, Java 11+; javac *.java && java Main.
Models
LogLevel.java
public enum LogLevel { DEBUG, INFO, WARN, ERROR, FATAL;
public boolean atLeast(LogLevel min) { return this.ordinal() >= min.ordinal(); } }
LogRecord.java
import java.time.Instant;
/** Immutable; captured on the caller thread (thread name, timestamp) BEFORE going async. */
public final class LogRecord {
public final Instant timestamp = Instant.now();
public final LogLevel level;
public final String loggerName, message, threadName;
public final Throwable error;
public LogRecord(LogLevel level, String loggerName, String message, Throwable error) {
this.level = level; this.loggerName = loggerName; this.message = message; this.error = error;
this.threadName = Thread.currentThread().getName();
}
}
Formatters
LogFormatter.java
public interface LogFormatter { String format(LogRecord r); }
SimpleFormatter.java
public class SimpleFormatter implements LogFormatter {
public String format(LogRecord r) {
String s = String.format("%s [%s] %-5s %s - %s", r.timestamp, r.threadName, r.level, r.loggerName, r.message);
return r.error == null ? s : s + " | " + r.error;
}
}
JsonFormatter.java
public class JsonFormatter implements LogFormatter {
public String format(LogRecord r) {
return "{\"ts\":\"" + r.timestamp + "\",\"level\":\"" + r.level + "\",\"logger\":\"" + esc(r.loggerName)
+ "\",\"thread\":\"" + esc(r.threadName) + "\",\"msg\":\"" + esc(r.message) + "\""
+ (r.error == null ? "" : ",\"error\":\"" + esc(r.error.toString()) + "\"") + "}";
}
private static String esc(String s) { return s.replace("\\", "\\\\").replace("\"", "\\\""); }
}
Appenders
Appender.java
/** Sink (Strategy for "where"). Implementations must be safe for concurrent append(). */
public interface Appender extends AutoCloseable {
void append(LogRecord r);
@Override default void close() {}
}
ConsoleAppender.java
import java.io.PrintStream;
public class ConsoleAppender implements Appender {
private final LogFormatter formatter; private final PrintStream out; private final String tag;
public ConsoleAppender(LogFormatter f, PrintStream out, String tag) { this.formatter = f; this.out = out; this.tag = tag; }
public void append(LogRecord r) { out.println(tag + formatter.format(r)); } // PrintStream.println is synchronized
}
FileAppender.java
import java.io.*;
import java.nio.charset.StandardCharsets;
public class FileAppender implements Appender {
private final LogFormatter formatter; private final BufferedWriter w;
public FileAppender(LogFormatter f, File file) throws IOException {
this.formatter = f;
this.w = new BufferedWriter(new OutputStreamWriter(new FileOutputStream(file, true), StandardCharsets.UTF_8));
}
public synchronized void append(LogRecord r) { // one lock per file: lines never interleave
try { w.write(formatter.format(r)); w.newLine(); } catch (IOException e) { System.err.println("log write failed " + e); }
}
public synchronized void close() { try { w.close(); } catch (IOException ignored) {} }
}
AsyncAppender.java
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicLong;
/** Decorator: callers only enqueue; one daemon thread drains to the delegate. */
public class AsyncAppender implements Appender {
private static final LogRecord POISON = new LogRecord(LogLevel.DEBUG, "", "", null);
private final Appender delegate;
private final BlockingQueue<LogRecord> queue;
private final Thread worker;
private final AtomicLong dropped = new AtomicLong();
public AsyncAppender(Appender delegate, int capacity) {
this.delegate = delegate; this.queue = new ArrayBlockingQueue<>(capacity);
worker = new Thread(this::drain, "async-log-writer"); worker.setDaemon(true); worker.start();
}
public void append(LogRecord r) {
// overflow policy: drop non-errors, block for ERROR+ (never lose the important stuff)
if (r.level.atLeast(LogLevel.ERROR)) {
try { queue.put(r); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
} else if (!queue.offer(r)) dropped.incrementAndGet();
}
private void drain() {
try {
for (LogRecord r; (r = queue.take()) != POISON; ) {
try { delegate.append(r); } catch (RuntimeException e) { System.err.println("appender failed " + e); }
}
} catch (InterruptedException ignored) {}
}
public long dropped() { return dropped.get(); }
/** Flush: enqueue poison pill, wait for drain, close delegate. */
public void close() {
try { queue.put(POISON); worker.join(5000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
delegate.close();
}
}
Chain, logger, manager
LogHandler.java
/** Chain of Responsibility node: each handler owns a min level and an appender, then forwards. */
public class LogHandler {
private final LogLevel minLevel; private final Appender appender; private volatile LogHandler next;
public LogHandler(LogLevel minLevel, Appender appender) { this.minLevel = minLevel; this.appender = appender; }
public LogHandler then(LogHandler n) { this.next = n; return n; }
public void handle(LogRecord r) {
if (r.level.atLeast(minLevel)) appender.append(r);
if (next != null) next.handle(r); // non-terminating chain: fan-out by level
}
public void close() { appender.close(); if (next != null) next.close(); }
}
Logger.java
public class Logger {
private final String name; private final LoggerManager mgr;
Logger(String name, LoggerManager mgr) { this.name = name; this.mgr = mgr; }
public void debug(String m) { log(LogLevel.DEBUG, m, null); }
public void info(String m) { log(LogLevel.INFO, m, null); }
public void warn(String m) { log(LogLevel.WARN, m, null); }
public void error(String m, Throwable t) { log(LogLevel.ERROR, m, t); }
public void log(LogLevel lvl, String m, Throwable t) {
if (!lvl.atLeast(mgr.rootLevel())) return; // cheap fast-path before allocating a record
LogHandler h = mgr.chain();
if (h != null) h.handle(new LogRecord(lvl, name, m, t));
}
}
LoggerManager.java
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/** Singleton (initialization-on-demand holder: lazy, thread-safe, no locking on read). */
public final class LoggerManager {
private static final class Holder { static final LoggerManager I = new LoggerManager(); }
public static LoggerManager getInstance() { return Holder.I; }
private LoggerManager() {}
private final Map<String, Logger> loggers = new ConcurrentHashMap<>();
private volatile LogLevel rootLevel = LogLevel.INFO;
private volatile LogHandler chain;
public Logger getLogger(String name) { return loggers.computeIfAbsent(name, n -> new Logger(n, this)); }
public void configure(LogLevel root, LogHandler head) { this.rootLevel = root; this.chain = head; } // swap = atomic reconfig
public void setRootLevel(LogLevel l) { this.rootLevel = l; }
LogLevel rootLevel() { return rootLevel; }
LogHandler chain() { return chain; }
public void shutdown() { if (chain != null) chain.close(); }
}
Lock granularity: LoggerManager uses a ConcurrentHashMap and volatile fields (config swaps are atomic reference writes; no lock on the hot path). FileAppender has one monitor per file (writes must be serial; contention removed by putting AsyncAppender in front). ConsoleAppender relies on PrintStream's internal lock. LogRecord is immutable and captures the thread name and timestamp on the caller thread before crossing to the async thread (a common bug is capturing them on the writer thread).
Demo
Main.java
import java.io.File;
import java.util.concurrent.*;
public class Main {
public static void main(String[] args) throws Exception {
File f = File.createTempFile("app", ".log");
// console gets WARN+ in plain text; file gets INFO+ as JSON, written asynchronously
LogHandler head = new LogHandler(LogLevel.WARN, new ConsoleAppender(new SimpleFormatter(), System.out, "CONSOLE> "));
head.then(new LogHandler(LogLevel.INFO, new AsyncAppender(new FileAppender(new JsonFormatter(), f), 1024)));
LoggerManager mgr = LoggerManager.getInstance();
mgr.configure(LogLevel.DEBUG, head);
Logger pay = mgr.getLogger("PaymentService");
pay.debug("debug detail"); // file: no (INFO min), console: no
pay.info("payment initiated id=P1"); // file only
pay.warn("retrying bank call"); // console + file
pay.error("bank declined", new RuntimeException("code=51")); // both
ExecutorService ex = Executors.newFixedThreadPool(4);
for (int i = 0; i < 4; i++) { int id = i; ex.submit(() -> mgr.getLogger("Worker").info("task " + id + " done")); }
ex.shutdown(); ex.awaitTermination(2, TimeUnit.SECONDS);
System.out.println("same instance? " + (mgr.getLogger("PaymentService") == pay));
mgr.setRootLevel(LogLevel.ERROR); // runtime level change
pay.warn("suppressed by root level");
mgr.shutdown(); // flushes async queue
System.out.println("--- file contents (" + java.nio.file.Files.readAllLines(f.toPath()).size() + " lines) ---");
java.nio.file.Files.readAllLines(f.toPath()).forEach(System.out::println);
}
}
Real output:
CONSOLE> 2026-10-19T09:47:36.359674Z [main] WARN PaymentService - retrying bank call
CONSOLE> 2026-10-19T09:47:36.362400Z [main] ERROR PaymentService - bank declined | java.lang.RuntimeException: code=51
same instance? true
--- file contents (7 lines) ---
{"ts":"2026-10-19T09:47:36.359649Z","level":"INFO","logger":"PaymentService","thread":"main","msg":"payment initiated id=P1"}
{"ts":"2026-10-19T09:47:36.359674Z","level":"WARN","logger":"PaymentService","thread":"main","msg":"retrying bank call"}
{"ts":"2026-10-19T09:47:36.362400Z","level":"ERROR","logger":"PaymentService","thread":"main","msg":"bank declined","error":"java.lang.RuntimeException: code=51"}
{"ts":"2026-10-19T09:47:36.367054Z","level":"INFO","logger":"Worker","thread":"pool-1-thread-1","msg":"task 0 done"}
{"ts":"2026-10-19T09:47:36.367054Z","level":"INFO","logger":"Worker","thread":"pool-1-thread-3","msg":"task 2 done"}
{"ts":"2026-10-19T09:47:36.367054Z","level":"INFO","logger":"Worker","thread":"pool-1-thread-2","msg":"task 1 done"}
{"ts":"2026-10-19T09:47:36.367154Z","level":"INFO","logger":"Worker","thread":"pool-1-thread-4","msg":"task 3 done"}
Reading it: console handler is WARN+ (so DEBUG/INFO absent), file handler is INFO+ JSON (so DEBUG absent, INFO present); the four worker lines come from the pool's threads (their relative order varies run to run); after setRootLevel(ERROR) the later WARN is dropped; shutdown() flushed the async queue before the file was read.
6. Key flows
app thread Logger LogHandler chain writer thread
| info("x") ---------->| |
| | level < root? -> return (no allocation) |
| | new LogRecord (ts, thread captured) |
| |------> Console(WARN+): skip -> next |
| |------> Async(INFO+): queue.offer() ===(queue)====> | delegate.append()
|<---- returns --------| | file write (JSON)
shutdown(): put POISON -> writer drains everything before it -> join -> close file
7. Extensibility
- New destination (Kafka, syslog, Slack for FATAL): implement
Appender, link a newLogHandler. - New format (logfmt): implement
LogFormatter. - Rolling file:
RollingFileAppenderimplementingAppender(size/time check inside the existing lock) or a decorator. - Filters (by logger name, regex): a
LogFilterchecked inLogHandler.handle(Chain stays unchanged). - Per-logger levels / hierarchy (
com.payinherits root): add a level map with longest-prefix lookup in the manager.
8. Concurrency and edge cases
- Queue full: policy here is drop DEBUG/INFO/WARN (counter exposed), but block for ERROR+. Alternatives: block all (backpressure but latency spikes), drop oldest, or spill to disk.
- Process crash loses queued async records; trade-off versus sync. Use sync appender for audit logs.
- Appender exception must not propagate to the app (caught in writer loop) and one failing appender must not stop others (production: try/catch per handler).
- Reconfiguration while logging: handlers are read once per call from a
volatilehead, so a call sees either the old or new chain whole. Old chain should be closed after swap. - Shutdown hook:
Runtime.addShutdownHook(mgr::shutdown). Writer thread is daemon so it will not keep the JVM alive. - Lazy message building: offer
Supplier<String>overloads sodebug(() -> expensive())costs nothing when disabled. Level check happens beforeLogRecordallocation. - Recursion: an appender that logs itself loops; guard with a thread-local flag.
9. Follow-ups and model answers
- Why async? Disk/network I/O latency would otherwise sit on request threads. Cost: ordering across threads is by enqueue time (single consumer preserves it), possible loss on crash, memory bound by queue capacity.
- Make it high throughput? LMAX Disruptor ring buffer (as in Log4j2 async loggers): lock-free, pre-allocated events, batching writes.
- Distributed / persisted? Write structured JSON to stdout/file, ship with Fluentd/Filebeat to Kafka then Elasticsearch; add
traceId/correlationIdvia MDC (ThreadLocal<Map>copied intoLogRecord, propagated across async hops) so one payment can be traced across services. - Singleton downsides? Global state hurts testing; make
LoggerManagerinjectable and keepgetInstance()as a convenience. - Chain vs list of appenders? Same fan-out here; chain lets a handler consume (stop) the record, e.g. a FATAL pager that stops propagation.
- Sensitive data (PAN, CVV)? Masking formatter/decorator before any appender; never log full card numbers (PCI-DSS) - great point for Visa/Mastercard.
- Complexity: disabled log call O(1) (one int compare); enabled call O(#handlers) + O(1) enqueue; file write amortised by buffer.
10. Common mistakes and rubric
Red flags: synchronized on the whole log() method (serialises all threads); double-checked locking without volatile; static System.out.println as "the logger"; formatter inside appender code (no Strategy); async queue unbounded (OOM) or no flush on shutdown; capturing thread name on the writer thread; levels as strings or ints instead of enum; string concatenation before the level check.
Rubric (10 pts): levels + multiple appenders + formatter separation (2); Singleton and CoR/Strategy/Decorator named with justification (3); async with overflow and shutdown flush (2); thread-safety explained (2); extensions and PCI/distributed discussion (1).