Design a Logging Framework
Levels, appenders, and a handler chain — a Chain-of-Responsibility showcase with async, thread-safe sinks.
Problem Statement
Design the object model for a logging framework that application code can use to emit diagnostics at different severities. The framework should support DEBUG, INFO, WARN, and ERROR levels, named loggers, level filtering, a chain of handlers, pluggable formatters, and pluggable sinks such as console and file output.
The interview focus is the class model: where filtering lives, how messages move through a chain, how sinks are added without changing core logging code, and how the same design can evolve into async logging for production workloads.
Business context
Logging frameworks sit on every critical path at companies like Amazon, Microsoft, and Oracle. They must be easy for application teams to call, cheap when a message is filtered out, and extensible enough to add structured formats, file rotation, remote collectors, or async buffering later.
A strong LLD answer shows that logging is not just printing text. It is a pipeline: caller API → level filter → log event → handler chain → formatter strategy → observer appenders.
Functional Requirements
Expose named loggers through a central manager so different application modules can request their own logger.
Support the four levels DEBUG, INFO, WARN, and ERROR with severity ordering.
Filter messages below the configured minimum level before creating or publishing a log event.
Route accepted messages through a Chain of Responsibility made of log-level handlers.
Format each accepted LogMessage through a pluggable Formatter strategy.
Publish the formatted message to one or more pluggable Appender sinks, including console and file sinks.
Provide convenience methods debug, info, warn, and error in addition to a generic log method.
Keep the sink abstraction compatible with an async appender that can enqueue work without changing Logger.
Non-Functional Requirements
Low overhead on filtered logs
A message below the logger's minimum level should return immediately. It should not allocate a LogMessage, run the handler chain, format text, or touch any sink.
Thread-safe shared loggers
LogManager must safely return the same named logger to concurrent callers. Logger configuration and appender iteration must not corrupt shared state.
Extensibility
New formatters, appenders, and async wrappers should be additive classes behind existing interfaces rather than edits to the logging flow.
Failure isolation
A sink failure should be isolated at the appender boundary. The reference FileAppender converts I/O errors into a clear runtime failure rather than silently losing logs.
Deterministic message model
A log event should capture its level, logger name, text, and creation time once so every appender sees the same event content.
Requirement Clarification
QDo we need hierarchical package loggers like **com.app.service** inheriting from **com.app**?
Not in the base design. We model named loggers returned by LogManager. Hierarchical inheritance is a documented extension.
QShould logging calls be async by default?
For the reference design, Logger publishes synchronously to keep the core pipeline readable. Async logging is supported by the Appender seam: a production AsyncAppender can enqueue formatted messages and flush on a worker.
QCan a logger write to multiple destinations?
Yes. Logger owns a list of appenders and publishes the same formatted message to each one, such as console plus file.
QCan users change the output format?
Yes. Formatter is an interface. SimpleFormatter is the default, and structured or JSON formatters can implement the same method.
QWhat happens when a message level does not match the first handler?
The LogHandler passes the event to the next handler until a matching LevelLogHandler publishes it or the chain ends.
UML Class Diagram
Sequence Diagram
Entity Identification
LogManager
Singleton registry for named Logger instances. It uses a concurrent map so repeated calls for the same name return the same logger safely.
Logger
Facade used by application code. It owns the minimum level, handler chain, formatter, and appender list, and coordinates the full logging pipeline.
LogLevel
Severity enum with explicit numeric ordering. isAtLeast is the single source of truth for level filtering.
LogMessage
Immutable event object created only after filtering succeeds. It captures logger name, level, text, and timestamp.
LogHandler
Abstract chain node. handle either publishes the message when this handler accepts it or forwards to the next handler.
LevelLogHandler
Concrete handler for one severity. defaultChain wires DEBUG → INFO → WARN → ERROR handlers in order.
Formatter
Rendering strategy interface. It turns a LogMessage into text without knowing where that text will be written.
SimpleFormatter
Default formatter that emits timestamp, level, logger name, and message text in a predictable human-readable form.
Appender
Sink observer interface. Each appender receives the rendered log line and decides where or how to write it.
ConsoleAppender
Console sink that writes formatted messages to standard output.
FileAppender
File sink that appends each formatted message to a path using UTF-8 and synchronizes writes for thread safety.
Design Patterns Used
LogHandler owns the chain link and LevelLogHandler owns the level match. Logger sends a message to the chain without switching on every level itself.
LogManager uses the holder idiom to expose exactly one registry of named loggers across the process.
Formatter is the rendering strategy and Appender is the sink strategy. New text formats and destinations are added behind interfaces.
Logger.publish broadcasts one formatted event to every registered Appender. Console, file, and future async appenders observe the same stream.
Step-by-Step Design
1Model severity as an ordered enum
Give each LogLevel an explicit severity number so filtering is stable even if enum declaration order changes later.
public enum LogLevel { DEBUG(10), INFO(20), WARN(30), ERROR(40); public boolean isAtLeast(LogLevel minimum) { return severity >= minimum.severity; } }2Capture accepted events as immutable messages
LogMessage.create stamps the event with the current time after filtering. Every formatter and appender sees the same logger name, level, text, and timestamp.
3Build a level handler chain
Each LevelLogHandler is responsible for exactly one level. The default chain makes level routing explicit without a switch inside Logger.
public static LogHandler defaultChain() { LogHandler debug = new LevelLogHandler(LogLevel.DEBUG); debug.linkWith(new LevelLogHandler(LogLevel.INFO)) .linkWith(new LevelLogHandler(LogLevel.WARN)) .linkWith(new LevelLogHandler(LogLevel.ERROR)); return debug; }4Filter first, then publish
Logger.log rejects below-threshold messages before allocation. Accepted messages go into the handler chain, which calls back into publish.
public void log(LogLevel level, String text) { if (!level.isAtLeast(minimumLevel)) { return; } handlerChain.handle(LogMessage.create(name, level, text), this); }5Separate formatting from delivery
Logger.publish asks the current Formatter for one rendered string, then iterates over the appender list. Output policy is not hard-coded into the logger.
6Centralize logger lookup in a singleton manager
LogManager.getInstance exposes the process-wide registry, while getLogger uses atomic map insertion so named loggers are reused safely.
7Keep async logging as an appender-level extension
Because Logger depends only on Appender, a future async implementation can enqueue the formatted line and return quickly. Backpressure, flushing, and worker lifecycle stay outside the core logger.
Complete Java Implementation
Explanation of Every Class
LogLevel
Enum for DEBUG, INFO, WARN, and ERROR. The numeric severity makes isAtLeast the single filter rule used by Logger.
LogMessage
Immutable event value. Its static create factory captures the current Instant and stores logger name, level, and text behind getters.
Formatter
Strategy interface for rendering a LogMessage. It keeps the logger independent from text shape, structured output, or locale choices.
SimpleFormatter
Default Formatter implementation. It uses an ISO offset timestamp and emits level, logger name, and text as one human-readable line.
Appender
Sink interface for already formatted text. Console, file, remote, buffered, and async destinations can all implement append.
ConsoleAppender
Simple Appender that writes the formatted line to standard output. It is the default sink added to every new Logger.
FileAppender
File-backed Appender. It appends UTF-8 bytes to a configured path, creates the file when needed, synchronizes writes, and surfaces I/O failure as IllegalStateException.
LogHandler
Abstract Chain of Responsibility node. linkWith wires the chain, and final handle either publishes through the logger or forwards to next.
LevelLogHandler
Concrete chain node that accepts one LogLevel. defaultChain constructs the DEBUG, INFO, WARN, and ERROR sequence used by each Logger.
Logger
Application-facing facade. It stores configuration, performs level filtering, creates LogMessage objects, invokes the handler chain, formats events, and broadcasts to appenders.
LogManager
Singleton registry for loggers. The holder idiom gives lazy, thread-safe initialization, and computeIfAbsent returns one Logger per name.
Dry Run
Sample input
Create logger billing from LogManager. Set minimum level to INFO, keep the default ConsoleAppender, add a FileAppender, and use SimpleFormatter. Calls: debug for cache miss, info for payment started, and error for payment failed.
| Step | Call | Filter decision | Handler result | Formatted output | Sink effect |
|---|---|---|---|---|---|
| 1 | getLogger(billing) | Not a log event | No handler | No output | Singleton manager returns the cached or new Logger |
| 2 | debug(cache miss) | DEBUG is below INFO | Chain not invoked | No output | No appender receives anything |
| 3 | info(payment started) | INFO passes | INFO handler publishes | timestamp INFO [billing] payment started | Console and file receive the same line |
| 4 | error(payment failed) | ERROR passes | ERROR handler publishes | timestamp ERROR [billing] payment failed | Console and file receive the same line |
| 5 | add async appender later | Not a log event | No handler | No output | A new Appender can enqueue future lines without Logger changes |
The important optimization is step 2: the DEBUG message is rejected before LogMessage.create, formatting, or I/O. Steps 3 and 4 show accepted events taking the same path regardless of severity.
Complexity Analysis
| Operation | Time | Space | Note |
|---|---|---|---|
| getLogger | O(1) average | O(1) per distinct logger | Concurrent hash map lookup and possible insertion by name. |
| filtered log call | O(1) | O(1) | A below-threshold call only compares severities and returns. |
| accepted log call | O(H + A + W) | O(1) | H handlers in the chain, A appenders, and W work done by sinks such as file write length. |
| addAppender | O(A) | O(A) | CopyOnWriteArrayList copies the appender array on mutation, which is acceptable because configuration is rare. |
| file append | O(M) | O(M) | M is formatted message length converted to UTF-8 bytes. |
The hot path is the accepted log call. In the reference implementation H is bounded by four levels, so practical cost is dominated by formatting and sink I/O. Async appenders shift I/O off the caller thread but add queue memory and backpressure decisions.
Extensibility
New formatter
Implement Formatter for JSON, key-value, colorized console, or redacted output, then call setFormatter on a logger.
New sink
Implement Appender for rotating files, sockets, cloud collectors, metrics streams, or tests. Logger.publish does not change.
Async logging
Add an AsyncAppender that owns a bounded queue and worker thread. It implements the same append method, so async behavior is a sink concern.
New level policy
Add a LogLevel and a matching LevelLogHandler in the chain. If levels become dynamic, replace the enum with a registry and keep the handler abstraction.
Structured context
Extend LogMessage with fields such as request id, tenant id, or key-value attributes. Existing formatters can ignore new fields while structured formatters use them.
Alternative Designs
Switch-based logger
Put level routing directly inside Logger.log with a switch over LogLevel and call appenders from each branch.
Tradeoffs
Simpler for four levels, but it removes the Chain of Responsibility seam and makes custom handling policies require logger edits.
Always-async event queue
Make Logger enqueue LogMessage objects into a shared dispatcher, and let worker threads format and write to sinks.
Tradeoffs
Better latency for callers under slow I/O, but more complex shutdown, flush, ordering, memory, and backpressure behavior.
Hierarchical logger tree
Store loggers in a package-style tree where child loggers inherit level, formatter, and appenders from parents unless overridden.
Tradeoffs
Closer to mature production frameworks, but substantially more state and precedence rules than an interview base design needs.
Common Mistakes
- ×
Formatting the message before checking minimumLevel, which makes filtered DEBUG logs expensive.
- ×
Hard-coding console and file writes inside Logger, preventing new sinks without modifying core logic.
- ×
Using a public constructor for LogManager and ending up with multiple logger registries.
- ×
Letting LogHandler.handle be overridden and accidentally breaking the forward-to-next invariant.
- ×
Ignoring thread safety for the appender list while one thread logs and another thread reconfigures sinks.
- ×
Treating async logging as one new thread per log call instead of a bounded queue and worker model.
- ×
Catching and swallowing file I/O failures, which makes missing logs impossible to diagnose.
Follow-up Interview Questions
QHow would you add async logging without changing application code?
Implement AsyncAppender behind the existing Appender interface. It enqueues formatted strings into a bounded queue, a worker drains to a delegate appender, and shutdown flushes remaining messages.
QHow would you support JSON logs?
Create a JsonFormatter implementing Formatter. It serializes LogMessage fields and any future structured context into one JSON line.
QHow can you prevent a slow file sink from delaying every request?
Use an async appender or a batching appender for file output. Decide a backpressure policy: block, drop DEBUG/INFO first, or fail fast when the queue is full.
QHow would you support different minimum levels for different modules?
Return one named Logger per module from LogManager and configure minimumLevel per logger. A hierarchy can be added later for inheritance.
QWhat should happen if one appender fails but another succeeds?
In production, isolate failures per appender and report them through internal diagnostics so one bad sink does not stop all logging. The reference implementation keeps failure visible for clarity.
Production Considerations
Async queue and backpressure
Use bounded queues, explicit drop or block policy, flush on shutdown, and metrics for queue depth and dropped messages.
File rotation and retention
Production file appenders need rotation by size or time, compression, retention limits, and safe rollover under concurrent writes.
Structured observability
Prefer machine-readable fields for request id, tenant, trace id, and error class. Keep text logs compatible with metrics and tracing systems.
Security and privacy
Add redaction in formatter or message creation for credentials, tokens, personal data, and payment details. Logging must not become a data leak.
Operational diagnostics
The framework itself should expose internal counters for appender failures, queue drops, bytes written, and formatting errors.
What Interviewers Look For
Did the candidate filter before allocation, formatting, and I/O?
Are handler routing, formatting, and sink delivery separated into clear abstractions?
Is LogManager a safe singleton rather than global mutable state with multiple instances?
Can the design add console, file, remote, and async sinks without editing Logger.log?
Did the candidate call out thread safety and backpressure instead of hand-waving async logging?
Quiz
0/5 answered
1.Why should **Logger.log** check **minimumLevel** before creating a **LogMessage**?
2.Which class represents the Singleton pattern in the reference design?
3.What is the main benefit of the **Appender** interface?
4.What does the Chain of Responsibility contribute here?
5.Where should async logging behavior fit best in this design?
Practice Variants
Build an async appender
IntermediateImplement an AsyncAppender with a bounded queue, one worker thread, flush on shutdown, and a configurable policy for full queues.
Add JSON structured logging
IntermediateCreate JsonFormatter and extend LogMessage with optional key-value context while keeping existing appenders unchanged.
Add hierarchical logger configuration
AdvancedSupport parent-child logger names such as service and service.payment, with inherited level and appenders unless overridden.
Flashcards
Cheat Sheet
Entities: LogManager, Logger, LogLevel, LogMessage, LogHandler, LevelLogHandler, Formatter, SimpleFormatter, Appender, ConsoleAppender, FileAppender.
Core flow: caller gets a named logger → logger filters by minimum level → creates LogMessage → sends through handler chain → formatter renders → appenders receive the line.
Patterns: Chain of Responsibility for level handlers, Singleton for LogManager, Strategy for Formatter and Appender, Observer for publishing to many appenders.
Invariants: filtered logs do no formatting or I/O; named loggers are reused; appenders receive identical formatted text for one event; file writes are synchronized.
Async extension: keep Logger unchanged. Add an appender with a bounded queue, worker, flush, and backpressure policy.
Complexity: filtered log O(1); accepted log O(H + A + W); getLogger O(1) average; addAppender O(A) because copy-on-write copies the appender list.
References
- BookDesign Patterns: Elements of Reusable Object-Oriented Software — Gamma, Helm, Johnson, and Vlissides
- DocsRefactoring Guru — Chain of Responsibility
- DocsLog4j 2 Manual — Architecture
- BookEffective Java — Item 3 and Item 17 — Joshua Bloch