06-lld-interview-problems

Design a Logger Framework

Design a pluggable, asynchronous logging framework with multiple levels, appenders, formatters, and rotation strategies — the LLD interview walkthrough.

August 11, 2026
lldloggerframeworkappenderasyncrotation

Design a Logger Framework

Every backend service needs logging, and every logging framework you've used — Log4j, Logback, SLF4J, java.util.logging — solves the same underlying design problem. That makes it a favorite LLD interview question: it's a system every candidate has used but rarely has built, so the interviewer can probe how well you generalize from usage to design. The six-part framework applies directly here: requirements first, then actors, then a class diagram, then code, then patterns, then the concurrency/edge-case discussion that usually decides the interview.


1. Requirements

Functional requirements

  • Support multiple log levels: TRACE, DEBUG, INFO, WARN, ERROR, FATAL, each with a strict priority ordering.
  • Support multiple, simultaneously-active output destinations ("appenders"): console, file, database, remote HTTP endpoint.
  • Support multiple output formats: plain text, JSON, XML — decoupled from where the log goes.
  • Log asynchronously so that a slow appender (a flaky remote endpoint, a full disk) never blocks the calling application thread.
  • Support log rotation: size-based ("roll after 10 MB") and time-based ("roll every day at midnight").
  • Support filtering: by minimum level globally, and by level per package/logger name (e.g. com.payments.* at DEBUG, everything else at INFO).
  • Allow runtime reconfiguration of levels and appenders without restarting the process.

Non-functional requirements

  • Logging must never throw an exception that crashes the caller's business logic.
  • Logging must have minimal latency impact on the calling thread (hence: async).
  • Framework must be thread-safe under high-concurrency logging (thousands of threads logging simultaneously).
  • Must not lose log messages silently under normal operation, and must degrade predictably (drop oldest / block / drop newest — configurable) under extreme load.
  • Extensible: adding a new appender or formatter must not require modifying existing, tested classes — this is Open/Closed in practice; see SOLID Principles for the general form of this requirement.

