Design a Logging Framework

Mediumobject-oriented design · design patterns · chain of responsibility · concurrency · java⏱ 15 min readAsked at Amazon, Microsoft, Google, Atlassian, SalesforceUpdated

Build a mini SLF4J/Log4j-style logging library with levels, hierarchical loggers, pluggable appenders and formatters, filter chains, thread safety and a non-blocking async appender.

Requirements

"Design a logger" sounds like a one-class problem until you list what real libraries such as Log4j, Logback or java.util.logging do. The interviewer wants to see you separate what is logged (records and levels), where it goes (appenders), how it looks (formatters) and who decides (filters and the logger hierarchy), all without slowing down the application.

Functional

  • Ordered levels: TRACE < DEBUG < INFO < WARN < ERROR < FATAL, plus OFF.
  • Named loggers obtained from a factory, e.g. getLogger("app.payments"). Names form a hierarchy by dots; a logger without its own level inherits its nearest ancestor's.
  • Each logger can have several appenders (console, file, network, in-memory). Records flow up to ancestor appenders unless additivity is switched off.
  • Each appender has a formatter (plain pattern, JSON) and an optional threshold and filters.
  • Parameterised messages ("user {} paid {}") and an attached exception.
  • Levels and appenders can be changed at runtime.

Non-functional

  • Cheap when disabled: a suppressed debug call must not build strings.
  • Thread-safe: many threads log at once; lines never interleave or get lost.
  • Low latency: an optional async mode so slow I/O never blocks request threads.
  • Extensible: new destinations or formats without touching Logger.
  • A failing appender must never crash the application.

Out of scope (confirm)

  • Config-file parsing, log shipping infrastructure, log search and retention.
Tip

Open by sketching the pipeline out loud: logger checks level, builds a record, hands it to appenders up the hierarchy; each appender filters, formats and writes. Every class you add later fits into one box of that sentence.

Core Entities

Entity Responsibility Key fields
LogLevel (enum) Ordered severities; comparison helper TRACE ... OFF
LogRecord Immutable event captured at the call site time, level, logger name, thread, message, error
Logger Level check, record creation, dispatch up the hierarchy name, parent, level, appenders, additive
LogManager Factory and registry of loggers; owns the root; shutdown ConcurrentHashMap<String, Logger>
Appender Destination for records interface
AbstractAppender Template: threshold, filter chain, format, synchronized write formatter, filters
StreamAppender, MemoryAppender Write to a PrintStream / keep lines for tests
AsyncAppender Decorator: bounded queue plus background worker queue, worker, dropped
Formatter Turns a record into text PatternFormatter, JsonFormatter
LogFilter Votes ACCEPT, DENY or NEUTRAL on a record interface

The record is created once per call and shared by every appender, so it must be immutable. Capturing the thread name and timestamp in the record (not in the appender) is what keeps them correct when an async appender writes later on another thread.

Class Diagram

classDiagram
  class LogLevel {
    <<enumeration>>
    TRACE
    DEBUG
    INFO
    WARN
    ERROR
    FATAL
    OFF
  }
  class LogRecord {
    -Instant timestamp
    -LogLevel level
    -String loggerName
    -String threadName
    -String message
    -Throwable error
  }
  class Logger {
    -String name
    -Logger parent
    -LogLevel level
    -boolean additive
    -List appenders
    +isEnabled(LogLevel) boolean
    +info(String, Object...) void
    +debug(Supplier) void
  }
  class LogManager {
    -Logger root
    -Map loggers
    +getLogger(String) Logger
    +shutdown() void
  }
  class Appender {
    <<interface>>
    +append(LogRecord) void
    +close() void
  }
  class AbstractAppender {
    <<abstract>>
    -LogLevel threshold
    -Formatter formatter
    -List filters
    #write(String) void
  }
  class StreamAppender
  class MemoryAppender
  class AsyncAppender {
    -BlockingQueue queue
    -Appender delegate
    -Thread worker
  }
  class Formatter {
    <<interface>>
    +format(LogRecord) String
  }
  class PatternFormatter
  class JsonFormatter
  class LogFilter {
    <<interface>>
    +decide(LogRecord) FilterDecision
  }

  LogManager "1" *-- "many" Logger
  Logger --> Logger : parent
  Logger "1" o-- "many" Appender
  Logger ..> LogRecord : creates
  Appender <|.. AbstractAppender
  Appender <|.. AsyncAppender
  AbstractAppender <|-- StreamAppender
  AbstractAppender <|-- MemoryAppender
  AsyncAppender --> Appender : delegate
  AbstractAppender --> Formatter
  AbstractAppender "1" o-- "many" LogFilter
  Formatter <|.. PatternFormatter
  Formatter <|.. JsonFormatter

