Facebook Pixel

Design a Logging Framework

A logging framework is infrastructure other code depends on, not a domain model — a utility problem. The design must stay invisible to callers — no caller should need to know whether messages go to a file, a console, or a network socket. The goal is a core clean enough to support all three delivery targets and to absorb format or destination changes without modifying the Logger itself.

See It in Action

The demo shows one log call fanning out to several appenders — a console, a file, and a network sink — each formatting the same record its own way. Toggle an appender to see destinations attach and detach independently, and raise the minimum level to watch the logger drop messages before any appender runs.

Logging Framework

Clarifying Requirements

A few targeted questions resolve the key decisions behind "design a logging framework" before any class is drawn. The functional requirements we will commit to: a LogLevel enum with four levels — DEBUG, INFO, WARN, and ERROR — ordered from least to most severe; a Logger that holds a minimum level and filters out any message below it; a Formatter that renders each accepted message as [LEVEL] message; and an Appender interface that the logger hands formatted lines to, with an in-memory implementation for deterministic testing.

Two commands drive the system: setlevel changes the minimum level immediately (no output), and log passes a message at a given level, producing one formatted line if the level clears the filter or nothing if it does not. The message text may contain spaces; all tokens after the level name are joined to form it.

The decision path for a single log call collapses to three steps:

Core Entities

Scanning the requirements for the nouns that carry state or enforce rules gives a short list. A LogLevel is an ordered value — DEBUG is the lowest, ERROR the highest. Using an integer enum means the filter check is a single comparison rather than a lookup table. A LogMessage is a value object that captures the level and text at the moment of the call; it is immutable because an async appender might hold a reference to it after the caller has moved on. A Formatter is the Strategy that decides how a message looks as a string. An Appender is the Strategy that decides where the formatted string goes. A Logger is the orchestrator: it holds the current minimum level, applies the filter, creates the message, and delegates to its appender.

One class that was not modeled: a "LogRegistry" mapping string names to logger instances is a reasonable production feature, but it adds global state and is not required to demonstrate the design. Defer it to extensions unless the interviewer asks specifically.

The five entities and their relationships at a glance:

Designing the Classes

Step 3 of the delivery framework asks you to derive each class's state from the requirements you listed in Step 1, then derive behavior (method signatures) from what callers need to do with that state. The guiding principle is Tell, Don't Ask: a class should make decisions about its own data rather than exposing raw fields for callers to inspect.

LogLevel

The requirements say levels must be ordered — DEBUG is the lowest, ERROR the highest — and that the filter check compares two levels. That ordering requirement immediately points to an integer enum rather than a plain string or a plain enum: IntEnum in Python gives you < directly, so level < min_level reads exactly like the requirement.

State: four named constants with integer values DEBUG=10, INFO=20, WARN=30, ERROR=40. The gap of 10 between values leaves room for custom levels without a rewrite.

Behavior: none beyond the built-in integer comparison inherited from IntEnum.

LogMessage

The requirements say the logger must capture the level and text at the moment log is called. "Capture at the moment" implies immutability — once a LogMessage is created, nothing should change it. An async appender or a future audit trail might hold a reference long after the caller has moved on; a mutable message would be a race condition waiting to happen.

State: level: LogLevel, text: str — set in __init__ and never modified.

Behavior: none. LogMessage is a pure value object, a named container for two fields. No methods beyond construction — callers read its fields, they do not ask it to make decisions.

Formatter (abstract) and SimpleFormatter

The requirements say messages are rendered as [LEVEL] message, but the Extensions section calls out timestamps, thread context, and JSON output as future formats — and delivery may go to memory now, a file or socket later. Both rendering and delivery are axes that will change independently of the Logger. Before reading on, decide how to keep the Logger from having to change every time one of them does.

Decision checkpoint

1)

Message rendering ([LEVEL] message vs JSON vs timestamped) and delivery (memory vs file vs socket) both vary. Hard-code them on Logger, or keep Logger independent of which format and destination are in use?

Both axes become Strategy interfaces, so the Logger never names a concrete format or destination.

State (abstract Formatter): none. The interface is stateless — it is a pure function wrapped in a class so it can be swapped.

Behavior: format(msg: LogMessage) -> str — the single method every concrete formatter must implement.

SimpleFormatter adds no state and implements exactly one method: format returns f"[{msg.level.name}] {msg.text}". The .name property on IntEnum gives the string "DEBUG", "INFO", and so on without a lookup table.

Appender (abstract) and MemoryAppender

The requirements say formatted lines must be delivered somewhere — a file, a console, a network socket. That variability belongs in a second Strategy interface. The Appender owns a Formatter because different appenders may want different formats (a file appender and a structured JSON appender are a natural pair). Holding the formatter inside the Appender keeps each delivery target self-contained.

State (abstract Appender): _formatter: Formatter — injected at construction.

Behavior: append(msg: LogMessage) -> None — delegates formatting to _formatter, then sends the resulting string wherever the concrete subclass delivers it.

MemoryAppender adds _lines: list[str] and implements append by calling self._formatter.format(msg) and storing the result. get_lines() returns a copy so the caller cannot mutate internal state.

Logger

The Logger is the orchestrator. The requirements say it holds a minimum level, filters incoming log calls against it, and hands accepted messages to one or more appenders. Tell, Don't Ask means the Logger — not the caller — decides whether a message passes the filter.

State: _min_level: LogLevel (mutable, changed by set_level), _appenders: list[Appender] (a list so multiple appenders can fan out the same message).

Behavior:

  • add_appender(appender) -> Logger — registers a delivery target; returns self for chaining.
  • set_level(level) -> None — replaces _min_level; takes effect immediately for all subsequent log calls.
  • log(level, text) -> None — applies the level filter, then wraps and dispatches a passing message to every registered appender.

Where exactly that filter runs is the last decision. It could sit at the top of Logger.log, or inside each Appender.append so every delivery target drops messages below the threshold. Before reading on, decide which placement keeps a filtered-out DEBUG call cheapest.

Decision checkpoint

1)

Should the minimum-level filter run at the top of Logger.log, or inside each Appender.append?

So the guard sits at the top of Logger.log, before any LogMessage is built.

Class Diagram


Reading the diagram: the Logger composes a list of Appenders (for delivery) and creates LogMessage value objects that flow through each Appender's Formatter. Both Formatter and Appender are interfaces — the Logger never depends on a concrete class. This is the Strategy pattern applied twice: swapping SimpleFormatter for a DetailedFormatter that adds timestamps requires no change to Logger at all; swapping MemoryAppender for a FileAppender is equally transparent.

Try It Yourself

Before reading the implementation, build it. Implement a function logging_framework(instructions) that replays a sequence of commands and returns one formatted line per accepted log call.

Each instruction is a list of strings. Two commands exist:

  • ["setlevel", level] — set the minimum level to DEBUG, INFO, WARN, or ERROR; no output.
  • ["log", level, word1, word2, ...] — if the level is at or above the minimum, append "[LEVEL] message" where message is all tokens after the level joined by spaces; otherwise produce no output.

The logger starts at DEBUG by default, so all messages pass until setlevel raises the bar. Level changes take effect immediately and persist for all subsequent log commands.

Run your solution against the test cases. The reference implementation follows below.

Invest in Yourself
Your new job is waiting. 83% of people that complete the program get a job offer. Unlock unlimited access to all content and features.
Go Pro