Out of scope: distributed log aggregation (ELK/Splunk ingestion is "just another appender" from this framework's point of view), structured tracing/span correlation, log-based alerting.


2. Actors & Use Cases

Actors

  • Application code — calls logger.info(...), logger.error(...), etc. This is the only actor most engineers ever see.
  • Logger configuration (a human via config file, or a config-management system) — sets levels, wires appenders, sets rotation policy.
  • Appender — a system-level actor that owns an I/O resource (file handle, DB connection, socket) and consumes formatted messages.
  • Rotation trigger — a background timer or size-check that fires rotation.

Primary use cases

  1. Application code logs a message at a given level; the framework decides synchronously whether it's enabled (cheap check) and, if so, hands it off asynchronously for formatting and writing.
  2. Operator configures Logger("com.payments").setLevel(DEBUG) while Logger("com.payments.legacy").setLevel(WARN) — hierarchical, package-scoped level filtering.
  3. A FileAppender's current file crosses 10 MB → rotation strategy closes it, renames it with a timestamp suffix, opens a fresh file.
  4. The async queue fills up because the DB appender is stalled on a slow connection → framework applies its configured overflow policy (block, drop, or discard-and-count) instead of an unbounded memory leak.
  5. A message is formatted differently per appender: the console gets human-readable text, the remote appender gets JSON.

3. Class Diagram


4. Core Class Design

Log level and message — immutable value types

java
public enum LogLevel {
    TRACE(0), DEBUG(1), INFO(2), WARN(3), ERROR(4), FATAL(5);
 
    private final int priority;
    LogLevel(int priority) { this.priority = priority; }
    public int priority() { return priority; }
}
 
public final class LogMessage {
    private final LogLevel level;
    private final String loggerName;
    private final String message;
    private final long timestampMillis;
    private final Map<String, String> context; // MDC-style structured context
    private final Throwable throwable;          // nullable
 
    public LogMessage(LogLevel level, String loggerName, String message,
                       Map<String, String> context, Throwable throwable) {
        this.level = level;
        this.loggerName = loggerName;
        this.message = message;
        this.timestampMillis = System.currentTimeMillis();
        this.context = context == null ? Map.of() : Map.copyOf(context);
        this.throwable = throwable;
    }
 
    public LogLevel level() { return level; }
    public String loggerName() { return loggerName; }
    public String message() { return message; }
    public long timestampMillis() { return timestampMillis; }
    public Map<String, String> context() { return context; }
    public Throwable throwable() { return throwable; }
}

Appender and Formatter — Strategy interfaces

java
public interface Formatter {
    String format(LogMessage message);
}
 
public final class TextFormatter implements Formatter {
    public String format(LogMessage m) {
        return "%s [%s] %s - %s".formatted(
            Instant.ofEpochMilli(m.timestampMillis()), m.level(), m.loggerName(), m.message());
    }
}
 
public final class JsonFormatter implements Formatter {
    public String format(LogMessage m) {
        // In production: use a JSON library. Shown inline for clarity.
        return """
            {"ts":%d,"level":"%s","logger":"%s","msg":"%s"}"""
            .formatted(m.timestampMillis(), m.level(), m.loggerName(), escape(m.message()));
    }
    private String escape(String s) { return s.replace("\"", "\\\""); }
}
 
public interface Appender {
    void append(LogMessage message);
    void setFormatter(Formatter formatter);
    void close();
}
 
public final class ConsoleAppender implements Appender {
    private Formatter formatter = new TextFormatter();
 
    public void setFormatter(Formatter f) { this.formatter = f; }
 
    public void append(LogMessage message) {
        System.out.println(formatter.format(message));
    }
 
    public void close() { /* no-op: stdout isn't owned by us */ }
}
 
public final class FileAppender implements Appender {
    private Formatter formatter = new TextFormatter();
    private final RotationStrategy rotationStrategy;
    private final Path filePath;
    private BufferedWriter writer;
 
    public FileAppender(Path filePath, RotationStrategy rotationStrategy) throws IOException {
        this.filePath = filePath;
        this.rotationStrategy = rotationStrategy;
        this.writer = Files.newBufferedWriter(filePath, StandardOpenOption.CREATE, StandardOpenOption.APPEND);
    }
 
    public void setFormatter(Formatter f) { this.formatter = f; }
 
    public synchronized void append(LogMessage message) {
        try {
            if (rotationStrategy.shouldRotate(filePath.toFile())) {
                writer.close();
                rotationStrategy.rotate(filePath.toFile());
                writer = Files.newBufferedWriter(filePath, StandardOpenOption.CREATE, StandardOpenOption.APPEND);
            }
            writer.write(formatter.format(message));
            writer.newLine();
            writer.flush();
        } catch (IOException e) {
            // Never let a logging failure propagate into application logic.
            System.err.println("FileAppender failed: " + e.getMessage());
        }
    }
 
    public synchronized void close() {
        try { writer.close(); } catch (IOException ignored) { }
    }
}

Filter chain — Chain of Responsibility

java
public abstract class LogFilter {
    private LogFilter next;
 
    public LogFilter setNext(LogFilter next) { this.next = next; return next; }
 
    public final boolean shouldLog(LogMessage message) {
        if (!accept(message)) return false;
        return next == null || next.shouldLog(message);
    }
 
    protected abstract boolean accept(LogMessage message);
}
 
public final class LevelFilter extends LogFilter {
    private final LogLevel minLevel;
    public LevelFilter(LogLevel minLevel) { this.minLevel = minLevel; }
    protected boolean accept(LogMessage m) { return m.level().priority() >= minLevel.priority(); }
}
 
public final class PackageFilter extends LogFilter {
    private final String packagePrefix;
    private final LogLevel minLevel;
    public PackageFilter(String packagePrefix, LogLevel minLevel) {
        this.packagePrefix = packagePrefix;
        this.minLevel = minLevel;
    }
    protected boolean accept(LogMessage m) {
        if (!m.loggerName().startsWith(packagePrefix)) return true; // not our concern, pass through
        return m.level().priority() >= minLevel.priority();
    }
}

Logger and factory

java
public final class Logger {
    private final String name;
    private volatile LogLevel level;
    private final AsyncLogProcessor processor;
 
    Logger(String name, LogLevel level, AsyncLogProcessor processor) {
        this.name = name;
        this.level = level;
        this.processor = processor;
    }
 
    public void setLevel(LogLevel level) { this.level = level; }
 
    public boolean isEnabled(LogLevel candidate) { return candidate.priority() >= level.priority(); }
 
    public void trace(String msg) { log(LogLevel.TRACE, msg, null); }
    public void debug(String msg) { log(LogLevel.DEBUG, msg, null); }
    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); }
    public void fatal(String msg, Throwable t) { log(LogLevel.FATAL, msg, t); }
 
    private void log(LogLevel level, String msg, Throwable t) {
        if (!isEnabled(level)) return; // cheap check on the caller's thread, no allocation
        LogMessage message = new LogMessage(level, name, msg, MDC.getContext(), t);
        processor.enqueue(message); // hands off — caller thread returns immediately
    }
}
 
public final class LoggerFactory {
    private static final LoggerFactory INSTANCE = new LoggerFactory();
    private final Map<String, Logger> loggers = new ConcurrentHashMap<>();
    private final AsyncLogProcessor sharedProcessor;
 
