Skip to content
LLD Learn/Case Studies: Data-Structure-Heavy Systems
Browsing as a guest. Sign in to save your progress and earn XP as you complete chapters.

Design a Logging Framework

8 min read

You'll learn to

  • -Design log levels, appenders, and formatters so new output targets can be added without touching existing code
  • -Combine Singleton (logger registry), Strategy (formatter), and Chain of Responsibility (level filtering) in one coherent design

A logging framework is a favorite LLD prompt for one specific reason: a genuinely good design naturally combines three patterns you have already learned, rather than needing any new ones, which makes it an excellent test of whether you can compose patterns instead of just naming them individually.

Step 1: Log Levels and the Filtering Chain

DEBUG, INFO, WARNING, ERROR form a natural severity ordering, and a logger configured at, say, WARNING should suppress DEBUG and INFO messages while allowing WARNING and ERROR through. This maps directly onto Chain of Responsibility from earlier in this course: a chain of level-handlers, each deciding whether a message at its severity should be processed and passed along, or dropped.

Log levels as an ordered chain of handlers
from enum import IntEnum

class LogLevel(IntEnum):
    DEBUG = 1
    INFO = 2
    WARNING = 3
    ERROR = 4

class LogMessage:
    def __init__(self, level: LogLevel, text: str):
        self.level = level
        self.text = text

class LogHandler(ABC):
    def __init__(self, min_level: LogLevel):
        self._min_level = min_level
        self._next: "LogHandler | None" = None

    def set_next(self, handler: "LogHandler") -> "LogHandler":
        self._next = handler
        return handler

    def handle(self, message: LogMessage) -> None:
        if message.level >= self._min_level:
            self._write(message)
        if self._next:
            self._next.handle(message)

    @abstractmethod
    def _write(self, message: LogMessage) -> None: ...

Step 2: Appenders as Independent Handlers

Different output destinations (console, file, remote log aggregator) are appenders, each with its own minimum level and its own formatter - a ConsoleAppender might show everything from DEBUG up, while a FileAppender only persists WARNING and above, both wired into the same chain and each receiving every message, filtering independently.

Console and file appenders, each independently configured
class ConsoleAppender(LogHandler):
    def __init__(self, min_level: LogLevel, formatter: "LogFormatter"):
        super().__init__(min_level)
        self._formatter = formatter
    def _write(self, message: LogMessage) -> None:
        print(self._formatter.format(message))

class FileAppender(LogHandler):
    def __init__(self, min_level: LogLevel, formatter: "LogFormatter", filename: str):
        super().__init__(min_level)
        self._formatter = formatter
        self._filename = filename
    def _write(self, message: LogMessage) -> None:
        with open(self._filename, "a") as f:
            f.write(self._formatter.format(message) + "\n")

Step 3: Formatters as a Swappable Strategy

How a message is rendered (plain text, JSON, a specific timestamp format) is independent of where it goes - a clean Strategy split, exactly as covered earlier. A JSONFormatter and a PlainTextFormatter both implement format(message) -> str, and either appender above can be configured with either formatter without any change to the appender itself.

Step 4: A Singleton Logger Registry

One global entry point, applying the double-checked-locking Singleton from earlier
class Logger:
    _instance: "Logger | None" = None

    def __new__(cls):
        if cls._instance is None:
            cls._instance = super().__new__(cls)
            cls._instance._chain = None
        return cls._instance

    def configure(self, chain_head: LogHandler) -> None:
        self._chain = chain_head

    def log(self, level: LogLevel, text: str) -> None:
        if self._chain:
            self._chain.handle(LogMessage(level, text))

# Application code anywhere just does:
Logger().log(LogLevel.ERROR, "Payment failed")

This is a legitimate, defensible use of Singleton - revisit the Singleton chapter's framing: logging is called from deep in nearly every part of a codebase, and threading a Logger instance as a parameter through every function would add real, ongoing friction for comparatively little benefit, unlike most Singleton use cases where dependency injection is clearly better.

Presenting this design as "Chain of Responsibility for level filtering, Strategy for formatting, Singleton for the global entry point" - naming all three explicitly - is exactly the composition skill this capstone-style problem is testing.

Interview Signal is part of Pro

See a real weak answer next to a real strong one for this exact topic.

Quiz is part of Pro

Test what you just read with a short quiz, and bank the XP.

ScaleDojo Logo
Initializing ScaleDojo