Low Level Design

Design a Logging Framework

A complete low-level design walkthrough for a custom logging framework — from requirements clarification to Singleton, Strategy, and Builder patterns, with async log processing and pluggable appenders in Java.

August 13, 2026·15 min read

Problem Description#

Design a Logging Framework that can capture, filter, format, and route log messages to multiple output destinations from any part of an application.

Logging is one of those cross-cutting concerns that every real system needs, yet it's surprisingly rich in design decisions:

  • Log messages must be filtered by severity so that debug noise doesn't flood production
  • The same message may need to reach multiple outputs — console for developers, a file for ops, a database for audit
  • Writing to slow outputs (disk, network) must never block the calling thread
  • The framework must be safe when many threads log simultaneously

This is a great LLD interview problem because it naturally exercises the Singleton, Strategy, Builder, and Producer–Consumer patterns in a domain every engineer already understands.


Clarify Requirements#

Before designing anything, ask these questions in an interview:

Functional

  • What log levels are needed? (DEBUG, INFO, WARN, ERROR?)
  • Can severity levels be filtered globally and per-logger independently?
  • What output destinations are needed — console, file, database?
  • Should a child logger inherit its parent's appenders?
  • Does each appender need its own formatting, or is there one global format?
  • Should loggers be named (e.g. com.example.service) to form a hierarchy?

Non-functional

  • Must logging be non-blocking to the calling thread?
  • Is thread safety required for concurrent writes?
  • Should the framework be extensible to new log levels and appenders without touching existing code?

Final Requirements#

After clarification, here's what we'll build:

  • Four log levels — DEBUG, INFO, WARN, ERROR — with numeric severity for filtering
  • Logger hierarchy — dot-separated names form a parent chain; a child without a configured level inherits its nearest ancestor's level
  • Hierarchy propagation — a log event walks up the logger chain and is dispatched to every appender it encounters (child → parent → root)
  • Two appenders — ConsoleAppender (stdout) and FileAppender (disk); each carries its own LogFormatter
  • Strategy formatters — LogFormatter interface with a TextFormatter implementation; swap formats per-appender without modifying appenders
  • Async processing — AsyncLogProcessor wraps a single-threaded ExecutorService; callers never block on I/O
  • LogMessage Builder — immutable log event constructed via a fluent builder with required-field validation
  • LoggingManager Singleton — double-checked locking; central registry for all loggers and the async processor; orchestrates graceful shutdown

Core Entities#

EntityResponsibility
LogLevelEnum defining severity (DEBUG=1 … ERROR=4) with isGreaterOrEqual() for filtering
LogMessageImmutable model — level, message, logger name, timestamp; constructed via inner Builder
LogFormatterInterface — format(LogMessage) String; enables pluggable output formats
TextFormatterConcrete strategy — formats messages as "timestamp LEVEL - loggerName: message"
LogAppenderInterface — append(LogMessage) + close(); pluggable output destination
ConsoleAppenderWrites formatted messages to System.out; holds a LogFormatter
FileAppenderWrites formatted messages to a file via FileWriter; holds a LogFormatter; thread-safe append
LoggerCore logging unit — filters by effective level, holds appenders, propagates to parent
AsyncLogProcessorWraps a single-threaded ExecutorService; submits appender calls off the calling thread
LoggingManagerSingleton facade — creates / caches loggers, owns the processor, manages shutdown

Patterns Used#

1. Singleton — LoggingManager#

There must be exactly one logging configuration in the process — one registry of loggers, one async processor, one shutdown hook. LoggingManager uses double-checked locking with a volatile instance field: the outer null-check keeps the common path lock-free; the inner check inside synchronized closes the race window.

LoggingManager (volatile instance)
 └─ loggers : Map<String, Logger>   ← shared registry
 └─ rootLogger                      ← default parent for all loggers
 └─ AsyncLogProcessor               ← single processor, owned here

