Back to LLD Problems: Parking Lot, Elevator, Chess, etc.
Q6Question 6 of 12

Design a Logging Framework

Key Concepts
  • The Logger checks a minimum LogLevel threshold and discards messages below it cheaply.
  • Levels are ordered DEBUG, INFO, WARN, ERROR; the threshold filters what passes through.
  • An Appender interface abstracts the destination: ConsoleAppender, FileAppender, RemoteAppender (Strategy pattern).
  • A Formatter turns a level, timestamp, and message into a formatted line, pluggable independently of the appender.
  • Messages are passed as supplier lambdas so string building is deferred until the level is confirmed enabled.
  • Thread safety is non-negotiable: appenders must synchronise writes or use a lock-free queue for concurrent logging.
  • In high-throughput banking systems, an asynchronous appender backed by a ring buffer keeps logging from blocking the business thread.

Definition

A logging framework filters by level, formats the message, and fans it out to multiple appenders, safely across threads.
  • Logger – holds a minimum LogLevel threshold; discards messages below the threshold cheaply.
  • LogLevel – ordered enum: DEBUG, INFO, WARN, ERROR.
  • Appender – interface abstracting the destination (console, file, remote); a Strategy for where logs go.
  • Formatter – turns a level, timestamp, and message into a formatted output line.
  • LoggerFactory – hands out loggers, typically cached per name as a Singleton.
  • Thread safety – appenders must synchronise writes or use a lock-free queue for concurrent logging.

Think of the logging framework as the audit department at National Bank of India. Every action Rajesh takes is reported. The department decides which reports are important enough to keep (level threshold), stamps each with a standard header (formatter), and files copies to several places: a local binder, a central archive, and a monitoring desk (appenders). Many tellers report at once, so the filing must be orderly and safe (thread safety).

Understanding the Concepts

What are the core entities?

The core entities are Logger, LogLevel, Appender, Formatter, and LoggerFactory.

JAVA
package com.bank.logging;

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

    private final int severity;

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

    public boolean isEnabled(LogLevel threshold) {
        return this.severity >= threshold.severity;
    }
}

How does the Logger work?

The Logger holds a minimum threshold and a list of appenders. Messages below the threshold are discarded cheaply.

JAVA
package com.bank.logging;

import java.util.List;
import java.util.ArrayList;
import java.util.function.Supplier;

public class Logger {
    private final String name;
    private LogLevel threshold;
    private final List<Appender> appenders;
    private Formatter formatter;

    public Logger(String name, LogLevel threshold,
                  Formatter formatter) {
        this.name = name;
        this.threshold = threshold;
        this.appenders = new ArrayList<>();
        this.formatter = formatter;
    }

    public void addAppender(Appender appender) {
        appenders.add(appender);
    }

    public void log(LogLevel level, Supplier<String> messageSupplier) {
        if (!level.isEnabled(threshold)) return;
        String formatted = formatter.format(
                level, name, messageSupplier.get());
        for (Appender appender : appenders) {
            appender.append(formatted);
        }
    }

    public void debug(Supplier<String> msg) {
        log(LogLevel.DEBUG, msg);
    }

    public void info(Supplier<String> msg) {
        log(LogLevel.INFO, msg);
    }

    public void warn(Supplier<String> msg) {
        log(LogLevel.WARN, msg);
    }

    public void error(Supplier<String> msg) {
        log(LogLevel.ERROR, msg);
    }
}

Why use supplier lambdas for messages?

Passing messages as a Supplier defers string building until the level is confirmed enabled. In a hot transfer path at National Bank of India, building log strings that get thrown away is pure waste.

JAVA
// Efficient: string only built if INFO is enabled
logger.info(() -> "Transfer " + amount + " from " + from
        + " to " + to);

// Wasteful: string always built, even if disabled
logger.info("Transfer " + amount + " from " + from
        + " to " + to);

How does the Appender interface work?

The Appender is a Strategy for where logs go. Adding a new destination is a new class.

JAVA
package com.bank.logging;

public interface Appender {
    void append(String formattedMessage);
}
JAVA
package com.bank.logging;

public class ConsoleAppender implements Appender {
    @Override
    public synchronized void append(String formattedMessage) {
        System.out.println(formattedMessage);
    }
}
JAVA
package com.bank.logging;

import java.io.*;

public class FileAppender implements Appender {
    private final PrintWriter writer;

    public FileAppender(String filePath) throws IOException {
        this.writer = new PrintWriter(
                new FileWriter(filePath, true));
    }

    @Override
    public synchronized void append(String formattedMessage) {
        writer.println(formattedMessage);
        writer.flush();
    }
}

How does the Formatter work?

The Formatter turns level, timestamp, logger name, and message into a formatted line. It is pluggable independently of the appender.

JAVA
package com.bank.logging;

import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;

public class SimpleFormatter implements Formatter {
    private static final DateTimeFormatter TS =
            DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");

    @Override
    public String format(LogLevel level, String loggerName,
                          String message) {
        return LocalDateTime.now().format(TS)
                + " [" + level + "] "
                + loggerName + " - " + message;
    }
}
JAVA
package com.bank.logging;