    private LoggerFactory() {
        this.sharedProcessor = new AsyncLogProcessor(10_000, OverflowPolicy.DROP_OLDEST);
        this.sharedProcessor.start();
    }
 
    public static Logger getLogger(Class<?> clazz) { return getLogger(clazz.getName()); }
 
    public static Logger getLogger(String name) {
        return INSTANCE.loggers.computeIfAbsent(name,
            n -> new Logger(n, LogLevel.INFO, INSTANCE.sharedProcessor));
    }
}

LoggerFactory.getLogger(...) is a textbook Singleton-backed registry: exactly one AsyncLogProcessor and one queue serve every Logger instance in the process, so ordering and backpressure are process-wide, not per-logger. ConcurrentHashMap.computeIfAbsent gives thread-safe, allocate-once-per-name registration without an explicit lock.


5. Design Patterns Applied

PatternWhere usedWhy
StrategyFormatter (Text/Json/Xml), RotationStrategy (size/time)Output format and rotation policy vary independently of the logging call site; new variants are new classes, zero edits elsewhere.
Observer (fan-out)Logger → multiple AppendersOne log call notifies every registered appender; appenders don't know about each other.
Chain of ResponsibilityLogFilter linked list (LevelFilterPackageFilter → ...)Each filter independently vetoes a message; new filter criteria compose without touching existing filters.
SingletonLoggerFactoryExactly one shared async processor and logger registry per process — matches how Log4j/Logback/SLF4J actually work.
Producer-ConsumerLogger.log() (producer) → BlockingQueueAsyncLogProcessor worker (consumer)Decouples the fast application thread from slow I/O-bound appenders.
Factory MethodLoggerFactory.getLogger(...)Centralizes Logger construction and caching; callers never call new Logger(...) directly.
Decorator (optional extension)Wrapping an Appender with a BufferedAppender or RetryingAppenderAdds buffering/retry behavior to any appender without subclassing each one.

6. Key Algorithms, Concurrency & Edge Cases

Async logging: bounded producer-consumer queue

The single most important design decision is that Logger.log() never blocks on I/O. It enqueues onto a bounded BlockingQueue and returns; a dedicated worker thread (or small pool) drains the queue and does the actual formatting + I/O.

java
public final class AsyncLogProcessor {
    private final BlockingQueue<LogMessage> queue;
    private final List<Appender> appenders = new CopyOnWriteArrayList<>();
    private final OverflowPolicy overflowPolicy;
    private final AtomicLong droppedCount = new AtomicLong();
    private volatile boolean running = true;
    private Thread worker;
 
    public AsyncLogProcessor(int capacity, OverflowPolicy overflowPolicy) {
        this.queue = new ArrayBlockingQueue<>(capacity);
        this.overflowPolicy = overflowPolicy;
    }
 
    public void addAppender(Appender appender) { appenders.add(appender); }
 
    public void enqueue(LogMessage message) {
        boolean offered = queue.offer(message); // non-blocking by default
        if (!offered) {
            switch (overflowPolicy) {
                case BLOCK -> {
                    try { queue.put(message); } catch (InterruptedException e) {
                        Thread.currentThread().interrupt();
                    }
                }
                case DROP_NEWEST -> droppedCount.incrementAndGet(); // silently discard this message
                case DROP_OLDEST -> {
                    queue.poll();          // evict the head
                    queue.offer(message);  // retry
                    droppedCount.incrementAndGet();
                }
            }
        }
    }
 
    public void start() {
        worker = new Thread(this::run, "log-async-worker");
        worker.setDaemon(true); // never block JVM shutdown
        worker.start();
    }
 
