Logger System Low Level Design is a pattern-rich problem that tests your understanding of the Chain of Responsibility, Singleton, and Observer patterns. It is asked at companies across all levels and serves as a reference design for the Java logging framework (java.util.logging, Log4j). This guide covers the complete Logger LLD with Java code, class diagram, and FAQ.
Why Interviewers Ask Logger System LLD
The logger problem is a pattern showcase in disguise. Interviewers want to see:
- Do you use Chain of Responsibility for log level filtering — only log above threshold?
- Can you design pluggable handlers (Console, File, Remote) without changing Logger?
- Do you use Singleton for the Logger so all classes share one instance?
- Can you design pluggable formatters (text, JSON, structured) as Strategy?
- Do you think about async logging for performance — non-blocking writes?
Functional Requirements
- Support log levels: TRACE, DEBUG, INFO, WARN, ERROR — only log at or above configured level
- Multiple handlers: ConsoleHandler (stdout), FileHandler (rolling files), RemoteHandler (HTTP)
- Multiple formatters: PlainText, JSON, Structured (key=value)
- Logger is a Singleton — application-wide single instance
- Handlers can be added or removed at runtime
- Async logging: writes should not block the calling thread
- Log record includes: level, timestamp, thread name, class name, message, exception
Non-Functional Requirements
- Log writes should add less than 1ms latency to the calling thread
- Adding a new handler (e.g., Slack alert) must not change Logger or existing handlers
- Level filtering must happen before any serialization (cheap early exit)
- Thread-safe: multiple threads logging simultaneously must not corrupt log output
Core Entities — Logger System LLD Class Design
- Logger — Singleton; root log level, list of handlers
- LogRecord — level, timestamp, threadName, className, message, throwable
- LogLevel — TRACE(0), DEBUG(1), INFO(2), WARN(3), ERROR(4)
- LogHandler — abstract; Chain of Responsibility; contains LogFormatter
- ConsoleHandler / FileHandler / RemoteHandler extend LogHandler
- LogFormatter — interface; PlainTextFormatter, JsonFormatter implement it
- AsyncLogger — wraps Logger, uses BlockingQueue + background thread
Text-Based Class Diagram
LogLevel (enum) +-- TRACE(0), DEBUG(1), INFO(2), WARN(3), ERROR(4) +-- isAtLeastAs(other): boolean LogRecord +-- level: LogLevel +-- timestamp: LocalDateTime +-- threadName, className, message: String +-- throwable: Throwable (nullable) LogFormatter (interface) +-- format(record): String PlainTextFormatter implements LogFormatter JsonFormatter implements LogFormatter LogHandler (abstract — Chain of Responsibility) +-- level: LogLevel (handler-level threshold) +-- next: LogHandler +-- formatter: LogFormatter +-- handle(record): void // check level, format, write, pass to next +-- write(formatted): void // abstract — overridden per handler ConsoleHandler extends LogHandler FileHandler extends LogHandler RemoteHandler extends LogHandler Logger (Singleton) +-- level: LogLevel (root threshold) +-- handlers: List<LogHandler> +-- log(level, message, throwable): void +-- addHandler(handler), removeHandler(handler): void
Chain of Responsibility — Log Handlers
public abstract class LogHandler {
protected LogLevel level;
protected LogHandler next;
protected LogFormatter formatter;
public LogHandler setNext(LogHandler next) {
this.next = next;
return next;
}
public void handle(LogRecord record) {
if (record.getLevel().ordinal() >= this.level.ordinal()) {
String formatted = formatter.format(record);
write(formatted);
}
if (next != null) next.handle(record); // always pass down the chain
}
protected abstract void write(String formattedMessage);
}
public class ConsoleHandler extends LogHandler {
public ConsoleHandler(LogLevel level, LogFormatter formatter) {
this.level = level;
this.formatter = formatter;
}
@Override
protected void write(String formattedMessage) {
System.out.println(formattedMessage);
}
}
public class FileHandler extends LogHandler {
private final String filePath;
private final long maxFileSizeBytes;
private final int maxBackupFiles;
private PrintWriter writer;
private long currentFileSize = 0;
public FileHandler(LogLevel level, LogFormatter formatter, String filePath,
long maxFileSizeBytes, int maxBackupFiles) throws IOException {
this.level = level;
this.formatter = formatter;
this.filePath = filePath;
this.maxFileSizeBytes = maxFileSizeBytes;
this.maxBackupFiles = maxBackupFiles;
this.writer = new PrintWriter(new FileWriter(filePath, true));
}
@Override
protected synchronized void write(String formattedMessage) {
writer.println(formattedMessage);
writer.flush();
currentFileSize += formattedMessage.length();
if (currentFileSize > maxFileSizeBytes) rotate();
}
private void rotate() {
writer.close();
// Rename current file to .1, shift older files, open fresh file
for (int i = maxBackupFiles - 1; i >= 1; i--) {
new File(filePath + "." + i).renameTo(new File(filePath + "." + (i + 1)));
}
new File(filePath).renameTo(new File(filePath + ".1"));
try { writer = new PrintWriter(new FileWriter(filePath)); } catch (IOException ignored) {}
currentFileSize = 0;
}
}Logger Singleton and Formatters
public class Logger {
private static volatile Logger instance;
private LogLevel level;
private final List<LogHandler> handlers = new CopyOnWriteArrayList<>();
private Logger(LogLevel level) { this.level = level; }
public static Logger getInstance(LogLevel level) {
if (instance == null) {
synchronized (Logger.class) {
if (instance == null) instance = new Logger(level);
}
}
return instance;
}
public void addHandler(LogHandler handler) { handlers.add(handler); }
public void removeHandler(LogHandler handler) { handlers.remove(handler); }
public void log(LogLevel level, String message, Throwable t) {
if (level.ordinal() < this.level.ordinal()) return; // early exit
LogRecord record = new LogRecord(level, LocalDateTime.now(),
Thread.currentThread().getName(), getCallerClass(), message, t);
for (LogHandler handler : handlers) {
handler.handle(record); // each handler is the head of its chain
}
}
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); }
private String getCallerClass() {
return StackWalker.getInstance().walk(frames ->
frames.skip(3).findFirst().map(f -> f.getClassName()).orElse("Unknown"));
}
}
// JSON formatter
public class JsonFormatter implements LogFormatter {
@Override
public String format(LogRecord record) {
return String.format(
"{"level":"%s","time":"%s","thread":"%s","class":"%s","message":"%s"}",
record.getLevel(), record.getTimestamp(), record.getThreadName(),
record.getClassName(), escapeJson(record.getMessage())
);
}
private String escapeJson(String s) {
return s.replace(""", "\"").replace("
", "\n");
}
}Async Logger — Non-Blocking Writes
public class AsyncLogger {
private final Logger syncLogger;
private final BlockingQueue<LogRecord> queue = new LinkedBlockingQueue<>(10_000);
private final ExecutorService worker = Executors.newSingleThreadExecutor();
public AsyncLogger(Logger syncLogger) {
this.syncLogger = syncLogger;
worker.submit(this::processQueue);
}
public void log(LogLevel level, String message, Throwable t) {
LogRecord record = new LogRecord(level, LocalDateTime.now(),
Thread.currentThread().getName(), "AsyncLogger", message, t);
if (!queue.offer(record)) {
// Queue full — drop or use overflow strategy
System.err.println("Log queue full — record dropped: " + message);
}
}
private void processQueue() {
while (!Thread.currentThread().isInterrupted()) {
try {
LogRecord record = queue.take(); // blocks until record available
syncLogger.log(record.getLevel(), record.getMessage(), record.getThrowable());
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
}Key Design Decisions
- Chain of Responsibility for handlers: Each handler decides independently whether to process a record based on its own level threshold. A WARN-level FileHandler writes only WARN and ERROR, while an INFO-level ConsoleHandler writes INFO, WARN, and ERROR. Records always pass down the full chain — each handler decides independently.
- Double-checked locking for Singleton: volatile on the instance field prevents the JVM from returning a partially constructed Logger (JIT reordering). The outer null check avoids synchronization on every call after initialization.
- CopyOnWriteArrayList for handlers: Handlers are rarely added or removed but frequently iterated. CopyOnWriteArrayList is thread-safe for concurrent reads with no locking. Handler add/remove is O(n) — acceptable for the expected handler count (2-5).
- BlockingQueue for async: The calling thread puts a record into the queue (O(1), sub-microsecond). A single background thread drains the queue and calls the synchronous handlers. This decouples logging latency from I/O latency completely.
Common Follow-Up Questions
- "How do you implement log file rotation?" — FileHandler tracks current file size. On each write, if size exceeds maxFileSizeBytes, rename app.log to app.log.1, shift older files, and open a fresh app.log. Limit maxBackupFiles to avoid unbounded disk usage.
- "How do you add a Slack alert for ERROR logs?" — Create a SlackHandler that extends LogHandler with level=ERROR. Its write() method calls the Slack webhook API. Add it to the Logger's handler list. Zero changes to any existing code.
- "How do you prevent sensitive data from appearing in logs?" — Add a SanitizingFormatter that wraps any other LogFormatter. It applies regex patterns to mask credit card numbers, passwords, and PAN numbers before passing to the underlying formatter.
FAQ — Logger System Low Level Design
What design patterns are used in Logger Framework LLD?
The primary patterns are Chain of Responsibility (log handlers filter and pass records), Singleton (one Logger instance per application), andStrategy (LogFormatter — PlainText, JSON). The Observer pattern is optionally used when handlers subscribe to level-specific event streams.
Why is the Chain of Responsibility pattern used in logging?
Multiple handlers need to process the same log record independently. Each handler has its own level threshold and formatter. The chain ensures all handlers get a chance to process the record without the Logger needing to know which handlers will act on it. Adding a handler is an append to the chain — no Logger code changes.
How do you make a logger thread-safe?
Use CopyOnWriteArrayList for the handler list (safe concurrent reads). Use double-checked locking with volatile for the Singleton. In FileHandler, synchronize the write() method so concurrent threads do not interleave their log lines. Async logger adds another layer: the calling thread only touches the BlockingQueue, which is inherently thread-safe.
How does async logging improve performance?
Synchronous logging forces the calling thread to wait for disk I/O. A disk write can take 1-10ms — at 1000 log calls/second, this adds 1-10 seconds of blocking time. Async logging offloads disk I/O to a background thread. The calling thread only adds to an in-memory queue (nanoseconds). The tradeoff: on crash, the last N records in the queue may be lost.