Low Level Design
Design a Logging Framework
A complete low-level design walkthrough for a custom logging framework — from requirements clarification to Singleton, Strategy, and Builder patterns, with async log processing and pluggable appenders in Java.
Problem Description#
Design a Logging Framework that can capture, filter, format, and route log messages to multiple output destinations from any part of an application.
Logging is one of those cross-cutting concerns that every real system needs, yet it's surprisingly rich in design decisions:
- Log messages must be filtered by severity so that debug noise doesn't flood production
- The same message may need to reach multiple outputs — console for developers, a file for ops, a database for audit
- Writing to slow outputs (disk, network) must never block the calling thread
- The framework must be safe when many threads log simultaneously
This is a great LLD interview problem because it naturally exercises the Singleton, Strategy, Builder, and Producer–Consumer patterns in a domain every engineer already understands.
Clarify Requirements#
Before designing anything, ask these questions in an interview:
Functional
- What log levels are needed? (DEBUG, INFO, WARN, ERROR?)
- Can severity levels be filtered globally and per-logger independently?
- What output destinations are needed — console, file, database?
- Should a child logger inherit its parent's appenders?
- Does each appender need its own formatting, or is there one global format?
- Should loggers be named (e.g. com.example.service) to form a hierarchy?
Non-functional
- Must logging be non-blocking to the calling thread?
- Is thread safety required for concurrent writes?
- Should the framework be extensible to new log levels and appenders without touching existing code?
Final Requirements#
After clarification, here's what we'll build:
- Four log levels — DEBUG, INFO, WARN, ERROR — with numeric severity for filtering
- Logger hierarchy — dot-separated names form a parent chain; a child without a configured level inherits its nearest ancestor's level
- Hierarchy propagation — a log event walks up the logger chain and is dispatched to every appender it encounters (child → parent → root)
- Two appenders — ConsoleAppender (stdout) and FileAppender (disk); each carries its own LogFormatter
- Strategy formatters — LogFormatter interface with a TextFormatter implementation; swap formats per-appender without modifying appenders
- Async processing — AsyncLogProcessor wraps a single-threaded ExecutorService; callers never block on I/O
- LogMessage Builder — immutable log event constructed via a fluent builder with required-field validation
- LoggingManager Singleton — double-checked locking; central registry for all loggers and the async processor; orchestrates graceful shutdown
Core Entities#
| Entity | Responsibility |
|---|---|
| LogLevel | Enum defining severity (DEBUG=1 … ERROR=4) with isGreaterOrEqual() for filtering |
| LogMessage | Immutable model — level, message, logger name, timestamp; constructed via inner Builder |
| LogFormatter | Interface — format(LogMessage) String; enables pluggable output formats |
| TextFormatter | Concrete strategy — formats messages as "timestamp LEVEL - loggerName: message" |
| LogAppender | Interface — append(LogMessage) + close(); pluggable output destination |
| ConsoleAppender | Writes formatted messages to System.out; holds a LogFormatter |
| FileAppender | Writes formatted messages to a file via FileWriter; holds a LogFormatter; thread-safe append |
| Logger | Core logging unit — filters by effective level, holds appenders, propagates to parent |
| AsyncLogProcessor | Wraps a single-threaded ExecutorService; submits appender calls off the calling thread |
| LoggingManager | Singleton facade — creates / caches loggers, owns the processor, manages shutdown |
Patterns Used#
1. Singleton — LoggingManager#
There must be exactly one logging configuration in the process — one registry of loggers, one async processor, one shutdown hook. LoggingManager uses double-checked locking with a volatile instance field: the outer null-check keeps the common path lock-free; the inner check inside synchronized closes the race window.
LoggingManager (volatile instance)
└─ loggers : Map<String, Logger> ← shared registry
└─ rootLogger ← default parent for all loggers
└─ AsyncLogProcessor ← single processor, owned here
2. Strategy — LogAppender and LogFormatter#
Appenders and formatters are fully interchangeable. ConsoleAppender and FileAppender both implement LogAppender; TextFormatter (and any future JsonFormatter) implements LogFormatter. A logger or appender depends only on the interface — adding a new output (Kafka, database) or format (JSON, XML) touches zero existing code.
This is Open/Closed in action: the framework is open for extension (new appender, new formatter) and closed for modification.
3. Builder — LogMessage#
LogMessage is immutable: once constructed its fields never change. The inner Builder provides a fluent API, enforces that logLevel and message are set before build() is called, and avoids a telescoping constructor. Immutability is also what makes it safe to hand a LogMessage to the async processor without copying or synchronizing.
4. Composite-like Hierarchy — Logger with parent reference#
Each Logger holds a reference to its parent (determined at creation time from the dot-separated name). When a log event passes the level filter, callAppender walks the chain from child to root, dispatching to every appender it finds. This mirrors Log4j's propagation model and means a single root ConsoleAppender covers all loggers without configuring each one individually.
5. Producer–Consumer — AsyncLogProcessor#
The calling thread (producer) hands a LogMessage to AsyncLogProcessor.process(); the single background thread (consumer) pulls tasks from the executor queue and calls each appender. The calling thread is never blocked by slow I/O. Using a single-threaded executor also guarantees that log messages from any source are written in the order they were submitted — no interleaving.
Code#
Models — LogLevel and LogMessage#
LogLevel defines severity with a numeric field for comparison. LogMessage is the immutable event object produced once per log call.
public enum LogLevel {
DEBUG(1), INFO(2), WARN(3), ERROR(4);
private final int level;
LogLevel(int level) {
this.level = level;
}
public boolean isGreaterOrEqual(LogLevel other) {
return this.level >= other.level;
}
}import java.time.LocalDateTime;
public class LogMessage {
private final LogLevel logLevel;
private final String message;
private final LocalDateTime timestamp;
private final String loggerName;
private LogMessage(Builder builder) {
this.logLevel = builder.logLevel;
this.message = builder.message;
this.loggerName = builder.loggerName;
this.timestamp = builder.timestamp;
}
public LogLevel getLogLevel() { return logLevel; }
public String getMessage() { return message; }
public LocalDateTime getTimestamp() { return timestamp; }
public String getLoggerName() { return loggerName; }
public static class Builder {
private LocalDateTime timestamp = LocalDateTime.now();
private LogLevel logLevel;
private String message;
private String loggerName;
public Builder logLevel(LogLevel level) { this.logLevel = level; return this; }
public Builder message(String message) { this.message = message; return this; }
public Builder timestamp(LocalDateTime ts) { this.timestamp = ts; return this; }
public Builder logger(String loggerName) { this.loggerName = loggerName; return this; }
public LogMessage build() {
if (logLevel == null)
throw new IllegalStateException("LogLevel is required");
if (message == null || message.trim().isEmpty())
throw new IllegalStateException("Message is required");
return new LogMessage(this);
}
}
}Strategy — Formatter and Appenders#
LogFormatter is the formatting contract; TextFormatter is its default implementation. LogAppender is the output contract; ConsoleAppender and FileAppender cover the two common destinations.
public interface LogFormatter {
String format(LogMessage message);
}import java.time.format.DateTimeFormatter;
public class TextFormatter implements LogFormatter {
private static final DateTimeFormatter DTF =
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS");
@Override
public String format(LogMessage message) {
return String.format("%s %s - %s: %s%n",
message.getTimestamp().format(DTF),
message.getLogLevel(),
message.getLoggerName(),
message.getMessage());
}
}public interface LogAppender {
void append(LogMessage message);
void close();
}public class ConsoleAppender implements LogAppender {
private LogFormatter logFormatter;
public ConsoleAppender(LogFormatter formatter) {
this.logFormatter = formatter;
}
@Override
public void append(LogMessage logMessage) {
System.out.print(logFormatter.format(logMessage));
}
@Override
public void close() { /* stdout needs no cleanup */ }
}import java.io.File;
import java.io.FileWriter;
import java.io.IOException;
public class FileAppender implements LogAppender {
private LogFormatter logFormatter;
private final FileWriter fileWriter;
public FileAppender(LogFormatter formatter, String filePath) {
this.logFormatter = formatter;
try {
File file = new File(filePath);
if (file.getParentFile() != null) file.getParentFile().mkdirs();
this.fileWriter = new FileWriter(file, true /* append */);
} catch (IOException e) {
throw new RuntimeException("Failed to open log file: " + filePath, e);
}
}
@Override
public synchronized void append(LogMessage logMessage) {
try {
fileWriter.write(logFormatter.format(logMessage));
fileWriter.flush();
} catch (IOException e) {
System.err.println("FileAppender write failed: " + e.getMessage());
}
}
@Override
public void close() {
try { fileWriter.close(); }
catch (IOException e) { System.err.println("FileAppender close failed: " + e.getMessage()); }
}
}Core — AsyncLogProcessor and Logger#
AsyncLogProcessor decouples log generation from I/O. Logger handles level filtering and hierarchy propagation.
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
public class AsyncLogProcessor {
private final ExecutorService executor;
public AsyncLogProcessor() {
this.executor = Executors.newSingleThreadExecutor(r -> {
Thread t = new Thread(r, "AsyncLogProcessor");
t.setDaemon(true);
return t;
});
}
public void process(LogMessage logMessage, List<LogAppender> appenders) {
if (executor.isShutdown()) {
System.err.println("Logger is shut down — cannot process logs");
return;
}
executor.submit(() -> {
for (LogAppender appender : appenders) {
appender.append(logMessage);
}
});
}
public void stop() {
executor.shutdown();
try {
if (!executor.awaitTermination(2, TimeUnit.SECONDS)) {
System.err.println("Async processor did not finish in time");
executor.shutdownNow();
}
} catch (InterruptedException e) {
executor.shutdownNow();
Thread.currentThread().interrupt();
}
}
}import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
public class Logger {
private final String name;
private LogLevel logLevel; // null = inherit from parent
private final Logger parent;
private final List<LogAppender> appenderList;
Logger(String name, Logger parent) {
this.name = name;
this.parent = parent;
this.appenderList = new CopyOnWriteArrayList<>();
}
public void setLogLevel(LogLevel level) { this.logLevel = level; }
public List<LogAppender> getAppenderList() { return appenderList; }
public void addAppender(LogAppender appender) {
appenderList.add(appender);
}
/** Walk up to the first ancestor with a configured level; default DEBUG. */
public LogLevel getEffectiveLevel() {
for (Logger l = this; l != null; l = l.parent) {
if (l.logLevel != null) return l.logLevel;
}
return LogLevel.DEBUG;
}
public void log(LogLevel level, String message) {
if (!level.isGreaterOrEqual(getEffectiveLevel())) return;
LogMessage logMessage = new LogMessage.Builder()
.logLevel(level)
.message(message)
.logger(this.name)
.build();
callAppender(logMessage);
}
/** Propagate up the logger chain, dispatching to each ancestor's appenders. */
private void callAppender(LogMessage message) {
for (Logger current = this; current != null; current = current.parent) {
if (!current.appenderList.isEmpty()) {
LoggingManager.getInstance()
.getProcessor()
.process(message, current.appenderList);
}
}
}
public void debug(String message) { log(LogLevel.DEBUG, message); }
public void info(String message) { log(LogLevel.INFO, message); }
public void warn(String message) { log(LogLevel.WARN, message); }
public void error(String message) { log(LogLevel.ERROR, message); }
}Singleton — LoggingManager#
Central registry that builds the logger hierarchy and owns the async processor.
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
public class LoggingManager {
private static volatile LoggingManager instance;
private final Map<String, Logger> loggers;
private final Logger rootLogger;
private final AsyncLogProcessor processor;
private LoggingManager() {
this.loggers = new ConcurrentHashMap<>();
this.rootLogger = new Logger("root", null);
this.loggers.put("root", rootLogger);
this.processor = new AsyncLogProcessor();
}
public static LoggingManager getInstance() {
if (instance == null) {
synchronized (LoggingManager.class) {
if (instance == null) instance = new LoggingManager();
}
}
return instance;
}
public Logger getRootLogger() { return rootLogger; }
public AsyncLogProcessor getProcessor() { return processor; }
public Logger getLogger(String name) {
Logger existing = loggers.get(name);
if (existing != null) return existing;
synchronized (this) {
existing = loggers.get(name);
if (existing != null) return existing;
Logger newLogger = createLogger(name);
loggers.put(name, newLogger);
return newLogger;
}
}
private Logger createLogger(String name) {
if (name.equals("root")) return rootLogger;
int lastDot = name.lastIndexOf('.');
String parentName = (lastDot == -1) ? "root" : name.substring(0, lastDot);
Logger parent = getLogger(parentName);
return new Logger(name, parent);
}
public void shutdown() {
processor.stop();
loggers.values().stream()
.flatMap(l -> l.getAppenderList().stream())
.distinct()
.forEach(LogAppender::close);
System.out.println("Logging framework shut down gracefully.");
}
}
Demo#
public class LoggingDemo {
public static void main(String[] args) {
LoggingManager manager = LoggingManager.getInstance();
// Root: INFO level → console
Logger root = manager.getRootLogger();
root.setLogLevel(LogLevel.INFO);
root.addAppender(new ConsoleAppender(new TextFormatter()));
// com.example.main inherits INFO from root
Logger mainLogger = manager.getLogger("com.example.main");
mainLogger.info("Application starting");
mainLogger.debug("This debug message is filtered out"); // below INFO
mainLogger.warn("Low disk space warning");
// com.example.db overrides to DEBUG
Logger dbLogger = manager.getLogger("com.example.db");
dbLogger.setLogLevel(LogLevel.DEBUG);
dbLogger.debug("Executing query — SELECT * FROM users");
dbLogger.info("DB connection established");
// com.example.service also writes to a file
Logger serviceLogger = manager.getLogger("com.example.service");
serviceLogger.addAppender(new FileAppender(new TextFormatter(), "logs/service.log"));
serviceLogger.info("Payment service started");
serviceLogger.warn("Retry attempt 1 of 3");
manager.shutdown();
}
}
Class Diagram#
Extendible — Follow Ups#
1. JSON formatter#
Add JsonFormatter implements LogFormatter that produces {"timestamp":"…","level":"INFO","logger":"…","message":"…"}. Attach it to a FileAppender without touching any existing class. Demonstrates pure Open/Closed: new format, zero changes to existing code.
2. Log level FATAL and runtime level changes#
Add FATAL(5) to the enum and expose LoggingManager.setRootLevel(LogLevel) so ops can switch from INFO to DEBUG at runtime without restarting the service. Because getEffectiveLevel() reads the field on every call, the change takes effect immediately across all child loggers.
3. Rolling file appender#
Extend FileAppender into RollingFileAppender that rotates the log file when it exceeds a size threshold (e.g. 10 MB) or at midnight, renaming the old file with a date suffix and opening a fresh one. Callers hold a LogAppender reference — no changes needed anywhere else.
4. Async batch writes#
Replace the per-message executor.submit() in AsyncLogProcessor with a BlockingQueue<Runnable> and a background thread that drains the queue in configurable batches. Batch writes to disk are far cheaper than one flush() per message when log volume is high.
5. NDC / MDC context propagation#
Add a ThreadLocal<Map<String, String>> Mapped Diagnostic Context to Logger. TextFormatter.format() appends the thread's MDC (e.g. requestId, userId) to every message automatically. Invaluable for correlating logs across async boundaries in a web service.
6. Appender filters#
Add a LogFilter interface to LogAppender — boolean accept(LogMessage). A FileAppender could be configured to only write ERROR and above while the root ConsoleAppender still shows everything. This avoids duplicating logger-level configuration per appender.