Design Patterns Used

Pattern Where Why it is justified
Chain of Responsibility Logger hierarchy (app.payments to app to root) and the appender filter chain A record is offered to each link in turn; any link can stop propagation (additive = false, or a filter returning DENY/ACCEPT)
Strategy Formatter, LogFilter Plain vs JSON output, or "drop health checks", are swappable algorithms
Template Method AbstractAppender.append() Fixed steps (threshold, filters, format, locked write); subclasses only implement write
Decorator AsyncAppender wraps any Appender Adds asynchrony without changing console or file appenders
Factory + Registry (Flyweight-like) LogManager.getLogger One shared Logger per name, created lazily and safely
Observer (implicit) Appenders attached to loggers Loggers publish records; appenders subscribe and unsubscribe at runtime

Two chains, two jobs

  1. Hierarchy chain. Logger.dispatch walks from the logger to the root, giving the record to each logger's appenders. If a logger has additive = false, the walk stops there. The level check happens once, at the originating logger, which matches how Logback treats additivity.
  2. Filter chain. Inside an appender each LogFilter returns DENY (drop now), ACCEPT (log now, skip remaining filters) or NEUTRAL (ask the next filter). This is the textbook Chain of Responsibility shape.
Insight

A classic interview variant chains handlers by level ("INFO handler, then ERROR handler..."). The hierarchy-plus-filters version is what production libraries do, and it answers the same question: each link decides to handle, pass on, or stop.

Making disabled logs nearly free

isEnabled compares two enum ordinals. Parameterised messages defer string building until after that check, and the Supplier<String> overload defers even computing the arguments. The demo proves an expensive debug argument is never evaluated when DEBUG is off.

Java Implementation

The code compiles as a single Main.java (Java 17+). A stepping Clock is injected so timestamps in the output are deterministic.

java
import java.time.*;
import java.time.format.DateTimeFormatter;
import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicLong;
import java.util.function.Supplier;

enum LogLevel {
    TRACE, DEBUG, INFO, WARN, ERROR, FATAL, OFF;
    boolean isAtLeast(LogLevel other) { return compareTo(other) >= 0; }
}

record LogRecord(Instant timestamp, LogLevel level, String loggerName, String threadName,
                 String message, Throwable error) {}

interface Formatter { String format(LogRecord r); }

class PatternFormatter implements Formatter {
    private static final DateTimeFormatter TIME =
            DateTimeFormatter.ofPattern("HH:mm:ss.SSS").withZone(ZoneOffset.UTC);
    public String format(LogRecord r) {
        String line = TIME.format(r.timestamp()) + " " + String.format("%-5s", r.level())
                + " [" + r.threadName() + "] " + r.loggerName() + " - " + r.message();
        return r.error() == null ? line
                : line + " | " + r.error().getClass().getSimpleName() + ": " + r.error().getMessage();
    }
}