2. Strategy — LogAppender and LogFormatter#

Appenders and formatters are fully interchangeable. ConsoleAppender and FileAppender both implement LogAppender; TextFormatter (and any future JsonFormatter) implements LogFormatter. A logger or appender depends only on the interface — adding a new output (Kafka, database) or format (JSON, XML) touches zero existing code.

This is Open/Closed in action: the framework is open for extension (new appender, new formatter) and closed for modification.

3. Builder — LogMessage#

LogMessage is immutable: once constructed its fields never change. The inner Builder provides a fluent API, enforces that logLevel and message are set before build() is called, and avoids a telescoping constructor. Immutability is also what makes it safe to hand a LogMessage to the async processor without copying or synchronizing.

4. Composite-like Hierarchy — Logger with parent reference#

Each Logger holds a reference to its parent (determined at creation time from the dot-separated name). When a log event passes the level filter, callAppender walks the chain from child to root, dispatching to every appender it finds. This mirrors Log4j's propagation model and means a single root ConsoleAppender covers all loggers without configuring each one individually.

5. Producer–Consumer — AsyncLogProcessor#

The calling thread (producer) hands a LogMessage to AsyncLogProcessor.process(); the single background thread (consumer) pulls tasks from the executor queue and calls each appender. The calling thread is never blocked by slow I/O. Using a single-threaded executor also guarantees that log messages from any source are written in the order they were submitted — no interleaving.


Code#

Models — LogLevel and LogMessage#

LogLevel defines severity with a numeric field for comparison. LogMessage is the immutable event object produced once per log call.

java
public enum LogLevel {
    DEBUG(1), INFO(2), WARN(3), ERROR(4);

    private final int level;

    LogLevel(int level) {
        this.level = level;
    }

    public boolean isGreaterOrEqual(LogLevel other) {
        return this.level >= other.level;
    }
}

Strategy — Formatter and Appenders#

LogFormatter is the formatting contract; TextFormatter is its default implementation. LogAppender is the output contract; ConsoleAppender and FileAppender cover the two common destinations.

java
public interface LogFormatter {
    String format(LogMessage message);
}

Core — AsyncLogProcessor and Logger#

AsyncLogProcessor decouples log generation from I/O. Logger handles level filtering and hierarchy propagation.

java
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;

public class AsyncLogProcessor {
    private final ExecutorService executor;

    public AsyncLogProcessor() {
        this.executor = Executors.newSingleThreadExecutor(r -> {
            Thread t = new Thread(r, "AsyncLogProcessor");
            t.setDaemon(true);
            return t;
        });
    }

    public void process(LogMessage logMessage, List<LogAppender> appenders) {
        if (executor.isShutdown()) {
            System.err.println("Logger is shut down — cannot process logs");
            return;
        }
        executor.submit(() -> {
            for (LogAppender appender : appenders) {
                appender.append(logMessage);
            }
        });
    }

    public void stop() {
        executor.shutdown();
        try {
            if (!executor.awaitTermination(2, TimeUnit.SECONDS)) {
                System.err.println("Async processor did not finish in time");
                executor.shutdownNow();
            }
        } catch (InterruptedException e) {
            executor.shutdownNow();
            Thread.currentThread().interrupt();
        }
    }
}

Singleton — LoggingManager#

Central registry that builds the logger hierarchy and owns the async processor.

java
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

public class LoggingManager {
    private static volatile LoggingManager instance;
    private final Map<String, Logger> loggers;
    private final Logger rootLogger;
    private final AsyncLogProcessor processor;

    private LoggingManager() {
        this.loggers    = new ConcurrentHashMap<>();
        this.rootLogger = new Logger("root", null);
        this.loggers.put("root", rootLogger);
        this.processor  = new AsyncLogProcessor();
    }

    public static LoggingManager getInstance() {
        if (instance == null) {
            synchronized (LoggingManager.class) {
                if (instance == null) instance = new LoggingManager();
            }
        }
        return instance;
    }

