Case Studies

Logger Utility (Log4j-style)

Design a flexible logger with levels, multiple outputs, formatters, and async writes.

5 min readDesigns · LLD & Patterns

Interview practice: try designing this yourself before reading. Go to the exercise ↓

Problem Statement

Design a logging library that:

  • Supports log levels (TRACE, DEBUG, INFO, WARN, ERROR, FATAL)
  • Filters logs below a configured level
  • Writes to multiple destinations (console, file, network)
  • Allows custom formatting
  • Handles async logging without blocking the caller
  • Is thread-safe

Requirements

Functional

  • Per-logger configurable level
  • Multiple appenders (console, rolling file, syslog, kafka)
  • Configurable formatter (plain, JSON, key-value)
  • Hierarchical loggers (com.app.module)
  • Runtime config changes (level, add/remove appender)

Non-Functional

  • Lock-free or low-contention hot path
  • Async writes for slow appenders (file I/O, network)
  • Bounded memory (drop or block on overflow)

Class Diagram


Levels

public enum LogLevel {
    TRACE(0), DEBUG(1), INFO(2), WARN(3), ERROR(4), FATAL(5);

    public final int rank;
    LogLevel(int rank) { this.rank = rank; }

    public boolean isAtLeast(LogLevel other) { return this.rank >= other.rank; }
}

A logger with level=INFO accepts INFO/WARN/ERROR/FATAL and skips TRACE/DEBUG.


LogEvent

public class LogEvent {
    public final long timestamp;
    public final LogLevel level;
    public final String loggerName;
    public final String message;
    public final Throwable throwable;
    public final String threadName;

    public LogEvent(LogLevel level, String loggerName, String message, Throwable t) {
        this.timestamp = System.currentTimeMillis();
        this.level = level;
        this.loggerName = loggerName;
        this.message = message;
        this.throwable = t;
        this.threadName = Thread.currentThread().getName();
    }
}

LogEvent is immutable — safe to share across threads, queues, etc.


Logger

public class Logger {
    private final String name;
    private volatile LogLevel level;
    private final List<Appender> appenders = new CopyOnWriteArrayList<>();

    public Logger(String name, LogLevel level) {
        this.name = name; this.level = level;
    }

    public void setLevel(LogLevel l) { this.level = l; }
    public void addAppender(Appender a) { appenders.add(a); }

    public void log(LogLevel l, String msg, Throwable t) {
        if (!l.isAtLeast(level)) return;       // fast-path: short-circuit
        LogEvent event = new LogEvent(l, name, msg, t);
        for (Appender a : appenders) {
            try { a.append(event); }
            catch (Throwable err) { System.err.println("Appender failed: " + err); }
        }
    }

    public void trace(String m) { log(LogLevel.TRACE, m, null); }
    public void debug(String m) { log(LogLevel.DEBUG, m, null); }
    public void info(String m)  { log(LogLevel.INFO,  m, null); }
    public void warn(String m)  { log(LogLevel.WARN,  m, null); }
    public void error(String m) { log(LogLevel.ERROR, m, null); }
    public void error(String m, Throwable t) { log(LogLevel.ERROR, m, t); }
    public void fatal(String m) { log(LogLevel.FATAL, m, null); }
}

The level check is the first thing — disabled logs cost a single comparison.

String formatting hot-path optimization

// BAD — always builds the string, even if DEBUG is off
log.debug("Processing user " + user + " with " + items.size() + " items");

// GOOD — varargs, only formats if enabled
log.debug("Processing user {} with {} items", user, items.size());

// Inside Logger.log:
public void log(LogLevel l, String pattern, Object... args) {
    if (!l.isAtLeast(level)) return;     // skip formatting entirely
    String msg = MessageFormatter.arrayFormat(pattern, args);
    // ...
}

Appenders

public interface Appender {
    void append(LogEvent event);
}

public class ConsoleAppender implements Appender {
    private final Formatter formatter;

    public ConsoleAppender(Formatter f) { this.formatter = f; }

    @Override
    public void append(LogEvent event) {
        PrintStream stream = (event.level.rank >= LogLevel.WARN.rank)
            ? System.err : System.out;
        stream.println(formatter.format(event));
    }
}

public class FileAppender implements Appender {
    private final BufferedWriter writer;
    private final Formatter formatter;
    private final Object lock = new Object();

    public FileAppender(Path path, Formatter f) throws IOException {
        this.writer = Files.newBufferedWriter(path, StandardOpenOption.CREATE, StandardOpenOption.APPEND);
        this.formatter = f;
    }

