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.
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
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; returnsselffor chaining.set_level(level) -> None— replaces_min_level; takes effect immediately for all subsequentlogcalls.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
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 toDEBUG,INFO,WARN, orERROR; 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.