class JsonFormatter implements Formatter {
    public String format(LogRecord r) {
        return "{\"ts\":\"" + r.timestamp() + "\",\"level\":\"" + r.level() + "\",\"logger\":\""
                + r.loggerName() + "\",\"msg\":\"" + escape(r.message()) + "\""
                + (r.error() == null ? "" : ",\"error\":\"" + escape(String.valueOf(r.error().getMessage())) + "\"")
                + "}";
    }
    private static String escape(String s) { return s.replace("\\", "\\\\").replace("\"", "\\\""); }
}

enum FilterDecision { ACCEPT, DENY, NEUTRAL }

interface LogFilter { FilterDecision decide(LogRecord r); }

interface Appender {
    void append(LogRecord r);
    default void close() {}
}

abstract class AbstractAppender implements Appender {
    private final Formatter formatter;
    private final List<LogFilter> filters = new CopyOnWriteArrayList<>();
    private volatile LogLevel threshold = LogLevel.TRACE;

    AbstractAppender(Formatter formatter) { this.formatter = formatter; }

    AbstractAppender threshold(LogLevel level) { this.threshold = level; return this; }
    AbstractAppender addFilter(LogFilter f) { filters.add(f); return this; }

    public final void append(LogRecord r) {
        if (!r.level().isAtLeast(threshold)) return;
        for (LogFilter f : filters) {                    // chain of responsibility
            FilterDecision d = f.decide(r);
            if (d == FilterDecision.DENY) return;
            if (d == FilterDecision.ACCEPT) break;
        }
        String line = formatter.format(r);               // format outside the lock
        try {
            synchronized (this) { write(line); }         // one writer at a time: no interleaving
        } catch (RuntimeException e) {
            System.err.println("appender failed: " + e);  // never propagate to the caller
        }
    }

    protected abstract void write(String line);
}

class StreamAppender extends AbstractAppender {
    private final java.io.PrintStream out;
    StreamAppender(java.io.PrintStream out, Formatter f) { super(f); this.out = out; }
    protected void write(String line) { out.println(line); }
}

class MemoryAppender extends AbstractAppender {
    private final List<String> lines = new ArrayList<>();
    MemoryAppender(Formatter f) { super(f); }
    protected void write(String line) { lines.add(line); }
    synchronized List<String> lines() { return new ArrayList<>(lines); }
}

// Decorator: callers enqueue and return; one worker thread performs the slow I/O.
class AsyncAppender implements Appender {
    private static final LogRecord POISON = new LogRecord(Instant.EPOCH, LogLevel.OFF, "", "", "", null);
    private final Appender delegate;
    private final BlockingQueue<LogRecord> queue;
    private final AtomicLong dropped = new AtomicLong();
    private final Thread worker;
    private volatile boolean closed;

    AsyncAppender(Appender delegate, int capacity) {
        this.delegate = delegate;
        this.queue = new ArrayBlockingQueue<>(capacity);
        this.worker = new Thread(this::drain, "log-async");
        worker.setDaemon(true);
        worker.start();
    }