    @Override
    public void append(LogEvent event) {
        synchronized (lock) {
            try {
                writer.write(formatter.format(event));
                writer.newLine();
                if (event.level.rank >= LogLevel.ERROR.rank) writer.flush();   // immediate flush for errors
            } catch (IOException e) { /* swallow or report */ }
        }
    }
}

AsyncAppender (Decorator)

Wrap any appender to make it async:

public class AsyncAppender implements Appender, AutoCloseable {
    private final Appender wrapped;
    private final BlockingQueue<LogEvent> queue;
    private final Thread worker;
    private volatile boolean running = true;

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

    @Override
    public void append(LogEvent event) {
        // Drop on overflow rather than block the caller
        if (!queue.offer(event)) {
            // metric: dropped log count
        }
    }

    private void drain() {
        try {
            while (running || !queue.isEmpty()) {
                LogEvent e = queue.poll(500, TimeUnit.MILLISECONDS);
                if (e != null) wrapped.append(e);
            }
        } catch (InterruptedException ie) {
            Thread.currentThread().interrupt();
        }
    }

    @Override
    public void close() throws InterruptedException {
        running = false;
        worker.join(2_000);
    }
}

The hot path becomes: level check → enqueue. The actual I/O happens on a single background thread.


Formatters (Strategy)

public interface Formatter {
    String format(LogEvent event);
}

public class PlainFormatter implements Formatter {
    public String format(LogEvent e) {
        return String.format("[%s] %5s [%s] %s - %s",
            Instant.ofEpochMilli(e.timestamp),
            e.level, e.threadName, e.loggerName, e.message)
            + (e.throwable != null ? "\n" + stackTrace(e.throwable) : "");
    }
}

public class JsonFormatter implements Formatter {
    public String format(LogEvent e) {
        return String.format(
            "{\"ts\":%d,\"level\":\"%s\",\"thread\":\"%s\",\"logger\":\"%s\",\"msg\":\"%s\"}",
            e.timestamp, e.level, escape(e.threadName), e.loggerName, escape(e.message));
    }
}

LoggerFactory (Singleton + Factory)

public class LoggerFactory {
    private static final Map<String, Logger> loggers = new ConcurrentHashMap<>();
    private static volatile Config config = Config.defaultConfig();

    public static Logger get(String name) {
        return loggers.computeIfAbsent(name, n -> {
            Logger l = new Logger(n, config.levelFor(n));
            for (Appender a : config.appendersFor(n)) l.addAppender(a);
            return l;
        });
    }

    public static void reconfigure(Config c) {
        config = c;
        for (Logger l : loggers.values()) {
            l.setLevel(config.levelFor(l.name()));
            // reset appenders
        }
    }
}

Hierarchical lookup: Logger("com.app.UserService") inherits level from com.app if com.app.UserService isn't explicitly configured.


Sequence


Edge Cases

CaseHandling
AsyncAppender queue fullDrop with counter, or put() if blocking is acceptable
File rotation (size or daily)Decorate FileAppender with RollingFileAppender
App crash before flushFlush on ERROR/FATAL; shutdown hook for clean exit
Re-entrant logging (logger logs itself)Skip if already in append; or use a separate "internal" logger
Log loops (e.g., logging from equals())Stack overflow risk; tag thread-local "logging now" guard
Configuration change at runtimeVolatile config; readers see fresh values atomically

Design Patterns Used

PatternWhere
SingletonLoggerFactory
FactoryLogger creation by name
StrategyFormatter
DecoratorAsyncAppender wraps any appender
Chain of responsibilityHierarchical logger lookup
ObserverAppenders subscribe to logger output
Producer-consumerAsyncAppender's queue + worker

Interview Tips

  • Lead with the level check is the hot path — must be O(1) and lock-free.
  • Pull I/O off the caller thread via AsyncAppender — interviewers expect this for a real logger.
  • Mention immutable LogEvent — safe across threads.
  • Bring up parameterized formatting ({}-placeholders) to avoid string concat when log is disabled.
  • For production, mention slf4j / logback / log4j2 — they're real-world implementations of this design.

Practice

Try it yourself

Design a logging library like Log4j or Python's logging module.

Your design should cover

  • Log levels (debug, info, warn, error) with a configurable minimum level
  • Several outputs at once: console, file, remote
  • Configurable message format
  • Logging must not slow down the calling thread much
  • Named loggers configured per module
0 / 8,000 characters · saved in this browser

Finished reading?

Mark it done to track your progress in Designs.