    public Logger getRootLogger()     { return rootLogger; }
    public AsyncLogProcessor getProcessor() { return processor; }

    public Logger getLogger(String name) {
        Logger existing = loggers.get(name);
        if (existing != null) return existing;

        synchronized (this) {
            existing = loggers.get(name);
            if (existing != null) return existing;
            Logger newLogger = createLogger(name);
            loggers.put(name, newLogger);
            return newLogger;
        }
    }

    private Logger createLogger(String name) {
        if (name.equals("root")) return rootLogger;
        int lastDot = name.lastIndexOf('.');
        String parentName = (lastDot == -1) ? "root" : name.substring(0, lastDot);
        Logger parent = getLogger(parentName);
        return new Logger(name, parent);
    }

    public void shutdown() {
        processor.stop();
        loggers.values().stream()
               .flatMap(l -> l.getAppenderList().stream())
               .distinct()
               .forEach(LogAppender::close);
        System.out.println("Logging framework shut down gracefully.");
    }
}

Demo#

java
public class LoggingDemo {
    public static void main(String[] args) {
        LoggingManager manager = LoggingManager.getInstance();

        // Root: INFO level → console
        Logger root = manager.getRootLogger();
        root.setLogLevel(LogLevel.INFO);
        root.addAppender(new ConsoleAppender(new TextFormatter()));

        // com.example.main inherits INFO from root
        Logger mainLogger = manager.getLogger("com.example.main");
        mainLogger.info("Application starting");
        mainLogger.debug("This debug message is filtered out");  // below INFO
        mainLogger.warn("Low disk space warning");

        // com.example.db overrides to DEBUG
        Logger dbLogger = manager.getLogger("com.example.db");
        dbLogger.setLogLevel(LogLevel.DEBUG);
        dbLogger.debug("Executing query — SELECT * FROM users");
        dbLogger.info("DB connection established");

        // com.example.service also writes to a file
        Logger serviceLogger = manager.getLogger("com.example.service");
        serviceLogger.addAppender(new FileAppender(new TextFormatter(), "logs/service.log"));
        serviceLogger.info("Payment service started");
        serviceLogger.warn("Retry attempt 1 of 3");

        manager.shutdown();
    }
}

Class Diagram#


Extendible — Follow Ups#

1. JSON formatter#

Add JsonFormatter implements LogFormatter that produces {"timestamp":"…","level":"INFO","logger":"…","message":"…"}. Attach it to a FileAppender without touching any existing class. Demonstrates pure Open/Closed: new format, zero changes to existing code.

2. Log level FATAL and runtime level changes#

Add FATAL(5) to the enum and expose LoggingManager.setRootLevel(LogLevel) so ops can switch from INFO to DEBUG at runtime without restarting the service. Because getEffectiveLevel() reads the field on every call, the change takes effect immediately across all child loggers.

3. Rolling file appender#

Extend FileAppender into RollingFileAppender that rotates the log file when it exceeds a size threshold (e.g. 10 MB) or at midnight, renaming the old file with a date suffix and opening a fresh one. Callers hold a LogAppender reference — no changes needed anywhere else.

4. Async batch writes#

Replace the per-message executor.submit() in AsyncLogProcessor with a BlockingQueue<Runnable> and a background thread that drains the queue in configurable batches. Batch writes to disk are far cheaper than one flush() per message when log volume is high.

5. NDC / MDC context propagation#

Add a ThreadLocal<Map<String, String>> Mapped Diagnostic Context to Logger. TextFormatter.format() appends the thread's MDC (e.g. requestId, userId) to every message automatically. Invaluable for correlating logs across async boundaries in a web service.

6. Appender filters#

Add a LogFilter interface to LogAppender — boolean accept(LogMessage). A FileAppender could be configured to only write ERROR and above while the root ConsoleAppender still shows everything. This avoids duplicating logger-level configuration per appender.