    public void append(LogRecord r) {
        if (closed) { dropped.incrementAndGet(); return; }
        try {
            // WARN and above wait briefly for space; chatty levels are dropped when the queue is full.
            boolean ok = r.level().isAtLeast(LogLevel.WARN) ? queue.offer(r, 1, TimeUnit.SECONDS) : queue.offer(r);
            if (!ok) dropped.incrementAndGet();
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }

    private void drain() {
        try {
            while (true) {
                LogRecord r = queue.take();
                if (r == POISON) return;
                delegate.append(r);
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }

    long dropped() { return dropped.get(); }

    public void close() {
        closed = true;
        try {
            queue.put(POISON);          // everything queued before it is still written
            worker.join(5_000);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
        delegate.close();
    }
}

class Logger {
    private final String name;
    private final Logger parent;
    private final Clock clock;
    private final List<Appender> appenders = new CopyOnWriteArrayList<>();
    private volatile LogLevel level;           // null means inherit from parent
    private volatile boolean additive = true;

    Logger(String name, Logger parent, Clock clock) { this.name = name; this.parent = parent; this.clock = clock; }

    String name() { return name; }
    List<Appender> appenders() { return appenders; }
    Logger setLevel(LogLevel level) { this.level = level; return this; }
    Logger setAdditive(boolean additive) { this.additive = additive; return this; }
    Logger addAppender(Appender a) { appenders.add(a); return this; }

    LogLevel effectiveLevel() {
        for (Logger l = this; l != null; l = l.parent) if (l.level != null) return l.level;
        return LogLevel.INFO;
    }

    boolean isEnabled(LogLevel lvl) { return lvl != LogLevel.OFF && lvl.isAtLeast(effectiveLevel()); }

    void log(LogLevel lvl, String template, Object... args) {
        if (!isEnabled(lvl)) return;                        // cheap exit: no string work
        Throwable error = null;
        int n = args.length;
        if (n > 0 && args[n - 1] instanceof Throwable t) { error = t; n--; }
        dispatch(new LogRecord(clock.instant(), lvl, name, Thread.currentThread().getName(),
                substitute(template, args, n), error));
    }

    void log(LogLevel lvl, Supplier<String> message) {
        if (!isEnabled(lvl)) return;
        dispatch(new LogRecord(clock.instant(), lvl, name, Thread.currentThread().getName(), message.get(), null));
    }

    void debug(String t, Object... a) { log(LogLevel.DEBUG, t, a); }
    void debug(Supplier<String> m) { log(LogLevel.DEBUG, m); }
    void info(String t, Object... a) { log(LogLevel.INFO, t, a); }
    void warn(String t, Object... a) { log(LogLevel.WARN, t, a); }
    void error(String t, Object... a) { log(LogLevel.ERROR, t, a); }

    // Hierarchy chain: offer the record to each ancestor until additivity stops it.
    private void dispatch(LogRecord r) {
        for (Logger l = this; l != null; l = l.parent) {
            for (Appender a : l.appenders) a.append(r);
            if (!l.additive) break;
        }
    }

    private static String substitute(String template, Object[] args, int n) {
        StringBuilder sb = new StringBuilder();
        int from = 0, i = 0, idx;
        while (i < n && (idx = template.indexOf("{}", from)) >= 0) {
            sb.append(template, from, idx).append(args[i++]);
            from = idx + 2;
        }
        return sb.append(template.substring(from)).toString();
    }
}

final class LogManager {
    private final Clock clock;
    private final Logger root;
    private final ConcurrentMap<String, Logger> loggers = new ConcurrentHashMap<>();

    LogManager(Clock clock) {
        this.clock = clock;
        this.root = new Logger("root", null, clock).setLevel(LogLevel.INFO);
    }

    Logger root() { return root; }

    Logger getLogger(String name) {
        if (name.isEmpty()) return root;
        Logger existing = loggers.get(name);
        if (existing != null) return existing;
        int dot = name.lastIndexOf('.');
        Logger parent = dot < 0 ? root : getLogger(name.substring(0, dot));   // create ancestors first
        return loggers.computeIfAbsent(name, n -> new Logger(n, parent, clock));
    }

    void shutdown() {
        Set<Appender> all = Collections.newSetFromMap(new IdentityHashMap<>());
        all.addAll(root.appenders());
        loggers.values().forEach(l -> all.addAll(l.appenders()));
        all.forEach(Appender::close);
    }
}

class SteppingClock extends Clock {
    private final AtomicLong millis;
    SteppingClock(Instant start) { millis = new AtomicLong(start.toEpochMilli()); }
    public ZoneId getZone() { return ZoneOffset.UTC; }
    public Clock withZone(ZoneId zone) { return this; }
    public Instant instant() { return Instant.ofEpochMilli(millis.getAndAdd(5)); }
}

public class Main {
    static int expensiveCalls = 0;

    static String expensiveDump() { expensiveCalls++; return "cache dump with 10k entries"; }

    public static void main(String[] args) throws Exception {
        LogManager lm = new LogManager(new SteppingClock(Instant.parse("2026-10-30T09:00:00Z")));
        AbstractAppender console = new StreamAppender(System.out, new PatternFormatter());
        console.addFilter(r -> r.message().startsWith("GET /health") ? FilterDecision.DENY : FilterDecision.NEUTRAL);
        lm.root().addAppender(console);

        Logger db = lm.getLogger("app.db").setLevel(LogLevel.DEBUG);
        Logger web = lm.getLogger("app.web");
        Logger pay = lm.getLogger("app.payments");
        pay.addAppender(new StreamAppender(System.out, new JsonFormatter()).threshold(LogLevel.WARN));

        db.debug("Opening pool size={}", 10);
        web.debug("Request headers {}", "hidden because app.web inherits INFO");
        web.debug(() -> expensiveDump());
        web.info("GET /orders took {} ms", 42);
        web.info("GET /health took {} ms", 1);
        pay.info("Charging {} for {} cents", "ord-7", 1999);
        pay.warn("Retrying charge {} attempt {}", "ord-7", 2);
        pay.error("Charge failed for {}", "ord-7", new IllegalStateException("card declined"));
        System.out.println("expensiveDump() calls while DEBUG off: " + expensiveCalls);

        web.setLevel(LogLevel.DEBUG);
        web.debug(() -> expensiveDump());
        pay.setAdditive(false);
        pay.error("Refund failed for {}", "ord-8");
        System.out.println("expensiveDump() calls after enabling DEBUG: " + expensiveCalls);

        MemoryAppender sink = new MemoryAppender(new PatternFormatter());
        AsyncAppender async = new AsyncAppender(sink, 4096);
        Logger bench = lm.getLogger("bench").setAdditive(false).addAppender(async);
        ExecutorService pool = Executors.newFixedThreadPool(4, r -> new Thread(r, "worker"));
        for (int t = 0; t < 4; t++) {
            int id = t;
            pool.submit(() -> { for (int i = 0; i < 250; i++) bench.info("task {} event {}", id, i); });
        }
        pool.shutdown();
        pool.awaitTermination(10, TimeUnit.SECONDS);
        lm.shutdown();   // drains the async queue before returning

        List<String> lines = sink.lines();
        boolean wellFormed = lines.stream().allMatch(l -> l.matches("\\d{2}:\\d{2}:\\d{2}\\.\\d{3} INFO  \\[worker] bench - task \\d event \\d+"));
        System.out.println("async lines=" + lines.size() + " dropped=" + async.dropped() + " wellFormed=" + wellFormed);
        System.out.println("same logger instance: " + (lm.getLogger("app.web") == web));
    }
}

Demo output

text
09:00:00.000 DEBUG [main] app.db - Opening pool size=10
09:00:00.005 INFO  [main] app.web - GET /orders took 42 ms
09:00:00.015 INFO  [main] app.payments - Charging ord-7 for 1999 cents
{"ts":"2026-10-30T09:00:00.020Z","level":"WARN","logger":"app.payments","msg":"Retrying charge ord-7 attempt 2"}
09:00:00.020 WARN  [main] app.payments - Retrying charge ord-7 attempt 2
{"ts":"2026-10-30T09:00:00.025Z","level":"ERROR","logger":"app.payments","msg":"Charge failed for ord-7","error":"card declined"}
09:00:00.025 ERROR [main] app.payments - Charge failed for ord-7 | IllegalStateException: card declined
expensiveDump() calls while DEBUG off: 0
09:00:00.030 DEBUG [main] app.web - cache dump with 10k entries
{"ts":"2026-10-30T09:00:00.035Z","level":"ERROR","logger":"app.payments","msg":"Refund failed for ord-8"}
expensiveDump() calls after enabling DEBUG: 1
async lines=1000 dropped=0 wellFormed=true
same logger instance: true

Walkthrough:

  • app.db has its own DEBUG level, so its debug line prints; app.web inherits INFO from the root, so its debug lines are skipped and expensiveDump() is never called. Skipped calls create no record, so they consume no clock tick.
  • The GET /health line was enabled, so a record was created (timestamp .010), but the console's filter chain denied it; GET /orders passes.
  • app.payments INFO goes only to the root console (the JSON appender's threshold is WARN). WARN and ERROR go to both: first the JSON appender on app.payments, then up the chain to the root console.
  • After setAdditive(false), the refund error stops at app.payments and appears only as JSON.
  • 4 threads × 250 records pass through the async appender with no drops and no interleaved lines; shutdown drains the queue before the counts are read.

Concurrency Considerations

Logging is called from every thread in the process, so the framework must be safe by default and fast under contention.

  • Logger registry. ConcurrentHashMap.computeIfAbsent guarantees one Logger per name even when threads race. Ancestors are created before the call because recursively modifying the map inside computeIfAbsent is illegal.
  • Configuration changes. level and additive are volatile, and appender and filter lists are CopyOnWriteArrayList: reads (every log call) take no lock, while rare writes copy the list. A thread iterating appenders never sees a ConcurrentModificationException.
  • No interleaved output. Each appender serialises write with its own monitor. Formatting happens outside the lock to keep the critical section short. Separate appenders never block each other.
  • Async appender. Callers only enqueue into a bounded ArrayBlockingQueue; a single worker thread does I/O, so writes stay ordered per appender. When the queue is full, INFO and below are dropped and counted while WARN and above wait up to a second. Choosing between blocking, dropping and discarding the oldest entries is a key trade-off to discuss.
  • Shutdown. close enqueues a poison pill after all pending records and joins the worker, so nothing already accepted is lost. Register lm.shutdown() as a JVM shutdown hook in production.
  • Failure isolation. Appender exceptions are caught and reported to stderr; a full disk must not turn into a failed checkout.
Warning

Never log while holding a lock that an appender might also need, and never let an appender call back into the logger. Both create deadlocks or infinite recursion. Real libraries guard against re-entrant logging from inside appenders.

Extensions

Requirement Change
File output with rotation RollingFileAppender with a RolloverPolicy strategy (size-based, time-based); rename and reopen inside the write lock
Configuration from YAML or properties A Configurator that builds loggers and appenders via a builder, with hot reload on file change
Request or trace IDs on every line A thread-local MDC map copied into LogRecord at creation (copy it, since async writes happen on another thread)
Higher throughput Batch writes in the async worker, or a lock-free ring buffer (the LMAX Disruptor idea behind Log4j 2 async loggers)
Structured logging Key-value fields in LogRecord; JsonFormatter emits them as JSON properties
Remote sinks (Kafka, HTTP) New appenders, always wrapped in AsyncAppender with retry and a circuit breaker
Sampling noisy logs A RateLimitFilter (token bucket per message template) in the filter chain
Faster level checks Cache the effective level per logger and invalidate descendants when a level changes

Interview Tips

  • Draw the pipeline first: logger, level check, record, hierarchy, appender, filter, formatter, sink.
  • Explain why LogRecord is immutable and captures thread and time at the call site.
  • Show the cheap disabled path: level check before formatting, plus Supplier for expensive arguments.
  • Name the Chain of Responsibility explicitly (hierarchy and filters) and the Decorator for async; justify each.
  • Volunteer the async trade-offs: bounded queue, back-pressure versus dropping, ordering, flush on shutdown.
  • Mention that a logger must never throw into business code; swallowing and reporting appender errors is a feature.
  • Avoid a hard-coded singleton; one LogManager per application, reachable through a static facade if needed, keeps tests isolated.

References

My notes saved in this browser