Design a Logging Framework
Build a mini SLF4J/Log4j-style logging library with levels, hierarchical loggers, pluggable appenders and formatters, filter chains, thread safety and a non-blocking async appender.
Requirements
"Design a logger" sounds like a one-class problem until you list what real libraries such as Log4j, Logback or java.util.logging do. The interviewer wants to see you separate what is logged (records and levels), where it goes (appenders), how it looks (formatters) and who decides (filters and the logger hierarchy), all without slowing down the application.
Functional
- Ordered levels:
TRACE < DEBUG < INFO < WARN < ERROR < FATAL, plusOFF. - Named loggers obtained from a factory, e.g.
getLogger("app.payments"). Names form a hierarchy by dots; a logger without its own level inherits its nearest ancestor's. - Each logger can have several appenders (console, file, network, in-memory). Records flow up to ancestor appenders unless additivity is switched off.
- Each appender has a formatter (plain pattern, JSON) and an optional threshold and filters.
- Parameterised messages (
"user {} paid {}") and an attached exception. - Levels and appenders can be changed at runtime.
Non-functional
- Cheap when disabled: a suppressed
debugcall must not build strings. - Thread-safe: many threads log at once; lines never interleave or get lost.
- Low latency: an optional async mode so slow I/O never blocks request threads.
- Extensible: new destinations or formats without touching
Logger. - A failing appender must never crash the application.
Out of scope (confirm)
- Config-file parsing, log shipping infrastructure, log search and retention.
Open by sketching the pipeline out loud: logger checks level, builds a record, hands it to appenders up the hierarchy; each appender filters, formats and writes. Every class you add later fits into one box of that sentence.
Core Entities
| Entity | Responsibility | Key fields |
|---|---|---|
LogLevel (enum) |
Ordered severities; comparison helper | TRACE ... OFF |
LogRecord |
Immutable event captured at the call site | time, level, logger name, thread, message, error |
Logger |
Level check, record creation, dispatch up the hierarchy | name, parent, level, appenders, additive |
LogManager |
Factory and registry of loggers; owns the root; shutdown | ConcurrentHashMap<String, Logger> |
Appender |
Destination for records | interface |
AbstractAppender |
Template: threshold, filter chain, format, synchronized write | formatter, filters |
StreamAppender, MemoryAppender |
Write to a PrintStream / keep lines for tests |
|
AsyncAppender |
Decorator: bounded queue plus background worker | queue, worker, dropped |
Formatter |
Turns a record into text | PatternFormatter, JsonFormatter |
LogFilter |
Votes ACCEPT, DENY or NEUTRAL on a record |
interface |
The record is created once per call and shared by every appender, so it must be immutable. Capturing the thread name and timestamp in the record (not in the appender) is what keeps them correct when an async appender writes later on another thread.
Class Diagram
classDiagram
class LogLevel {
<<enumeration>>
TRACE
DEBUG
INFO
WARN
ERROR
FATAL
OFF
}
class LogRecord {
-Instant timestamp
-LogLevel level
-String loggerName
-String threadName
-String message
-Throwable error
}
class Logger {
-String name
-Logger parent
-LogLevel level
-boolean additive
-List appenders
+isEnabled(LogLevel) boolean
+info(String, Object...) void
+debug(Supplier) void
}
class LogManager {
-Logger root
-Map loggers
+getLogger(String) Logger
+shutdown() void
}
class Appender {
<<interface>>
+append(LogRecord) void
+close() void
}
class AbstractAppender {
<<abstract>>
-LogLevel threshold
-Formatter formatter
-List filters
#write(String) void
}
class StreamAppender
class MemoryAppender
class AsyncAppender {
-BlockingQueue queue
-Appender delegate
-Thread worker
}
class Formatter {
<<interface>>
+format(LogRecord) String
}
class PatternFormatter
class JsonFormatter
class LogFilter {
<<interface>>
+decide(LogRecord) FilterDecision
}
LogManager "1" *-- "many" Logger
Logger --> Logger : parent
Logger "1" o-- "many" Appender
Logger ..> LogRecord : creates
Appender <|.. AbstractAppender
Appender <|.. AsyncAppender
AbstractAppender <|-- StreamAppender
AbstractAppender <|-- MemoryAppender
AsyncAppender --> Appender : delegate
AbstractAppender --> Formatter
AbstractAppender "1" o-- "many" LogFilter
Formatter <|.. PatternFormatter
Formatter <|.. JsonFormatter
Design Patterns Used
| Pattern | Where | Why it is justified |
|---|---|---|
| Chain of Responsibility | Logger hierarchy (app.payments to app to root) and the appender filter chain |
A record is offered to each link in turn; any link can stop propagation (additive = false, or a filter returning DENY/ACCEPT) |
| Strategy | Formatter, LogFilter |
Plain vs JSON output, or "drop health checks", are swappable algorithms |
| Template Method | AbstractAppender.append() |
Fixed steps (threshold, filters, format, locked write); subclasses only implement write |
| Decorator | AsyncAppender wraps any Appender |
Adds asynchrony without changing console or file appenders |
| Factory + Registry (Flyweight-like) | LogManager.getLogger |
One shared Logger per name, created lazily and safely |
| Observer (implicit) | Appenders attached to loggers | Loggers publish records; appenders subscribe and unsubscribe at runtime |
Two chains, two jobs
- Hierarchy chain.
Logger.dispatchwalks from the logger to the root, giving the record to each logger's appenders. If a logger hasadditive = false, the walk stops there. The level check happens once, at the originating logger, which matches how Logback treats additivity. - Filter chain. Inside an appender each
LogFilterreturnsDENY(drop now),ACCEPT(log now, skip remaining filters) orNEUTRAL(ask the next filter). This is the textbook Chain of Responsibility shape.
A classic interview variant chains handlers by level ("INFO handler, then ERROR handler..."). The hierarchy-plus-filters version is what production libraries do, and it answers the same question: each link decides to handle, pass on, or stop.
Making disabled logs nearly free
isEnabled compares two enum ordinals. Parameterised messages defer string building until after that check, and the Supplier<String> overload defers even computing the arguments. The demo proves an expensive debug argument is never evaluated when DEBUG is off.
Java Implementation
The code compiles as a single Main.java (Java 17+). A stepping Clock is injected so timestamps in the output are deterministic.
import java.time.*; import java.time.format.DateTimeFormatter; import java.util.*; import java.util.concurrent.*; import java.util.concurrent.atomic.AtomicLong; import java.util.function.Supplier; enum LogLevel { TRACE, DEBUG, INFO, WARN, ERROR, FATAL, OFF; boolean isAtLeast(LogLevel other) { return compareTo(other) >= 0; } } record LogRecord(Instant timestamp, LogLevel level, String loggerName, String threadName, String message, Throwable error) {} interface Formatter { String format(LogRecord r); } class PatternFormatter implements Formatter { private static final DateTimeFormatter TIME = DateTimeFormatter.ofPattern("HH:mm:ss.SSS").withZone(ZoneOffset.UTC); public String format(LogRecord r) { String line = TIME.format(r.timestamp()) + " " + String.format("%-5s", r.level()) + " [" + r.threadName() + "] " + r.loggerName() + " - " + r.message(); return r.error() == null ? line : line + " | " + r.error().getClass().getSimpleName() + ": " + r.error().getMessage(); } } class JsonFormatter implements Formatter { public String format(LogRecord r) { return "{\"ts\":\"" + r.timestamp() + "\",\"level\":\"" + r.level() + "\",\"logger\":\"" + r.loggerName() + "\",\"msg\":\"" + escape(r.message()) + "\"" + (r.error() == null ? "" : ",\"error\":\"" + escape(String.valueOf(r.error().getMessage())) + "\"") + "}"; } private static String escape(String s) { return s.replace("\\", "\\\\").replace("\"", "\\\""); } } enum FilterDecision { ACCEPT, DENY, NEUTRAL } interface LogFilter { FilterDecision decide(LogRecord r); } interface Appender { void append(LogRecord r); default void close() {} } abstract class AbstractAppender implements Appender { private final Formatter formatter; private final List<LogFilter> filters = new CopyOnWriteArrayList<>(); private volatile LogLevel threshold = LogLevel.TRACE; AbstractAppender(Formatter formatter) { this.formatter = formatter; } AbstractAppender threshold(LogLevel level) { this.threshold = level; return this; } AbstractAppender addFilter(LogFilter f) { filters.add(f); return this; } public final void append(LogRecord r) { if (!r.level().isAtLeast(threshold)) return; for (LogFilter f : filters) { // chain of responsibility FilterDecision d = f.decide(r); if (d == FilterDecision.DENY) return; if (d == FilterDecision.ACCEPT) break; } String line = formatter.format(r); // format outside the lock try { synchronized (this) { write(line); } // one writer at a time: no interleaving } catch (RuntimeException e) { System.err.println("appender failed: " + e); // never propagate to the caller } } protected abstract void write(String line); } class StreamAppender extends AbstractAppender { private final java.io.PrintStream out; StreamAppender(java.io.PrintStream out, Formatter f) { super(f); this.out = out; } protected void write(String line) { out.println(line); } } class MemoryAppender extends AbstractAppender { private final List<String> lines = new ArrayList<>(); MemoryAppender(Formatter f) { super(f); } protected void write(String line) { lines.add(line); } synchronized List<String> lines() { return new ArrayList<>(lines); } } // Decorator: callers enqueue and return; one worker thread performs the slow I/O. class AsyncAppender implements Appender { private static final LogRecord POISON = new LogRecord(Instant.EPOCH, LogLevel.OFF, "", "", "", null); private final Appender delegate; private final BlockingQueue<LogRecord> queue; private final AtomicLong dropped = new AtomicLong(); private final Thread worker; private volatile boolean closed; AsyncAppender(Appender delegate, int capacity) { this.delegate = delegate; this.queue = new ArrayBlockingQueue<>(capacity); this.worker = new Thread(this::drain, "log-async"); worker.setDaemon(true); worker.start(); } public void append(LogRecord r) { if (closed) { dropped.incrementAndGet(); return; } try { // WARN and above wait briefly for space; chatty levels are dropped when the queue is full. boolean ok = r.level().isAtLeast(LogLevel.WARN) ? queue.offer(r, 1, TimeUnit.SECONDS) : queue.offer(r); if (!ok) dropped.incrementAndGet(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } private void drain() { try { while (true) { LogRecord r = queue.take(); if (r == POISON) return; delegate.append(r); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } long dropped() { return dropped.get(); } public void close() { closed = true; try { queue.put(POISON); // everything queued before it is still written worker.join(5_000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } delegate.close(); } } class Logger { private final String name; private final Logger parent; private final Clock clock; private final List<Appender> appenders = new CopyOnWriteArrayList<>(); private volatile LogLevel level; // null means inherit from parent private volatile boolean additive = true; Logger(String name, Logger parent, Clock clock) { this.name = name; this.parent = parent; this.clock = clock; } String name() { return name; } List<Appender> appenders() { return appenders; } Logger setLevel(LogLevel level) { this.level = level; return this; } Logger setAdditive(boolean additive) { this.additive = additive; return this; } Logger addAppender(Appender a) { appenders.add(a); return this; } LogLevel effectiveLevel() { for (Logger l = this; l != null; l = l.parent) if (l.level != null) return l.level; return LogLevel.INFO; } boolean isEnabled(LogLevel lvl) { return lvl != LogLevel.OFF && lvl.isAtLeast(effectiveLevel()); } void log(LogLevel lvl, String template, Object... args) { if (!isEnabled(lvl)) return; // cheap exit: no string work Throwable error = null; int n = args.length; if (n > 0 && args[n - 1] instanceof Throwable t) { error = t; n--; } dispatch(new LogRecord(clock.instant(), lvl, name, Thread.currentThread().getName(), substitute(template, args, n), error)); } void log(LogLevel lvl, Supplier<String> message) { if (!isEnabled(lvl)) return; dispatch(new LogRecord(clock.instant(), lvl, name, Thread.currentThread().getName(), message.get(), null)); } void debug(String t, Object... a) { log(LogLevel.DEBUG, t, a); } void debug(Supplier<String> m) { log(LogLevel.DEBUG, m); } void info(String t, Object... a) { log(LogLevel.INFO, t, a); } void warn(String t, Object... a) { log(LogLevel.WARN, t, a); } void error(String t, Object... a) { log(LogLevel.ERROR, t, a); } // Hierarchy chain: offer the record to each ancestor until additivity stops it. private void dispatch(LogRecord r) { for (Logger l = this; l != null; l = l.parent) { for (Appender a : l.appenders) a.append(r); if (!l.additive) break; } } private static String substitute(String template, Object[] args, int n) { StringBuilder sb = new StringBuilder(); int from = 0, i = 0, idx; while (i < n && (idx = template.indexOf("{}", from)) >= 0) { sb.append(template, from, idx).append(args[i++]); from = idx + 2; } return sb.append(template.substring(from)).toString(); } } final class LogManager { private final Clock clock; private final Logger root; private final ConcurrentMap<String, Logger> loggers = new ConcurrentHashMap<>(); LogManager(Clock clock) { this.clock = clock; this.root = new Logger("root", null, clock).setLevel(LogLevel.INFO); } Logger root() { return root; } Logger getLogger(String name) { if (name.isEmpty()) return root; Logger existing = loggers.get(name); if (existing != null) return existing; int dot = name.lastIndexOf('.'); Logger parent = dot < 0 ? root : getLogger(name.substring(0, dot)); // create ancestors first return loggers.computeIfAbsent(name, n -> new Logger(n, parent, clock)); } void shutdown() { Set<Appender> all = Collections.newSetFromMap(new IdentityHashMap<>()); all.addAll(root.appenders()); loggers.values().forEach(l -> all.addAll(l.appenders())); all.forEach(Appender::close); } } class SteppingClock extends Clock { private final AtomicLong millis; SteppingClock(Instant start) { millis = new AtomicLong(start.toEpochMilli()); } public ZoneId getZone() { return ZoneOffset.UTC; } public Clock withZone(ZoneId zone) { return this; } public Instant instant() { return Instant.ofEpochMilli(millis.getAndAdd(5)); } } public class Main { static int expensiveCalls = 0; static String expensiveDump() { expensiveCalls++; return "cache dump with 10k entries"; } public static void main(String[] args) throws Exception { LogManager lm = new LogManager(new SteppingClock(Instant.parse("2026-10-30T09:00:00Z"))); AbstractAppender console = new StreamAppender(System.out, new PatternFormatter()); console.addFilter(r -> r.message().startsWith("GET /health") ? FilterDecision.DENY : FilterDecision.NEUTRAL); lm.root().addAppender(console); Logger db = lm.getLogger("app.db").setLevel(LogLevel.DEBUG); Logger web = lm.getLogger("app.web"); Logger pay = lm.getLogger("app.payments"); pay.addAppender(new StreamAppender(System.out, new JsonFormatter()).threshold(LogLevel.WARN)); db.debug("Opening pool size={}", 10); web.debug("Request headers {}", "hidden because app.web inherits INFO"); web.debug(() -> expensiveDump()); web.info("GET /orders took {} ms", 42); web.info("GET /health took {} ms", 1); pay.info("Charging {} for {} cents", "ord-7", 1999); pay.warn("Retrying charge {} attempt {}", "ord-7", 2); pay.error("Charge failed for {}", "ord-7", new IllegalStateException("card declined")); System.out.println("expensiveDump() calls while DEBUG off: " + expensiveCalls); web.setLevel(LogLevel.DEBUG); web.debug(() -> expensiveDump()); pay.setAdditive(false); pay.error("Refund failed for {}", "ord-8"); System.out.println("expensiveDump() calls after enabling DEBUG: " + expensiveCalls); MemoryAppender sink = new MemoryAppender(new PatternFormatter()); AsyncAppender async = new AsyncAppender(sink, 4096); Logger bench = lm.getLogger("bench").setAdditive(false).addAppender(async); ExecutorService pool = Executors.newFixedThreadPool(4, r -> new Thread(r, "worker")); for (int t = 0; t < 4; t++) { int id = t; pool.submit(() -> { for (int i = 0; i < 250; i++) bench.info("task {} event {}", id, i); }); } pool.shutdown(); pool.awaitTermination(10, TimeUnit.SECONDS); lm.shutdown(); // drains the async queue before returning List<String> lines = sink.lines(); boolean wellFormed = lines.stream().allMatch(l -> l.matches("\\d{2}:\\d{2}:\\d{2}\\.\\d{3} INFO \\[worker] bench - task \\d event \\d+")); System.out.println("async lines=" + lines.size() + " dropped=" + async.dropped() + " wellFormed=" + wellFormed); System.out.println("same logger instance: " + (lm.getLogger("app.web") == web)); } }
Demo output
09:00:00.000 DEBUG [main] app.db - Opening pool size=10
09:00:00.005 INFO [main] app.web - GET /orders took 42 ms
09:00:00.015 INFO [main] app.payments - Charging ord-7 for 1999 cents
{"ts":"2026-10-30T09:00:00.020Z","level":"WARN","logger":"app.payments","msg":"Retrying charge ord-7 attempt 2"}
09:00:00.020 WARN [main] app.payments - Retrying charge ord-7 attempt 2
{"ts":"2026-10-30T09:00:00.025Z","level":"ERROR","logger":"app.payments","msg":"Charge failed for ord-7","error":"card declined"}
09:00:00.025 ERROR [main] app.payments - Charge failed for ord-7 | IllegalStateException: card declined
expensiveDump() calls while DEBUG off: 0
09:00:00.030 DEBUG [main] app.web - cache dump with 10k entries
{"ts":"2026-10-30T09:00:00.035Z","level":"ERROR","logger":"app.payments","msg":"Refund failed for ord-8"}
expensiveDump() calls after enabling DEBUG: 1
async lines=1000 dropped=0 wellFormed=true
same logger instance: true
Walkthrough:
app.dbhas its own DEBUG level, so its debug line prints;app.webinherits INFO from the root, so its debug lines are skipped andexpensiveDump()is never called. Skipped calls create no record, so they consume no clock tick.- The
GET /healthline was enabled, so a record was created (timestamp.010), but the console's filter chain denied it;GET /orderspasses. app.paymentsINFO goes only to the root console (the JSON appender's threshold is WARN). WARN and ERROR go to both: first the JSON appender onapp.payments, then up the chain to the root console.- After
setAdditive(false), the refund error stops atapp.paymentsand appears only as JSON. - 4 threads × 250 records pass through the async appender with no drops and no interleaved lines;
shutdowndrains the queue before the counts are read.
Concurrency Considerations
Logging is called from every thread in the process, so the framework must be safe by default and fast under contention.
- Logger registry.
ConcurrentHashMap.computeIfAbsentguarantees oneLoggerper name even when threads race. Ancestors are created before the call because recursively modifying the map insidecomputeIfAbsentis illegal. - Configuration changes.
levelandadditivearevolatile, and appender and filter lists areCopyOnWriteArrayList: reads (every log call) take no lock, while rare writes copy the list. A thread iterating appenders never sees aConcurrentModificationException. - No interleaved output. Each appender serialises
writewith its own monitor. Formatting happens outside the lock to keep the critical section short. Separate appenders never block each other. - Async appender. Callers only enqueue into a bounded
ArrayBlockingQueue; a single worker thread does I/O, so writes stay ordered per appender. When the queue is full, INFO and below are dropped and counted while WARN and above wait up to a second. Choosing between blocking, dropping and discarding the oldest entries is a key trade-off to discuss. - Shutdown.
closeenqueues a poison pill after all pending records and joins the worker, so nothing already accepted is lost. Registerlm.shutdown()as a JVM shutdown hook in production. - Failure isolation. Appender exceptions are caught and reported to
stderr; a full disk must not turn into a failed checkout.
Never log while holding a lock that an appender might also need, and never let an appender call back into the logger. Both create deadlocks or infinite recursion. Real libraries guard against re-entrant logging from inside appenders.
Extensions
| Requirement | Change |
|---|---|
| File output with rotation | RollingFileAppender with a RolloverPolicy strategy (size-based, time-based); rename and reopen inside the write lock |
| Configuration from YAML or properties | A Configurator that builds loggers and appenders via a builder, with hot reload on file change |
| Request or trace IDs on every line | A thread-local MDC map copied into LogRecord at creation (copy it, since async writes happen on another thread) |
| Higher throughput | Batch writes in the async worker, or a lock-free ring buffer (the LMAX Disruptor idea behind Log4j 2 async loggers) |
| Structured logging | Key-value fields in LogRecord; JsonFormatter emits them as JSON properties |
| Remote sinks (Kafka, HTTP) | New appenders, always wrapped in AsyncAppender with retry and a circuit breaker |
| Sampling noisy logs | A RateLimitFilter (token bucket per message template) in the filter chain |
| Faster level checks | Cache the effective level per logger and invalidate descendants when a level changes |
Interview Tips
- Draw the pipeline first: logger, level check, record, hierarchy, appender, filter, formatter, sink.
- Explain why
LogRecordis immutable and captures thread and time at the call site. - Show the cheap disabled path: level check before formatting, plus
Supplierfor expensive arguments. - Name the Chain of Responsibility explicitly (hierarchy and filters) and the Decorator for async; justify each.
- Volunteer the async trade-offs: bounded queue, back-pressure versus dropping, ordering, flush on shutdown.
- Mention that a logger must never throw into business code; swallowing and reporting appender errors is a feature.
- Avoid a hard-coded singleton; one
LogManagerper application, reachable through a static facade if needed, keeps tests isolated.