Design a Logger Framework
Design a pluggable, asynchronous logging framework with multiple levels, appenders, formatters, and rotation strategies — the LLD interview walkthrough.
Design a Logger Framework
Every backend service needs logging, and every logging framework you've used — Log4j, Logback, SLF4J, java.util.logging — solves the same underlying design problem. That makes it a favorite LLD interview question: it's a system every candidate has used but rarely has built, so the interviewer can probe how well you generalize from usage to design. The six-part framework applies directly here: requirements first, then actors, then a class diagram, then code, then patterns, then the concurrency/edge-case discussion that usually decides the interview.
1. Requirements
Functional requirements
- Support multiple log levels:
TRACE,DEBUG,INFO,WARN,ERROR,FATAL, each with a strict priority ordering. - Support multiple, simultaneously-active output destinations ("appenders"): console, file, database, remote HTTP endpoint.
- Support multiple output formats: plain text, JSON, XML — decoupled from where the log goes.
- Log asynchronously so that a slow appender (a flaky remote endpoint, a full disk) never blocks the calling application thread.
- Support log rotation: size-based ("roll after 10 MB") and time-based ("roll every day at midnight").
- Support filtering: by minimum level globally, and by level per package/logger name (e.g.
com.payments.*atDEBUG, everything else atINFO). - Allow runtime reconfiguration of levels and appenders without restarting the process.
Non-functional requirements
- Logging must never throw an exception that crashes the caller's business logic.
- Logging must have minimal latency impact on the calling thread (hence: async).
- Framework must be thread-safe under high-concurrency logging (thousands of threads logging simultaneously).
- Must not lose log messages silently under normal operation, and must degrade predictably (drop oldest / block / drop newest — configurable) under extreme load.
- Extensible: adding a new appender or formatter must not require modifying existing, tested classes — this is Open/Closed in practice; see SOLID Principles for the general form of this requirement.
Out of scope: distributed log aggregation (ELK/Splunk ingestion is "just another appender" from this framework's point of view), structured tracing/span correlation, log-based alerting.
2. Actors & Use Cases
Actors
- Application code — calls
logger.info(...),logger.error(...), etc. This is the only actor most engineers ever see. - Logger configuration (a human via config file, or a config-management system) — sets levels, wires appenders, sets rotation policy.
- Appender — a system-level actor that owns an I/O resource (file handle, DB connection, socket) and consumes formatted messages.
- Rotation trigger — a background timer or size-check that fires rotation.
Primary use cases
- Application code logs a message at a given level; the framework decides synchronously whether it's enabled (cheap check) and, if so, hands it off asynchronously for formatting and writing.
- Operator configures
Logger("com.payments").setLevel(DEBUG)whileLogger("com.payments.legacy").setLevel(WARN)— hierarchical, package-scoped level filtering. - A
FileAppender's current file crosses 10 MB → rotation strategy closes it, renames it with a timestamp suffix, opens a fresh file. - The async queue fills up because the DB appender is stalled on a slow connection → framework applies its configured overflow policy (block, drop, or discard-and-count) instead of an unbounded memory leak.
- A message is formatted differently per appender: the console gets human-readable text, the remote appender gets JSON.
3. Class Diagram
4. Core Class Design
Log level and message — immutable value types
public enum LogLevel {
TRACE(0), DEBUG(1), INFO(2), WARN(3), ERROR(4), FATAL(5);
private final int priority;
LogLevel(int priority) { this.priority = priority; }
public int priority() { return priority; }
}
public final class LogMessage {
private final LogLevel level;
private final String loggerName;
private final String message;
private final long timestampMillis;
private final Map<String, String> context; // MDC-style structured context
private final Throwable throwable; // nullable
public LogMessage(LogLevel level, String loggerName, String message,
Map<String, String> context, Throwable throwable) {
this.level = level;
this.loggerName = loggerName;
this.message = message;
this.timestampMillis = System.currentTimeMillis();
this.context = context == null ? Map.of() : Map.copyOf(context);
this.throwable = throwable;
}
public LogLevel level() { return level; }
public String loggerName() { return loggerName; }
public String message() { return message; }
public long timestampMillis() { return timestampMillis; }
public Map<String, String> context() { return context; }
public Throwable throwable() { return throwable; }
}Appender and Formatter — Strategy interfaces
public interface Formatter {
String format(LogMessage message);
}
public final class TextFormatter implements Formatter {
public String format(LogMessage m) {
return "%s [%s] %s - %s".formatted(
Instant.ofEpochMilli(m.timestampMillis()), m.level(), m.loggerName(), m.message());
}
}
public final class JsonFormatter implements Formatter {
public String format(LogMessage m) {
// In production: use a JSON library. Shown inline for clarity.
return """
{"ts":%d,"level":"%s","logger":"%s","msg":"%s"}"""
.formatted(m.timestampMillis(), m.level(), m.loggerName(), escape(m.message()));
}
private String escape(String s) { return s.replace("\"", "\\\""); }
}
public interface Appender {
void append(LogMessage message);
void setFormatter(Formatter formatter);
void close();
}
public final class ConsoleAppender implements Appender {
private Formatter formatter = new TextFormatter();
public void setFormatter(Formatter f) { this.formatter = f; }
public void append(LogMessage message) {
System.out.println(formatter.format(message));
}
public void close() { /* no-op: stdout isn't owned by us */ }
}
public final class FileAppender implements Appender {
private Formatter formatter = new TextFormatter();
private final RotationStrategy rotationStrategy;
private final Path filePath;
private BufferedWriter writer;
public FileAppender(Path filePath, RotationStrategy rotationStrategy) throws IOException {
this.filePath = filePath;
this.rotationStrategy = rotationStrategy;
this.writer = Files.newBufferedWriter(filePath, StandardOpenOption.CREATE, StandardOpenOption.APPEND);
}
public void setFormatter(Formatter f) { this.formatter = f; }
public synchronized void append(LogMessage message) {
try {
if (rotationStrategy.shouldRotate(filePath.toFile())) {
writer.close();
rotationStrategy.rotate(filePath.toFile());
writer = Files.newBufferedWriter(filePath, StandardOpenOption.CREATE, StandardOpenOption.APPEND);
}
writer.write(formatter.format(message));
writer.newLine();
writer.flush();
} catch (IOException e) {
// Never let a logging failure propagate into application logic.
System.err.println("FileAppender failed: " + e.getMessage());
}
}
public synchronized void close() {
try { writer.close(); } catch (IOException ignored) { }
}
}Filter chain — Chain of Responsibility
public abstract class LogFilter {
private LogFilter next;
public LogFilter setNext(LogFilter next) { this.next = next; return next; }
public final boolean shouldLog(LogMessage message) {
if (!accept(message)) return false;
return next == null || next.shouldLog(message);
}
protected abstract boolean accept(LogMessage message);
}
public final class LevelFilter extends LogFilter {
private final LogLevel minLevel;
public LevelFilter(LogLevel minLevel) { this.minLevel = minLevel; }
protected boolean accept(LogMessage m) { return m.level().priority() >= minLevel.priority(); }
}
public final class PackageFilter extends LogFilter {
private final String packagePrefix;
private final LogLevel minLevel;
public PackageFilter(String packagePrefix, LogLevel minLevel) {
this.packagePrefix = packagePrefix;
this.minLevel = minLevel;
}
protected boolean accept(LogMessage m) {
if (!m.loggerName().startsWith(packagePrefix)) return true; // not our concern, pass through
return m.level().priority() >= minLevel.priority();
}
}Logger and factory
public final class Logger {
private final String name;
private volatile LogLevel level;
private final AsyncLogProcessor processor;
Logger(String name, LogLevel level, AsyncLogProcessor processor) {
this.name = name;
this.level = level;
this.processor = processor;
}
public void setLevel(LogLevel level) { this.level = level; }
public boolean isEnabled(LogLevel candidate) { return candidate.priority() >= level.priority(); }
public void trace(String msg) { log(LogLevel.TRACE, msg, null); }
public void debug(String msg) { log(LogLevel.DEBUG, msg, null); }
public void info(String msg) { log(LogLevel.INFO, msg, null); }
public void warn(String msg) { log(LogLevel.WARN, msg, null); }
public void error(String msg, Throwable t) { log(LogLevel.ERROR, msg, t); }
public void fatal(String msg, Throwable t) { log(LogLevel.FATAL, msg, t); }
private void log(LogLevel level, String msg, Throwable t) {
if (!isEnabled(level)) return; // cheap check on the caller's thread, no allocation
LogMessage message = new LogMessage(level, name, msg, MDC.getContext(), t);
processor.enqueue(message); // hands off — caller thread returns immediately
}
}
public final class LoggerFactory {
private static final LoggerFactory INSTANCE = new LoggerFactory();
private final Map<String, Logger> loggers = new ConcurrentHashMap<>();
private final AsyncLogProcessor sharedProcessor;
private LoggerFactory() {
this.sharedProcessor = new AsyncLogProcessor(10_000, OverflowPolicy.DROP_OLDEST);
this.sharedProcessor.start();
}
public static Logger getLogger(Class<?> clazz) { return getLogger(clazz.getName()); }
public static Logger getLogger(String name) {
return INSTANCE.loggers.computeIfAbsent(name,
n -> new Logger(n, LogLevel.INFO, INSTANCE.sharedProcessor));
}
}LoggerFactory.getLogger(...) is a textbook Singleton-backed registry: exactly one AsyncLogProcessor and one queue serve every Logger instance in the process, so ordering and backpressure are process-wide, not per-logger. ConcurrentHashMap.computeIfAbsent gives thread-safe, allocate-once-per-name registration without an explicit lock.
5. Design Patterns Applied
| Pattern | Where used | Why |
|---|---|---|
| Strategy | Formatter (Text/Json/Xml), RotationStrategy (size/time) | Output format and rotation policy vary independently of the logging call site; new variants are new classes, zero edits elsewhere. |
| Observer (fan-out) | Logger → multiple Appenders | One log call notifies every registered appender; appenders don't know about each other. |
| Chain of Responsibility | LogFilter linked list (LevelFilter → PackageFilter → ...) | Each filter independently vetoes a message; new filter criteria compose without touching existing filters. |
| Singleton | LoggerFactory | Exactly one shared async processor and logger registry per process — matches how Log4j/Logback/SLF4J actually work. |
| Producer-Consumer | Logger.log() (producer) → BlockingQueue → AsyncLogProcessor worker (consumer) | Decouples the fast application thread from slow I/O-bound appenders. |
| Factory Method | LoggerFactory.getLogger(...) | Centralizes Logger construction and caching; callers never call new Logger(...) directly. |
| Decorator (optional extension) | Wrapping an Appender with a BufferedAppender or RetryingAppender | Adds buffering/retry behavior to any appender without subclassing each one. |
6. Key Algorithms, Concurrency & Edge Cases
Async logging: bounded producer-consumer queue
The single most important design decision is that Logger.log() never blocks on I/O. It enqueues onto a bounded BlockingQueue and returns; a dedicated worker thread (or small pool) drains the queue and does the actual formatting + I/O.
public final class AsyncLogProcessor {
private final BlockingQueue<LogMessage> queue;
private final List<Appender> appenders = new CopyOnWriteArrayList<>();
private final OverflowPolicy overflowPolicy;
private final AtomicLong droppedCount = new AtomicLong();
private volatile boolean running = true;
private Thread worker;
public AsyncLogProcessor(int capacity, OverflowPolicy overflowPolicy) {
this.queue = new ArrayBlockingQueue<>(capacity);
this.overflowPolicy = overflowPolicy;
}
public void addAppender(Appender appender) { appenders.add(appender); }
public void enqueue(LogMessage message) {
boolean offered = queue.offer(message); // non-blocking by default
if (!offered) {
switch (overflowPolicy) {
case BLOCK -> {
try { queue.put(message); } catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
case DROP_NEWEST -> droppedCount.incrementAndGet(); // silently discard this message
case DROP_OLDEST -> {
queue.poll(); // evict the head
queue.offer(message); // retry
droppedCount.incrementAndGet();
}
}
}
}
public void start() {
worker = new Thread(this::run, "log-async-worker");
worker.setDaemon(true); // never block JVM shutdown
worker.start();
}
private void run() {
while (running || !queue.isEmpty()) {
try {
LogMessage message = queue.poll(500, TimeUnit.MILLISECONDS);
if (message == null) continue;
for (Appender appender : appenders) {
try {
appender.append(message); // isolate one bad appender from the rest
} catch (Exception e) {
System.err.println("Appender failed: " + e.getMessage());
}
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
public void shutdown() {
running = false;
try { worker.join(2000); } catch (InterruptedException ignored) { }
appenders.forEach(Appender::close);
}
}
public enum OverflowPolicy { BLOCK, DROP_NEWEST, DROP_OLDEST }Overflow policy trade-off: BLOCK guarantees zero message loss but risks slowing down (or, under sustained overload, stalling) application threads — defeating the purpose of async logging. DROP_NEWEST/DROP_OLDEST guarantee the app is never blocked but silently lose messages; production frameworks (Log4j's AsyncAppender) default to a bounded queue with drop-and-count so operators can alert on droppedCount > 0 rather than lose data invisibly.
Log rotation
public final class SizeBasedRotation implements RotationStrategy {
private final long maxBytes;
public SizeBasedRotation(long maxBytes) { this.maxBytes = maxBytes; }
public boolean shouldRotate(File file) { return file.exists() && file.length() >= maxBytes; }
public void rotate(File file) {
String timestamp = DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss").format(LocalDateTime.now());
File rolled = new File(file.getParent(), file.getName() + "." + timestamp);
file.renameTo(rolled);
}
}
public final class TimeBasedRotation implements RotationStrategy {
private volatile LocalDate lastRotatedDate = LocalDate.now();
public boolean shouldRotate(File file) { return !LocalDate.now().equals(lastRotatedDate); }
public void rotate(File file) {
File rolled = new File(file.getParent(), file.getName() + "." + lastRotatedDate);
file.renameTo(rolled);
lastRotatedDate = LocalDate.now();
}
}shouldRotate is checked on every append call in FileAppender above — cheap (a File.length() syscall or a date comparison), so it doesn't need a separate background thread, though production systems often also run a periodic sweep to rotate idle files that haven't received a write in a while.
Thread safety concerns
Logger.levelisvolatile— reconfiguration from another thread (an admin endpoint) must be visible immediately to logging threads without a full lock.FileAppender.appendissynchronizedbecause theWriterand rotation check-and-act are not otherwise atomic — two threads could both observe "should rotate" and both attempt to rename the file.AsyncLogProcessor.appendersis aCopyOnWriteArrayListsince appenders are added rarely (at startup/reconfig) but iterated on every single log message — optimizing for the read-heavy case.- A crash inside one
Appender.append()must never stop other appenders from receiving the message or kill the worker thread — hence the per-appendertry/catchinside the drain loop.
Edge cases
- MDC (Mapped Diagnostic Context) leak across thread-pool threads: if request-scoped context (
requestId,userId) is stored in aThreadLocal, a thread-pool worker must clear it after each task or a subsequent unrelated request inherits stale context. - Logging during shutdown: the worker thread should drain the queue (with a timeout) before the JVM exits, or final log lines are lost — hence
shutdown()joins the worker rather than just settingrunning = false. - Recursive logging: an appender that itself logs on failure (e.g.,
DBAppenderlogging a connection error) can recurse infinitely if it uses the same logger; production frameworks route internal framework errors to a separate, appender-free "status logger." - Clock skew across a fleet: timestamps should be generated at enqueue time (on the caller's thread), not at append time, so queueing delay doesn't distort the recorded time of the event — reflected above by stamping
timestampMillisin theLogMessageconstructor, not in the appender.
7. Trade-offs & Extensions
| Decision | Trade-off |
|---|---|
| Async by default | Lower latency for callers, but risk of message loss under overflow and harder-to-debug "log ordering" issues across appenders if multiple worker threads are used. |
| One shared queue/worker vs. per-appender queues | Shared: simpler, bounded total memory. Per-appender: a slow RemoteAppender can't cause backpressure that also delays the ConsoleAppender; costs more memory and threads. |
| Package-hierarchy level filtering | Matches real frameworks (Log4j/Logback) and lets you dial verbosity for one module in production; adds lookup complexity (walk the package prefix chain, cache resolved levels). |
| Synchronous fallback mode | A "flush and block" mode for FATAL-level errors right before a crash guarantees the last message is written, at the cost of occasionally blocking. |
Natural extensions:
- Structured logging: replace the free-text
messagewith a structured event object;JsonFormatterbecomes the primary formatter, text becomes a rendering of the structured event. - Sampling: an appender-level
SamplingFilterthat logs only 1-in-NDEBUGmessages under high load, while always loggingWARN+. - Correlation IDs and distributed tracing: thread the same
requestIdthroughMDCand propagate it across service boundaries via headers. - Metrics: expose
droppedCount, queue depth, and per-level counts as framework health metrics — turns the logger itself into an observable component.
Interview Questions
- Why does the logging call on the application thread need to stay non-blocking, and what's the failure mode if it doesn't?
- Walk through what happens end-to-end when
logger.error("...")is called: which checks happen synchronously vs. asynchronously? - How would you implement package-hierarchy level filtering (
com.payments.*atDEBUG, defaultINFO) efficiently, given that logger names form a tree? - What are the trade-offs between
BLOCK,DROP_NEWEST, andDROP_OLDESToverflow policies for the async queue? - How do you guarantee no message is silently lost during a clean JVM shutdown, while still using an async queue?
- Where would you apply the Chain of Responsibility pattern in this design, and why not just a single method with several
ifchecks? - How would you extend this design to support sampling debug logs at 1% under high load without losing all
WARN/ERRORmessages?