    private void run() {
        while (running || !queue.isEmpty()) {
            try {
                LogMessage message = queue.poll(500, TimeUnit.MILLISECONDS);
                if (message == null) continue;
                for (Appender appender : appenders) {
                    try {
                        appender.append(message); // isolate one bad appender from the rest
                    } catch (Exception e) {
                        System.err.println("Appender failed: " + e.getMessage());
                    }
                }
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        }
    }
 
    public void shutdown() {
        running = false;
        try { worker.join(2000); } catch (InterruptedException ignored) { }
        appenders.forEach(Appender::close);
    }
}
 
public enum OverflowPolicy { BLOCK, DROP_NEWEST, DROP_OLDEST }

Overflow policy trade-off: BLOCK guarantees zero message loss but risks slowing down (or, under sustained overload, stalling) application threads — defeating the purpose of async logging. DROP_NEWEST/DROP_OLDEST guarantee the app is never blocked but silently lose messages; production frameworks (Log4j's AsyncAppender) default to a bounded queue with drop-and-count so operators can alert on droppedCount > 0 rather than lose data invisibly.

Log rotation

java
public final class SizeBasedRotation implements RotationStrategy {
    private final long maxBytes;
    public SizeBasedRotation(long maxBytes) { this.maxBytes = maxBytes; }
 
    public boolean shouldRotate(File file) { return file.exists() && file.length() >= maxBytes; }
 
    public void rotate(File file) {
        String timestamp = DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss").format(LocalDateTime.now());
        File rolled = new File(file.getParent(), file.getName() + "." + timestamp);
        file.renameTo(rolled);
    }
}
 
public final class TimeBasedRotation implements RotationStrategy {
    private volatile LocalDate lastRotatedDate = LocalDate.now();
 
    public boolean shouldRotate(File file) { return !LocalDate.now().equals(lastRotatedDate); }
 
    public void rotate(File file) {
        File rolled = new File(file.getParent(), file.getName() + "." + lastRotatedDate);
        file.renameTo(rolled);
        lastRotatedDate = LocalDate.now();
    }
}

shouldRotate is checked on every append call in FileAppender above — cheap (a File.length() syscall or a date comparison), so it doesn't need a separate background thread, though production systems often also run a periodic sweep to rotate idle files that haven't received a write in a while.

Thread safety concerns

  • Logger.level is volatile — reconfiguration from another thread (an admin endpoint) must be visible immediately to logging threads without a full lock.
  • FileAppender.append is synchronized because the Writer and rotation check-and-act are not otherwise atomic — two threads could both observe "should rotate" and both attempt to rename the file.
  • AsyncLogProcessor.appenders is a CopyOnWriteArrayList since appenders are added rarely (at startup/reconfig) but iterated on every single log message — optimizing for the read-heavy case.
  • A crash inside one Appender.append() must never stop other appenders from receiving the message or kill the worker thread — hence the per-appender try/catch inside the drain loop.

Edge cases

  • MDC (Mapped Diagnostic Context) leak across thread-pool threads: if request-scoped context (requestId, userId) is stored in a ThreadLocal, a thread-pool worker must clear it after each task or a subsequent unrelated request inherits stale context.
  • Logging during shutdown: the worker thread should drain the queue (with a timeout) before the JVM exits, or final log lines are lost — hence shutdown() joins the worker rather than just setting running = false.
  • Recursive logging: an appender that itself logs on failure (e.g., DBAppender logging a connection error) can recurse infinitely if it uses the same logger; production frameworks route internal framework errors to a separate, appender-free "status logger."
  • Clock skew across a fleet: timestamps should be generated at enqueue time (on the caller's thread), not at append time, so queueing delay doesn't distort the recorded time of the event — reflected above by stamping timestampMillis in the LogMessage constructor, not in the appender.

7. Trade-offs & Extensions

DecisionTrade-off
Async by defaultLower latency for callers, but risk of message loss under overflow and harder-to-debug "log ordering" issues across appenders if multiple worker threads are used.
One shared queue/worker vs. per-appender queuesShared: simpler, bounded total memory. Per-appender: a slow RemoteAppender can't cause backpressure that also delays the ConsoleAppender; costs more memory and threads.
Package-hierarchy level filteringMatches real frameworks (Log4j/Logback) and lets you dial verbosity for one module in production; adds lookup complexity (walk the package prefix chain, cache resolved levels).
Synchronous fallback modeA "flush and block" mode for FATAL-level errors right before a crash guarantees the last message is written, at the cost of occasionally blocking.

Natural extensions:

  • Structured logging: replace the free-text message with a structured event object; JsonFormatter becomes the primary formatter, text becomes a rendering of the structured event.
  • Sampling: an appender-level SamplingFilter that logs only 1-in-N DEBUG messages under high load, while always logging WARN+.
  • Correlation IDs and distributed tracing: thread the same requestId through MDC and propagate it across service boundaries via headers.
  • Metrics: expose droppedCount, queue depth, and per-level counts as framework health metrics — turns the logger itself into an observable component.

Interview Questions

  • Why does the logging call on the application thread need to stay non-blocking, and what's the failure mode if it doesn't?
  • Walk through what happens end-to-end when logger.error("...") is called: which checks happen synchronously vs. asynchronously?
  • How would you implement package-hierarchy level filtering (com.payments.* at DEBUG, default INFO) efficiently, given that logger names form a tree?
  • What are the trade-offs between BLOCK, DROP_NEWEST, and DROP_OLDEST overflow policies for the async queue?
  • How do you guarantee no message is silently lost during a clean JVM shutdown, while still using an async queue?
  • Where would you apply the Chain of Responsibility pattern in this design, and why not just a single method with several if checks?
  • How would you extend this design to support sampling debug logs at 1% under high load without losing all WARN/ERROR messages?