Problem 10: Design a Logger
The setup
"Design a logging library. Multiple log levels (DEBUG, INFO, WARN, ERROR). Multiple destinations (console, file, network). Configurable per-component log level. Thread-safe. Possibly async."
This problem demonstrates the Chain of Responsibility pattern (level filtering), Strategy pattern (output destinations), and Singleton (logger instance). It's also a good lens on concurrency for shared resources.
Clarifying questions
| Question | Story answer |
|---|---|
| Log format — structured (JSON) or plain text? | Configurable; default plain text. |
| Async logging? | Yes — synchronous logging in the request path is a latency tax. |
| Per-class log levels? | Yes — DEBUG for one class, INFO globally. |
| Log rotation for files? | Mention briefly; not core. |
Core entities
enum LogLevel {
DEBUG(0), INFO(1), WARN(2), ERROR(3);
int priority;
}
class LogMessage {
Instant timestamp;
LogLevel level;
String message;
String loggerName; // typically class name
Map context; // structured fields
Throwable exception;
}
interface Appender {
void append(LogMessage msg);
}
interface Formatter {
String format(LogMessage msg);
}
class Logger {
String name;
LogLevel minLevel;
List appenders;
void log(LogLevel level, String message, Throwable t) { ... }
void debug(String msg) { log(DEBUG, msg, null); }
void info(String msg) { log(INFO, msg, null); }
// ... etc.
}
Design Decision 1: Multiple appenders (Strategy + Composite)
class Logger {
public void log(LogLevel level, String message, Throwable t) {
if (level.priority < minLevel.priority) return; // filter
LogMessage msg = new LogMessage(Instant.now(), level, message, name, t);
for (Appender appender : appenders) {
appender.append(msg);
}
}
}
class ConsoleAppender implements Appender {
private final Formatter formatter;
public void append(LogMessage msg) {
System.out.println(formatter.format(msg));
}
}
class FileAppender implements Appender {
private final Formatter formatter;
private final BufferedWriter writer;
public synchronized void append(LogMessage msg) {
writer.write(formatter.format(msg));
writer.newLine();
}
}
class NetworkAppender implements Appender { ... }
Each appender is independent. Adding a new destination (Kafka, syslog) is a new class implementing Appender.
Design Decision 2: Async logging
Synchronous logging blocks the application thread. For high-throughput services, this is a latency tax. Async logging puts messages in a queue; a separate thread drains the queue and writes.
class AsyncAppender implements Appender {
private final Appender delegate;
private final BlockingQueue queue;
private final Thread workerThread;
public AsyncAppender(Appender delegate, int bufferSize) {
this.delegate = delegate;
this.queue = new ArrayBlockingQueue<>(bufferSize);
this.workerThread = new Thread(this::drainLoop, "log-worker");
workerThread.setDaemon(true);
workerThread.start();
}
public void append(LogMessage msg) {
if (!queue.offer(msg)) {
// Queue full: drop or block? Configuration choice.
// Default: drop, increment dropped-count metric.
}
}
private void drainLoop() {
while (!Thread.interrupted()) {
try {
LogMessage msg = queue.take();
delegate.append(msg);
} catch (InterruptedException e) { break; }
}
}
}
Now any Appender can be wrapped: new AsyncAppender(new FileAppender("app.log"), 10000). This is the Decorator pattern.
Design Decision 3: Per-component log levels
class LoggerFactory {
private final Map loggers = new ConcurrentHashMap<>();
private final Map levelOverrides = new ConcurrentHashMap<>();
private LogLevel defaultLevel = INFO;
public Logger getLogger(String name) {
return loggers.computeIfAbsent(name, n -> {
LogLevel level = resolveLevel(n);
return new Logger(n, level, defaultAppenders);
});
}
private LogLevel resolveLevel(String name) {
// Walk up hierarchy: com.example.foo.Bar → com.example.foo → com.example → com → ""
String current = name;
while (current != null) {
if (levelOverrides.containsKey(current)) return levelOverrides.get(current);
int dot = current.lastIndexOf('.');
current = (dot == -1) ? null : current.substring(0, dot);
}
return defaultLevel;
}
public void setLevel(String prefix, LogLevel level) {
levelOverrides.put(prefix, level);
// Update existing loggers
loggers.values().forEach(logger -> {
if (logger.name.startsWith(prefix)) logger.minLevel = level;
});
}
}
Hierarchical: setting "com.example" to DEBUG enables debug logging for all loggers under that prefix unless a more-specific override exists.
Cross-examination round 1: when async drops messages
Takeaway
Loggers combine several patterns elegantly: Strategy (appender types), Decorator (async wrapper), Composite (multiple appenders), Chain of Responsibility (level filtering). The insight: logging is a cross-cutting concern that affects every part of an application, so it must be performant and configurable without intrusion. Real loggers (Log4j, SLF4J, Zap, Pino) implement exactly these patterns.