Lesson 17 of 31
Design a Logger Framework in Java (Low-Level Design)
A logging framework turns calls such as logger.info("started") into lines in files, consoles or services, with control over what is recorded and where it goes. Libraries such as Log4j, Logback, java.util.logging and Python's logging share one shape: levels, loggers arranged in a hierarchy, and pluggable output destinations. This article designs that shape from scratch, which is a common low-level design interview problem because it is small enough to finish and has several real extension points.
The framework used is the LLD interview framework. The design uses small interfaces for open behavior (users add their own formatters, filters and appenders) and an enum for the closed set of levels (modeling domains in Java).
Requirements
Functional
- Five severity levels ordered
TRACE < DEBUG < INFO < WARN < ERROR. - A logger drops messages below its threshold.
- Several destinations (appenders) can receive a record: console, file, memory, a network service.
- The output format is configurable per destination (plain text, JSON).
- Filters can reject records by any rule (minimum level, logger name prefix, message content).
- Loggers have dotted names (
app.db.pool) and form a hierarchy. A logger without its own level inherits from its parent, and a record is also delivered to the appenders of its ancestors unless a logger opts out (additive flag). - Configuration can change at runtime.
Non-functional
- A disabled log call must be cheap: no formatting, no allocation of the message.
- Loggers are used from many threads.
- Tests can capture output deterministically: no real clock, no files.
Entities and class diagram
classDiagram
class Level {
<<enumeration>>
}
class LogRecord {
<<record>>
level: Level
target: String
message: String
timestampMs: long
}
class Registry {
-nodes: TreeMap~String,Node~
+logger(name) Logger
}
class Node {
-level: Level
-additive: boolean
-appenders: List~Appender~
}
class Logger {
+info(msg)
+log(level, supplier)
}
class Appender {
<<interface>>
+append(record)
}
class Formatter {
<<interface>>
+format(record) String
}
class Filter {
<<interface>>
+accepts(record) boolean
}
class Clock {
<<java.time.Clock>>
}
Registry *-- Node : one per logger name
Node o-- Appender : delivers to
Logger --> Registry : reads configuration from
Appender <|.. MemoryAppender
MemoryAppender --> Formatter : uses
MemoryAppender --> Filter : uses
Registry --> Clock : timestamps from
Appender ..> LogRecord : receivesThe important decision is that a Logger is a cheap handle and the configuration lives in a Registry. A logger has no state of its own beyond its name and a reference to the registry, so it can be created anywhere and always reflects the current configuration. The registry is a map from name to node instead of a tree of parent references, so the hierarchy has no cycles (object graphs and references): the parent of app.db.pool is found by cutting the name at the last dot.
API
@FunctionalInterface
interface Appender {
void append(LogRecord record); // implementations must be thread-safe
}
@FunctionalInterface
interface Formatter {
String format(LogRecord record);
}
@FunctionalInterface
interface Filter {
boolean accepts(LogRecord record);
}
final class Registry {
Logger logger(String name); // IllegalArgumentException for an invalid name
void setLevel(String name, Level level);
void addAppender(String name, Appender appender);
}
final class Logger {
void info(String message); // and error, warn, debug, trace
void log(Level level, Supplier<String> message);
}The signatures above are a summary and are not compiled. An appender is shared by several loggers and threads, so each implementation manages its own thread safety, typically with a lock around the short section that stores a line. Because each interface has one method, a lambda is a valid Appender, Formatter or Filter. See thread-safe components.
Design decisions
Levels are an enum. The set is closed and ordered, so the declaration order of the constants gives threshold comparison for free: level.compareTo(threshold) >= 0. Parsing a level from a configuration string is a static method that wraps Level.valueOf and throws IllegalArgumentException with a clear message for unknown names.
Three interfaces, one job each. A Formatter turns a record into text, a Filter decides whether a record is wanted, and an Appender delivers it. Separating them means a JSON format is written once and reused by a file appender and a network appender. This is the strategy pattern applied three times. Each is an interface, not an enum, because users add their own.
Hierarchy by name. The resolution rules are two small functions over a name, shown here with checks. The checks use plain assert statements, which the checker runs with assertions enabled:
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
enum Level { DEBUG, INFO }
public class Main {
/** The name and all its ancestors, most specific first, ending with the root "". */
static List<String> chain(String name) {
var out = new ArrayList<String>();
var cur = name;
while (!cur.isEmpty()) {
out.add(cur);
int dot = cur.lastIndexOf('.');
cur = dot < 0 ? "" : cur.substring(0, dot);
}
out.add("");
return out;
}
/** The first configured level walking up the chain. */
static Level effectiveLevel(String name, Map<String, Level> levels) {
for (var n : chain(name)) {
var level = levels.get(n);
if (level != null) {
return level;
}
}
throw new IllegalStateException("the root always has a level");
}
public static void main(String[] args) {
assert chain("app.db.pool").equals(List.of("app.db.pool", "app.db", "app", ""));
assert chain("").equals(List.of(""));
System.out.println("chain walks up to the root");
var levels = new TreeMap<String, Level>();
levels.put("", Level.INFO);
levels.put("app.db", Level.DEBUG);
assert effectiveLevel("app.db.pool", levels) == Level.DEBUG;
assert effectiveLevel("app.web", levels) == Level.INFO;
assert effectiveLevel("other", levels) == Level.INFO;
System.out.println("a child inherits from the nearest configured ancestor");
assert effectiveLevel("app.dbx", levels) == Level.INFO;
System.out.println("a shared prefix is not ancestry");
}
}chain walks up to the root
a child inherits from the nearest configured ancestor
a shared prefix is not ancestryThe last check matters: app.dbx starts with the text app.db but is not a child of it, and cutting at dots rather than comparing prefixes gets that right.
Additive appenders. A record is delivered to the appenders of its logger and then to those of each ancestor, up to the root. A node can set additive to false to stop the walk, which is how an audit log gets its own destination without also appearing in the general log.
Lazy messages. logger.debug("row " + row) builds the string even when debug is off. log(level, () -> "row " + row) takes a Supplier<String> that runs only if the level is enabled, so a disabled call costs one comparison.
The clock is injected. The registry stamps each record from a java.time.Clock, so tests get fixed timestamps and can assert on exact output, as in testability and dependency injection. A log timestamp is a point on the calendar, so the wall clock is the right source here, unlike the monotonic ticker used for expiry in design a key-value store with TTL. The test clock is a small subclass of Clock that holds a settable instant.
Concurrency. The registry is configured first and then shared read-only. Handing it to other threads through an executor or Thread.start makes the configuration visible to them, because starting a task happens-after everything the submitting thread did before. Appenders lock internally for the short time it takes to store a line. Changing configuration at runtime with the registry already shared needs either a ReadWriteLock or an immutable configuration snapshot swapped through a volatile field, listed under extensions.
Full implementation
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneId;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.TreeMap;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong;
import java.util.function.Supplier;
enum Level {
TRACE, DEBUG, INFO, WARN, ERROR;
static Level parse(String text) {
try {
return valueOf(text.trim().toUpperCase(Locale.ROOT));
} catch (IllegalArgumentException e) {
throw new IllegalArgumentException("unknown level: " + text);
}
}
}
record LogRecord(Level level, String target, String message, long timestampMs) {}
/** A clock that returns whatever instant it was last set to. */
final class ManualClock extends Clock {
private final AtomicLong millis = new AtomicLong();
void set(long ms) {
millis.set(ms);
}
@Override
public ZoneId getZone() {
return ZoneOffset.UTC;
}
@Override
public Clock withZone(ZoneId zone) {
return this;
}
@Override
public Instant instant() {
return Instant.ofEpochMilli(millis.get());
}
}
@FunctionalInterface
interface Formatter {
String format(LogRecord record);
}
final class PlainFormatter implements Formatter {
@Override
public String format(LogRecord r) {
return "%5d %-5s %s - %s".formatted(r.timestampMs(), r.level(), r.target(), r.message());
}
}
final class JsonFormatter implements Formatter {
private static String escape(String s) {
return s.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n");
}
@Override
public String format(LogRecord r) {
return "{\"ts\":%d,\"level\":\"%s\",\"target\":\"%s\",\"msg\":\"%s\"}"
.formatted(r.timestampMs(), r.level(), escape(r.target()), escape(r.message()));
}
}
@FunctionalInterface
interface Filter {
boolean accepts(LogRecord record);
static Filter minLevel(Level min) {
return r -> r.level().compareTo(min) >= 0;
}
}
/** Implementations must be safe to call from several threads. */
@FunctionalInterface
interface Appender {
void append(LogRecord record);
}
/** Collects formatted lines in memory. Tests read them with {@link #lines()}. */
final class MemoryAppender implements Appender {
private final Formatter formatter;
private final Filter filter;
private final List<String> lines = new ArrayList<>();
MemoryAppender(Formatter formatter) {
this(formatter, r -> true);
}
MemoryAppender(Formatter formatter, Filter filter) {
this.formatter = formatter;
this.filter = filter;
}
@Override
public synchronized void append(LogRecord record) {
if (filter.accepts(record)) {
lines.add(formatter.format(record));
}
}
synchronized List<String> lines() {
return List.copyOf(lines);
}
}
final class Registry {
/** Configuration of one logger name. A null level means "inherit from the parent". */
private static final class Node {
Level level;
boolean additive = true;
final List<Appender> appenders = new ArrayList<>();
}
private final TreeMap<String, Node> nodes = new TreeMap<>();
private final Clock clock;
Registry(Clock clock) {
this.clock = clock;
node("").level = Level.INFO;
}
/** The name and its ancestors, most specific first, ending with the root "". */
static List<String> chain(String name) {
var out = new ArrayList<String>();
var cur = name;
while (!cur.isEmpty()) {
out.add(cur);
int dot = cur.lastIndexOf('.');
cur = dot < 0 ? "" : cur.substring(0, dot);
}
out.add("");
return out;
}
private static void validate(String name) {
boolean ok = name.isEmpty() || List.of(name.split("\\.", -1)).stream()
.allMatch(seg -> !seg.isEmpty() && seg.chars().allMatch(c -> Character.isLetterOrDigit(c) || c == '_'));
if (!ok) {
throw new IllegalArgumentException("invalid logger name: " + name);
}
}
private Node node(String name) {
validate(name);
return nodes.computeIfAbsent(name, n -> new Node());
}
void setLevel(String name, Level level) {
node(name).level = level;
}
void setAdditive(String name, boolean additive) {
node(name).additive = additive;
}
void addAppender(String name, Appender appender) {
node(name).appenders.add(appender);
}
Level effectiveLevel(String name) {
for (var n : chain(name)) {
var node = nodes.get(n);
if (node != null && node.level != null) {
return node.level;
}
}
throw new IllegalStateException("the root always has a level");
}
Logger logger(String name) {
validate(name);
return new Logger(this, name);
}
boolean enabled(String name, Level level) {
return level.compareTo(effectiveLevel(name)) >= 0;
}
void emit(String name, Level level, String message) {
var record = new LogRecord(level, name, message, clock.millis());
for (var n : chain(name)) {
var node = nodes.get(n);
if (node == null) {
continue;
}
for (var appender : node.appenders) {
appender.append(record);
}
if (!node.additive) {
break;
}
}
}
}
final class Logger {
private final Registry registry;
private final String name;
Logger(Registry registry, String name) {
this.registry = registry;
this.name = name;
}
void log(Level level, String message) {
if (registry.enabled(name, level)) {
registry.emit(name, level, message);
}
}
/** The supplier runs only when the level is enabled. */
void log(Level level, Supplier<String> message) {
if (registry.enabled(name, level)) {
registry.emit(name, level, message.get());
}
}
void debug(String message) {
log(Level.DEBUG, message);
}
void info(String message) {
log(Level.INFO, message);
}
void error(String message) {
log(Level.ERROR, message);
}
}
public class Main {
static void show(String title, MemoryAppender appender) {
System.out.println("--- " + title);
appender.lines().forEach(System.out::println);
}
public static void main(String[] args) {
var clock = new ManualClock();
var registry = new Registry(clock);
var all = new MemoryAppender(new PlainFormatter());
var errors = new MemoryAppender(new PlainFormatter(), Filter.minLevel(Level.ERROR));
var audit = new MemoryAppender(new JsonFormatter());
registry.addAppender("", all);
registry.addAppender("", errors);
registry.setLevel("app.db", Level.DEBUG);
registry.addAppender("audit", audit);
registry.setAdditive("audit", false);
try {
registry.setLevel("app..db", Level.WARN);
} catch (IllegalArgumentException e) {
System.out.println("bad name: " + e.getMessage());
}
clock.set(1000);
var web = registry.logger("app.web");
var pool = registry.logger("app.db.pool");
var auditLog = registry.logger("audit");
web.info("started");
web.debug("hidden: the root level is INFO");
pool.debug("connection reused");
clock.set(2500);
pool.error("connection lost");
auditLog.info("login \"bob\"");
var built = new AtomicInteger();
web.log(Level.DEBUG, () -> {
built.incrementAndGet();
return "expensive";
});
System.out.println("supplier ran for a disabled level: " + (built.get() > 0));
show("all", all);
show("errors only", errors);
show("audit (not additive)", audit);
// Shared across threads after configuration is complete.
try (var pool3 = Executors.newFixedThreadPool(3)) {
for (int t = 0; t < 3; t++) {
int worker = t;
pool3.submit(() -> {
var log = registry.logger("app.worker");
for (int i = 0; i < 2; i++) {
log.info("worker " + worker + " step " + i);
}
});
}
} // close() waits for the submitted tasks
var lines = new ArrayList<>(all.lines());
lines.sort(null);
System.out.println("--- after threads: " + lines.size() + " lines, workers sorted");
lines.stream().filter(l -> l.contains("worker")).forEach(System.out::println);
}
}bad name: invalid logger name: app..db
supplier ran for a disabled level: false
--- all
1000 INFO app.web - started
1000 DEBUG app.db.pool - connection reused
2500 ERROR app.db.pool - connection lost
--- errors only
2500 ERROR app.db.pool - connection lost
--- audit (not additive)
{"ts":2500,"level":"INFO","target":"audit","msg":"login \"bob\""}
--- after threads: 9 lines, workers sorted
2500 INFO app.worker - worker 0 step 0
2500 INFO app.worker - worker 0 step 1
2500 INFO app.worker - worker 1 step 0
2500 INFO app.worker - worker 1 step 1
2500 INFO app.worker - worker 2 step 0
2500 INFO app.worker - worker 2 step 1Check the output against the rules. app.web uses the root level (INFO), so its debug call is dropped. app.db.pool inherits DEBUG from app.db, so its debug record is delivered, and it reaches the root appenders because appenders are additive. The errors appender applies its own filter, so it sees only the error. The audit logger is not additive, so its record appears only in its own JSON appender and is escaped correctly. Lines from the threads arrive in nondeterministic order, so the example sorts them before printing. In a test, assert on sorted lines, or on counts, when threads are involved.
An asynchronous appender
A slow destination such as a file on a network drive should not block the calling thread. An AsyncAppender hands each record to a worker thread through a queue and returns immediately. It is itself an Appender that wraps another one, so it composes with everything above. See blocking queue and producer-consumer.
The queue holds items of a small sealed type: a line to write or a stop marker. The marker tells the worker to finish, because a BlockingQueue has no "closed" state.
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.LinkedBlockingQueue;
enum Level { TRACE, DEBUG, INFO, WARN, ERROR }
record LogRecord(Level level, String target, String message, long timestampMs) {}
@FunctionalInterface
interface Appender {
void append(LogRecord record);
}
final class AsyncAppender implements Appender {
private sealed interface Item {}
private record Line(LogRecord record) implements Item {}
private record Stop() implements Item {}
private final BlockingQueue<Item> queue = new LinkedBlockingQueue<>();
private final ExecutorService executor = Executors.newSingleThreadExecutor();
private final Future<Integer> worker;
private boolean closed;
AsyncAppender(Appender delegate) {
worker = executor.submit(() -> {
int written = 0;
while (queue.take() instanceof Line line) {
delegate.append(line.record()); // stand-in for slow I/O
written++;
}
return written; // a Stop ends the loop
});
}
@Override
public synchronized void append(LogRecord record) {
if (!closed) {
queue.add(new Line(record)); // after shutdown, records are dropped
}
}
/** Stops accepting records, drains the queue, and reports how many were written. */
int shutdown() throws InterruptedException, ExecutionException {
synchronized (this) {
closed = true;
queue.add(new Stop());
}
int written = worker.get();
executor.shutdown();
return written;
}
}
public class Main {
public static void main(String[] args) throws Exception {
var sink = new ArrayList<String>(); // written only by the worker thread
var appender = new AsyncAppender(r -> sink.add(r.message()));
try (var producers = Executors.newFixedThreadPool(3)) {
for (int t = 0; t < 3; t++) {
int id = t;
producers.submit(() -> {
for (int i = 0; i < 5; i++) {
appender.append(new LogRecord(Level.INFO, "t" + id, "t" + id + "-" + i, 0));
}
});
}
}
System.out.println("written: " + appender.shutdown());
appender.append(new LogRecord(Level.INFO, "late", "too late", 0));
System.out.println("in sink: " + sink.size());
boolean inOrder = true;
for (int t = 0; t < 3; t++) {
var prefix = "t" + t + "-";
List<String> mine = sink.stream().filter(l -> l.startsWith(prefix)).toList();
for (int i = 1; i < mine.size(); i++) {
inOrder &= mine.get(i - 1).compareTo(mine.get(i)) < 0;
}
}
System.out.println("per-thread order preserved: " + inOrder);
}
}written: 15
in sink: 15
per-thread order preserved: trueOrder across threads is not defined, but each thread's own records stay in order, because a queue preserves the order of insertions made by one thread. The synchronized blocks make "check closed, then enqueue" and "set closed, then enqueue the stop marker" atomic, so no record can land behind the stop marker and be lost silently. Reading sink from the main thread is safe because worker.get() returns only after the worker finished, and that completion happens-before the return. The queue is unbounded, so a slow destination makes it grow until memory runs out; a bounded ArrayBlockingQueue makes append block when full, which turns the problem into back-pressure on the callers. The example assumes the wrapped appender does not throw, as the Appender contract requires: an exception would end the worker task, and shutdown would then rethrow it as an ExecutionException. The design needs a shutdown method: without one, records still queued when the program exits are lost. That is the main cost of asynchronous logging, and interviewers often ask about it.
Extensions
| Follow-up question | Answer |
|---|---|
| Write to a file or stdout | A WriterAppender holding a Writer and a lock around each write. Tests use a StringWriter. |
| Rotate log files by size or day | A RollingFileAppender that owns the current file and swaps it when a limit is passed. It implements Appender, so nothing else changes. |
| Change levels while running | Keep the registry configuration in an immutable snapshot behind a volatile field and replace it on change, or guard the registry with a ReadWriteLock, or store each level in an AtomicReference<Level> per node so reads never lock. |
Structured fields (user_id=42) |
Add a Map<String, String> fields to LogRecord (copied with Map.copyOf); formatters print them. |
Avoid the cost of chain on every call |
Cache the effective level per logger name and invalidate the cache when configuration changes. |
| Context propagation (request IDs) | A ThreadLocal or an explicit context passed to the logger, copied into each record. |
| Failing appenders | append returns nothing and must not throw, so one broken destination cannot break the caller. Catch and count failures internally and fall back to stderr. |
| Global static logger | A static registry reachable from anywhere is convenient, but it is shared mutable state that tests cannot replace. Prefer passing a Logger through constructors. |
Summary
- A logging framework is three open interfaces (
Appender,Formatter,Filter), one closed enum (Level), and a configuration structure that maps logger names to settings. - Loggers are cheap handles. The hierarchy lives in a
TreeMapkeyed by dotted name, and a parent is found by cutting at the last dot, so there are no reference cycles. - A logger inherits its level from the nearest configured ancestor, and records travel up to ancestor appenders unless a node is marked non-additive.
- Make disabled calls cheap with an early level check and a
Supplier-basedlogthat builds the message only when needed. - Inject a
Clockand use in-memory appenders so tests assert on exact, deterministic output. - Appenders must be thread-safe and lock internally. Sort lines or count them when threads are involved.
- An asynchronous appender decouples callers from slow I/O with a
BlockingQueueand a worker thread, and needs an explicit shutdown to flush.
