Design a Logger
Log levels, formatters, and appenders in a handler chain — Amazon, Microsoft, and backend LLD rounds love this Chain of Responsibility classic.
Logger exposes debug/info/warn/error. Each call builds a LogRecord and passes it down a LogHandler chain. Handlers filter by LogLevel, Formatter shapes output, ConsoleAppender / FileAppender write sinks. Classic Chain of Responsibility.
The interview question
“Design a logging library like log4j/slf4j.” Support log levels, pluggable appenders (console, file), and formatted messages.
Interviewers want Chain of Responsibility called out explicitly and clean separation: record creation → filter → format → append.
Clarifying questions (ask before coding)
Requirements & assumptions (state aloud)
- API:
logger.info("msg"),logger.error("msg", exception). - LogRecord: level, message, timestamp, optional throwable.
- Handlers: each has min level; passes to next handler in chain.
- Formatters: plain text v1; JSON formatter as extension.
- v1: synchronous, single process; no rotation or remote shipping.
High-level design
Application calls Logger → builds LogRecord → head of LogHandler chain.
Each handler: if level >= threshold, format and append; always forward to next (or stop if designed as filter-only).
ConsoleAppender writes to stdout; FileAppender writes to file with shared Formatter.
LogLevelenum with orderingLogHandler— setNext + handle(record)ConsoleAppender/FileAppender+Formatter
UML class diagram (optional)
Show LogHandler interface with next pointer and two concrete appenders — classic CoR diagram.
Design patterns used (and why)
| Pattern | Where | Why (interview soundbite) |
|---|---|---|
| ⛓️Chain of Responsibility | LogHandler linked list | Each handler decides to process and/or forward — add appenders without changing Logger. |
| 🎯Strategy | Formatter implementations | Swap text vs JSON formatting independently of sink. |
| 🏭Factory (optional) | LoggerFactory | Configure chain once per app/module. |
Full solution — record & formatter
import java.time.Instant;
public enum LogLevel {
DEBUG(0), INFO(1), WARN(2), ERROR(3);
private final int priority;
// Assigns numeric priority for level comparison
LogLevel(int priority) {
this.priority = priority;
}
// True if this level is at least as severe as other
public boolean isAtLeast(LogLevel other) {
return this.priority >= other.priority;
}
}
public record LogRecord(
LogLevel level,
String message,
Instant timestamp,
Throwable cause) {
// Factory for simple message without exception
public static LogRecord of(LogLevel level, String message) {
return new LogRecord(level, message, Instant.now(), null);
}
// Factory attaching stack trace source for errors
public static LogRecord of(LogLevel level, String message, Throwable cause) {
return new LogRecord(level, message, Instant.now(), cause);
}
}public interface Formatter {
// Converts a log record to output string (no trailing newline required)
String format(LogRecord record);
}
public class PlainTextFormatter implements Formatter {
// Human-readable line: timestamp LEVEL message
@Override
public String format(LogRecord record) {
String base = record.timestamp() + " [" + record.level() + "] " + record.message();
if (record.cause() == null) return base;
return base + " | " + record.cause().toString();
}
}
public class JsonFormatter implements Formatter {
// Structured single-line JSON for log aggregators
@Override
public String format(LogRecord record) {
return "{\"ts\":\"" + record.timestamp() + "\",\"level\":\"" + record.level()
+ "\",\"msg\":\"" + escape(record.message()) + "\"}";
}
// Escapes quotes in message for valid JSON
private String escape(String s) {
return s.replace("\\", "\\\\").replace("\"", "\\\"");
}
}Full solution — handler chain & logger
import java.io.*;
import java.nio.file.*;
public abstract class LogHandler {
private LogHandler next;
protected final LogLevel minLevel;
protected final Formatter formatter;
// Base handler with level filter and formatter
protected LogHandler(LogLevel minLevel, Formatter formatter) {
this.minLevel = minLevel;
this.formatter = formatter;
}
// Links the next handler in the chain
public LogHandler setNext(LogHandler next) {
this.next = next;
return next;
}
// Entry point: handle locally then forward to next
public void handle(LogRecord record) {
if (minLevel.isAtLeast(record.level()))
write(formatter.format(record));
if (next != null)
next.handle(record);
}
// Subclass writes formatted line to its sink
protected abstract void write(String formattedLine);
// Flushes/closes resources; override in file appender
public void close() {}
}
public class ConsoleAppender extends LogHandler {
// Logs to standard output at or above minLevel
public ConsoleAppender(LogLevel minLevel, Formatter formatter) {
super(minLevel, formatter);
}
// Prints one formatted log line to console
@Override
protected void write(String formattedLine) {
System.out.println(formattedLine);
}
}
public class FileAppender extends LogHandler {
private final BufferedWriter writer;
// Opens file for append; creates parent dirs if needed
public FileAppender(LogLevel minLevel, Formatter formatter, Path path) throws IOException {
super(minLevel, formatter);
Files.createDirectories(path.getParent());
this.writer = Files.newBufferedWriter(path, StandardOpenOption.CREATE, StandardOpenOption.APPEND);
}
// Writes formatted line to file with flush for durability
@Override
protected synchronized void write(String formattedLine) {
try {
writer.write(formattedLine);
writer.newLine();
writer.flush();
} catch (IOException e) {
System.err.println("FileAppender failed: " + e.getMessage());
}
}
// Closes underlying file writer
@Override
public void close() {
try { writer.close(); } catch (IOException ignored) {}
}
}public class Logger {
private final String name;
private final LogHandler chainHead;
private LogLevel level = LogLevel.DEBUG;
// Binds logger name to the first handler in the chain
public Logger(String name, LogHandler chainHead) {
this.name = name;
this.chainHead = chainHead;
}
// Drops messages below configured logger level
public void setLevel(LogLevel level) {
this.level = level;
}
// Emits DEBUG record if enabled
public void debug(String message) {
log(LogLevel.DEBUG, message, null);
}
// Emits INFO record if enabled
public void info(String message) {
log(LogLevel.INFO, message, null);
}
// Emits WARN record if enabled
public void warn(String message) {
log(LogLevel.WARN, message, null);
}
// Emits ERROR record with optional throwable
public void error(String message, Throwable cause) {
log(LogLevel.ERROR, message, cause);
}
// Builds LogRecord and dispatches into handler chain
private void log(LogLevel msgLevel, String message, Throwable cause) {
if (!msgLevel.isAtLeast(level)) return;
LogRecord record = cause == null
? LogRecord.of(msgLevel, "[" + name + "] " + message)
: LogRecord.of(msgLevel, "[" + name + "] " + message, cause);
chainHead.handle(record);
}
}
public class LoggerDemo {
// Chains console (INFO+) and file (ERROR+) appenders
public static void main(String[] args) throws Exception {
Formatter fmt = new PlainTextFormatter();
LogHandler console = new ConsoleAppender(LogLevel.INFO, fmt);
LogHandler file = new FileAppender(LogLevel.ERROR, fmt, Path.of("logs/app.log"));
console.setNext(file);
Logger log = new Logger("OrderService", console);
log.debug("won't print"); // below logger level default passes but console filters DEBUG if min INFO
log.info("order created");
log.error("payment failed", new RuntimeException("timeout"));
file.close();
}
}While you code — questions interviewers ask
Extensions & follow-ups
- Async appender with bounded queue and drop policy.
- MDC (mapped diagnostic context) for traceId in Formatter.
- Remote appender (Kafka, CloudWatch) as another handler.
- Logger hierarchy inheriting levels from parent package logger.
Common mistakes (fail signals)
- God class Logger that both formats and writes file — no separation.
- Forgetting to forward to
nexthandler — breaks multi-appender setup. - No level ordering — string compare on enum names fails.
- Unsynchronized FileAppender corrupting lines under concurrency.