public interface Formatter {
    String format(LogLevel level, String loggerName, String message);
}
TEXT
2025-01-15 14:30:22 [INFO] TransferService - Transfer 50000 from RAJESH-001 to PRIYA-002

How does LoggerFactory work?

A LoggerFactory hands out loggers, typically cached per name as a Singleton.

JAVA
package com.bank.logging;

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

public class LoggerFactory {
    private static final Map<String, Logger> LOGGERS =
            new ConcurrentHashMap<>();
    private static LogLevel defaultThreshold = LogLevel.INFO;
    private static Formatter defaultFormatter = new SimpleFormatter();

    public static Logger getLogger(String name) {
        return LOGGERS.computeIfAbsent(name,
                n -> new Logger(n, defaultThreshold, defaultFormatter));
    }

    public static void setDefaultThreshold(LogLevel level) {
        defaultThreshold = level;
    }
}

How do you handle thread safety?

Thread safety is non-negotiable because many threads log concurrently. Each appender's append() method is synchronised. For high-throughput systems, use an asynchronous appender backed by a ring buffer.

JAVA
package com.bank.logging;

import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;

public class AsyncAppender implements Appender {
    private final Appender delegate;
    private final BlockingQueue<String> queue;
    private final Thread worker;

    public AsyncAppender(Appender delegate) {
        this.delegate = delegate;
        this.queue = new LinkedBlockingQueue<>(10000);
        this.worker = new Thread(() -> {
            while (!Thread.currentThread().isInterrupted()) {
                try {
                    String msg = queue.take();
                    delegate.append(msg);
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                }
            }
        }, "async-log-worker");
        worker.setDaemon(true);
        worker.start();
    }

    @Override
    public void append(String formattedMessage) {
        queue.offer(formattedMessage); // non-blocking
    }
}

What about extensibility?

Switching from plain text to structured JSON logs is a new Formatter class. Adding a Kafka appender is a new Appender class. Neither requires changing the Logger.

What should a senior engineer know?

  • Logging must be both cheap when disabled (supplier lambdas) and safe under heavy concurrency (synchronised or async appenders).
  • In production banking systems, an async appender backed by a ring buffer (like LMAX Disruptor) ensures the business thread never blocks on I/O.
  • The MDC (Mapped Diagnostic Context) pattern adds per-thread context like transaction ID to every log line without passing it explicitly.
  • Log rotation, compression, and retention policies are real production concerns.

What are common interview follow-up questions?

  • How do you ensure the async appender does not lose logs on shutdown?
  • How would you add structured JSON logging?
  • How do you implement log rotation?
  • How does the MDC pattern work for adding transaction IDs?
  • What is the performance impact of synchronised vs async appenders?

Visual Overview

DIAGRAM
  Logging Pipeline:
  ─────────────────

  logger.info(() -> "message")
       │
       ▼
  Level check: INFO >= threshold?
       │
       ├── no → discard (cheap, no string built)
       │
       └── yes → build string from supplier
                    │
                    ▼
              Formatter.format(level, name, message)
                    │
                    ▼
              ┌─────┴──────────────┐
              ▼                    ▼
        ConsoleAppender     FileAppender
        (synchronized)      (synchronized)
DIAGRAM
  Logger
    │
    ├── threshold: INFO
    ├── formatter: SimpleFormatter
    └── appenders:
         ├── ConsoleAppender
         ├── FileAppender (/var/log/bank.log)
         └── AsyncAppender → RemoteAppender (Kafka)

Key Takeaway

When you design a logging framework, separate four responsibilities so each can evolve independently. The Logger decides whether a message passes the configured level threshold. The Formatter decides how a line looks. The Appender decides where it goes. And a LoggerFactory hands out loggers that share configuration. This separation is what lets a framework support console in development and file plus remote in production without rewriting the core.

Levels are ordered from debug up to error, and the Logger holds a minimum threshold so anything below it is discarded cheaply. A key efficiency trick is accepting the message as a supplier lambda, so the string is only built when the level is actually enabled. In a hot transfer path at National Bank of India, building log strings that get thrown away is pure waste.

The Appender is an interface with implementations for console, file, and remote destinations, which is the Strategy pattern. Adding a new sink like a database or Kafka is a new class rather than a change to the Logger. The Formatter is pluggable the same way, so switching from plain text to structured JSON is a configuration choice.

Thread safety is non-negotiable because many transfer threads log at the same instant, and the log file must never get garbled. Synchronise appender writes for correctness, and in high-throughput services move to an asynchronous appender backed by a ring buffer so the business thread never blocks on I/O. The senior insight is that logging must be both cheap when disabled and safe under heavy concurrency, which is exactly why supplier lambdas and async appenders are essential.

Remember one sentence: a logging framework filters by level, formats through a pluggable formatter, fans out to strategy-based appenders, and must be both cheap when disabled and safe under concurrency.

---

Share
Use ← → arrow keys to navigate