Hirestack
Hirestack
Chapter 14

Problem 10: Design a Logger

📖 3 min read · 8 sections · May 2026
Asked at: Amazon Microsoft

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

QuestionStory 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

Interviewer: Async buffer is full. What's the right behavior?
Three options: (1) Drop the new message (count dropped messages for monitoring). (2) Block the caller until space is available — but this defeats the purpose of async. (3) Drop the oldest message in the queue. Most production loggers do (1) with metrics. The assumption: log volume that overflows the buffer is itself a sign of trouble; dropping logs is acceptable to keep the application running.

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.