Design a Logger (Logging Framework), Low Level Design (LLD) Interview
A small logging library like Logback or Log4j 2: loggers named by dots that inherit their level from a parent, appenders that send lines to the console or a file, formatters that shape each line, and an async writer (one that works in the background) with a bounded queue that either waits or drops and counts what it dropped.
Where it shows up
A common LLD question, often phrased as design a logger or design a logging library. It is popular because every engineer has used one.
Two courses by the author of this page:
770 lessons · 18 free to read
₹499 in India · $49 elsewhere, once
Get System DesignYou own this course
204 lessons · 10 free to read
₹999 in India · $49 elsewhere, once
Get AI EngineeringYou own this course
Spec sheetLogging Framework
- 01Core classes
- LogManager, Logger, LogEvent, Appender, FileAppender, AsyncAppender, Formatter
- 02Design patterns
- Singleton, Factory, Strategy, Observer, Decorator, Producer and consumer with a bounded queue
- 03Enums
- Level, OverflowPolicy
- 04Key methods
- 5 signatures, with the code skeleton
worked through below, with the maths
Why this is asked
Everyone has used a logger, but few have thought about how it works inside. The question tests clean use of patterns: a single manager that hands out loggers, swappable output targets and formats, and a tree of loggers. Then it moves to harder ground. Many threads write at once, so lines must not mix. The disk is slow, so logging must not slow the app. And when the log queue fills up, you must choose between waiting and losing lines. That last choice is where strong candidates stand out.
Requirements
Functional
- Log a message at a level: TRACE, DEBUG, INFO, WARN or ERROR. A level says how important a line is.
- Each logger has a name with dots, like com.shop.cart. Its parent is com.shop.
- A logger with no level of its own uses the level of its nearest parent that has one.
- Send lines to one or more places, called appenders: the console, a file, or anything new.
- Shape each line with a formatter: plain text or JSON.
- Write lines in the background so the app does not wait for the disk.
Constraints & non-functional
- Asking for one logger name twice must return one object, both times.
- A line below the logger's level must cost almost nothing. The message text must not even be built.
- Many threads, which are parts of one program running side by side, can log at once. No line may be cut or mixed with another.
- The background queue has a fixed size, so memory cannot grow without limit.
- When the queue is full, the behaviour is a setting: wait, or drop the line and count it. A dropped line must never be lost without a count.
- On shutdown, every line already in the queue is written before the program ends.
Core classes & entities
LogManager
One per program. It holds the root logger and a map from name to logger. It makes any missing parent loggers on the way.
attrs: root: Logger, loggers: ConcurrentHashMap<String, Logger>
methods: get(): LogManager, getLogger(name): Logger, root()
Logger
A node in the logger tree. It decides if a line is on, builds the event, and passes it to its own appenders and then up to its parents' appenders. Each logger points to its parent. The root has none.
attrs: name, parent: Logger, level: Level (may be empty), appenders, additive: boolean
methods: effectiveLevel(), isEnabled(level), log(level, pattern, args), info(...)
LogEvent
One log record: time, level, logger name, thread name and the finished message. It never changes after it is made, so it is safe to hand to another thread.
attrs: time, level, logger, thread, message
Appender
The interface for any place a line can go. Console, file and memory appenders are the built-in ones.
methods: append(event), close()
FileAppender
Writes formatted lines to a file. It holds a lock while writing one line, so lines from different threads never mix.
attrs: writer, formatter: Formatter
methods: append(event), close()
AsyncAppender
Sits in front of a slow appender. The app thread puts each event on a fixed-size queue and returns. One background thread takes events off the queue and writes them.
attrs: queue: ArrayBlockingQueue, policy: OverflowPolicy, discardThreshold, dropped: AtomicLong
methods: append(event), start(), close()
Formatter
Turns an event into a line of text. PatternFormatter writes plain text and JsonFormatter writes one JSON object per line.
methods: format(event): String
Relationships
- LogManager → composition → Logger. The manager creates and owns every logger.
- Logger → association → LogEvent. A logger makes one event per line that is on, and passes that one event to every appender.
- Logger → aggregation → Appender. A logger holds a list of appenders. One appender can be shared by many loggers.
- Appender → implements → FileAppender. ConsoleAppender and MemoryAppender implement it too.
- Appender → implements → AsyncAppender. It wraps another appender, so it is also a decorator.
- FileAppender → association → Formatter. Each appender is given the formatter to use.
Design patterns used
Singleton in LogManager.get()
There must be one logger tree per program. Otherwise two parts of the app could get two different loggers for one name.
Factory in LogManager.getLogger(name)
Callers never create a Logger. They ask for one by name and get the cached one, with its parents already linked.
Strategy in Formatter and Appender
Where a line goes and how it looks both change from one app to the next. Each is a small interface that can be swapped.
Observer in Logger notifies all of its appenders
A logger does not know or care how many appenders listen. Adding one more target means adding one more listener.
Decorator in AsyncAppender wraps another Appender
Any appender can be made async without changing it.
Producer and consumer with a bounded queue in AsyncAppender
Many app threads put events in, and one writer thread takes them out. The fixed size is the back-pressure: a full queue pushes back on the app.
Enums
Key API / methods
Logger LogManager.getLogger(String name)Returns the one logger with this name. Creates it and any missing parents the first time.
void Logger.log(Level level, String pattern, Object... args)If the level is on, fills each {} in the pattern with the next argument, builds one event, and passes it to its appenders and then its parents' appenders. If the level is off, it returns at once.
Level Logger.effectiveLevel()Its own level if set, else its parent's, and so on up to the root, which always has a level.
void AsyncAppender.append(LogEvent e)Puts the event on the queue. If the queue is full: with BLOCK it waits for space, with DROP it counts the event as dropped and returns.
void AsyncAppender.close()Stops new events, writes out every event already queued, then stops the writer thread.
Code skeleton
import java.io.*;
import java.nio.file.*;
import java.time.*;
import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.atomic.*;
// ---------- Enums ----------
enum Level { TRACE, DEBUG, INFO, WARN, ERROR, OFF } // order matters: a request is on if its level >= the logger's level
enum OverflowPolicy { BLOCK, DROP }
// ---------- One log record, made once and never changed ----------
record LogEvent(Instant time, Level level, String logger, String thread, String message) {}
// ---------- Formatters (Strategy): turn an event into text ----------
interface Formatter { String format(LogEvent e); }
final class PatternFormatter implements Formatter {
public String format(LogEvent e) {
return String.format("%s %-5s [%s] %s - %s", e.time(), e.level(), e.thread(), e.logger(), e.message());
}
}
final class JsonFormatter implements Formatter {
public String format(LogEvent e) {
return "{\"time\":\"" + e.time() + "\",\"level\":\"" + e.level() + "\",\"logger\":\"" + e.logger()
+ "\",\"msg\":\"" + e.message().replace("\\", "\\\\").replace("\"", "\\\"") + "\"}";
}
}
// ---------- Appenders (Strategy for where a line goes; a logger notifies all of its appenders) ----------
interface Appender extends Closeable { void append(LogEvent e); default void close() {} }
final class ConsoleAppender implements Appender {
private final PrintStream out; private final Formatter fmt;
ConsoleAppender(PrintStream out, Formatter fmt) { this.out = out; this.fmt = fmt; }
public synchronized void append(LogEvent e) { out.println(fmt.format(e)); }
}
final class FileAppender implements Appender {
private final BufferedWriter w; private final Formatter fmt;
FileAppender(Path p, Formatter fmt) throws IOException { w = Files.newBufferedWriter(p); this.fmt = fmt; }
// one lock per file: a line is written whole, so two threads never mix their text on one line
public synchronized void append(LogEvent e) { try { w.write(fmt.format(e)); w.newLine(); } catch (IOException x) { throw new UncheckedIOException(x); } }
public synchronized void close() { try { w.close(); } catch (IOException x) { throw new UncheckedIOException(x); } }
}
final class MemoryAppender implements Appender { // for tests
final List<LogEvent> events = Collections.synchronizedList(new ArrayList<>());
public void append(LogEvent e) { events.add(e); }
}
/** Puts events on a bounded queue. One background thread writes them to the real appender. */
final class AsyncAppender implements Appender {
private static final LogEvent STOP = new LogEvent(Instant.EPOCH, Level.OFF, "", "", "");
private final Appender target; private final BlockingQueue<LogEvent> queue; private final OverflowPolicy policy;
private final int discardThreshold; // when free slots <= this, drop events below WARN (0 = never)
final AtomicLong dropped = new AtomicLong(), delivered = new AtomicLong();
private final Thread worker; private boolean closed;
private final java.util.concurrent.locks.ReentrantReadWriteLock gate = new java.util.concurrent.locks.ReentrantReadWriteLock();
AsyncAppender(Appender target, int capacity, OverflowPolicy policy, int discardThreshold) {
this.target = target; this.queue = new ArrayBlockingQueue<>(capacity); this.policy = policy; this.discardThreshold = discardThreshold;
worker = new Thread(() -> {
try { for (LogEvent e; (e = queue.take()) != STOP; ) { target.append(e); delivered.incrementAndGet(); } }
catch (InterruptedException ie) { Thread.currentThread().interrupt(); }
}, "log-writer");
worker.setDaemon(true);
}
AsyncAppender start() { worker.start(); return this; }
public void append(LogEvent e) {
gate.readLock().lock(); // many writers at once; close() waits for them
try { offer(e); } finally { gate.readLock().unlock(); }
}
private void offer(LogEvent e) {
if (closed) { dropped.incrementAndGet(); return; }
if (discardThreshold > 0 && e.level().compareTo(Level.WARN) < 0 && queue.remainingCapacity() <= discardThreshold) { dropped.incrementAndGet(); return; }
if (policy == OverflowPolicy.DROP) { if (!queue.offer(e)) dropped.incrementAndGet(); return; }
try { queue.put(e); } catch (InterruptedException ie) { Thread.currentThread().interrupt(); dropped.incrementAndGet(); }
}
/** Stop taking new events, write out everything already queued, then stop the thread. */
public void close() {
gate.writeLock().lock();
try { closed = true; } finally { gate.writeLock().unlock(); } // after this no event can slip in behind STOP
try { queue.put(STOP); worker.join(); } catch (InterruptedException ie) { Thread.currentThread().interrupt(); }
target.close();
}
int queued() { return queue.size(); }
}
// ---------- Logger: a node in a tree named by dots ----------
final class Logger {
final String name; final Logger parent;
private volatile Level level; // null means "use my parent's"
private final List<Appender> appenders = new CopyOnWriteArrayList<>();
private volatile boolean additive = true;
Logger(String name, Logger parent) { this.name = name; this.parent = parent; }
void setLevel(Level l) { level = l; }
void setAdditive(boolean a) { additive = a; }
void addAppender(Appender a) { appenders.add(a); }
Level effectiveLevel() { for (Logger l = this; ; l = l.parent) if (l.level != null) return l.level; }
boolean isEnabled(Level l) { return l != Level.OFF && l.compareTo(effectiveLevel()) >= 0; }
/** "{}" placeholders are filled only if the level is on, so a debug line that is off costs almost nothing. */
void log(Level l, String pattern, Object... args) {
if (!isEnabled(l)) return;
StringBuilder sb = new StringBuilder(); int a = 0, i = 0;
for (int j; (j = pattern.indexOf("{}", i)) >= 0 && a < args.length; i = j + 2) sb.append(pattern, i, j).append(args[a++]);
sb.append(pattern.substring(i));
LogEvent e = new LogEvent(Instant.now(), l, name, Thread.currentThread().getName(), sb.toString());
for (Logger lg = this; lg != null; lg = lg.parent) { // my appenders, then my parent's, until one says stop
for (Appender ap : lg.appenders) ap.append(e);
if (!lg.additive) break;
}
}
void debug(String p, Object... a) { log(Level.DEBUG, p, a); }
void info(String p, Object... a) { log(Level.INFO, p, a); }
void warn(String p, Object... a) { log(Level.WARN, p, a); }
void error(String p, Object... a) { log(Level.ERROR, p, a); }
}
// ---------- LogManager: one per process (Singleton), hands out loggers (Factory) ----------
final class LogManager {
private static final LogManager INSTANCE = new LogManager();
private final Logger root = new Logger("ROOT", null);
private final ConcurrentHashMap<String, Logger> loggers = new ConcurrentHashMap<>();
private LogManager() { root.setLevel(Level.INFO); }
static LogManager get() { return INSTANCE; }
Logger root() { return root; }
/** Same name, same object. Parents ("com", "com.shop") are made on the way if missing. */
Logger getLogger(String name) {
Logger found = loggers.get(name);
if (found != null) return found;
int dot = name.lastIndexOf('.');
Logger parent = dot < 0 ? root : getLogger(name.substring(0, dot));
return loggers.computeIfAbsent(name, n -> new Logger(n, parent));
}
}
// ---------- Demo with checks ----------
public class LoggingDemo {
public static void main(String[] args) throws Exception {
LogManager lm = LogManager.get();
MemoryAppender rootMem = new MemoryAppender(), shopMem = new MemoryAppender();
lm.root().addAppender(rootMem);
Logger shop = lm.getLogger("com.shop");
shop.setLevel(Level.DEBUG); shop.addAppender(shopMem);
Logger cart = lm.getLogger("com.shop.cart"), billing = lm.getLogger("com.billing");
System.out.println("Levels and the logger tree");
check(lm.getLogger("com.shop.cart") == cart && cart.parent == shop && shop.parent.name.equals("com"), "same name gives the same logger; com.shop.cart -> com.shop -> com -> ROOT");
check(cart.effectiveLevel() == Level.DEBUG && billing.effectiveLevel() == Level.INFO, "com.shop.cart has no level, inherits DEBUG from com.shop; com.billing inherits INFO from ROOT");
int[] built = {0};
Object costly = new Object() { public String toString() { built[0]++; return "cart{3 items}"; } };
billing.debug("cart is {}", costly);
check(built[0] == 0 && rootMem.events.isEmpty(), "debug on an INFO logger is skipped before the message is built (0 toString calls)");
cart.debug("cart is {}", costly);
check(built[0] == 1 && shopMem.events.size() == 1 && rootMem.events.size() == 1, "debug on com.shop.cart reaches com.shop's appender and ROOT's (additivity)");
shop.setAdditive(false);
cart.info("checkout started");
check(shopMem.events.size() == 2 && rootMem.events.size() == 1, "after com.shop.setAdditive(false), events stop at com.shop");
shop.setAdditive(true);
LogEvent fixed = new LogEvent(Instant.parse("2026-10-07T09:30:00Z"), Level.WARN, "com.shop.cart", "main", "stock low: \"sku-1\"");
check(new PatternFormatter().format(fixed).equals("2026-10-07T09:30:00Z WARN [main] com.shop.cart - stock low: \"sku-1\""), "pattern format: " + new PatternFormatter().format(fixed));
check(new JsonFormatter().format(fixed).contains("\"msg\":\"stock low: \\\"sku-1\\\"\""), "json format escapes quotes: " + new JsonFormatter().format(fixed));
System.out.println("Thread safety");
Path file = Files.createTempFile("app", ".log");
FileAppender fa = new FileAppender(file, new PatternFormatter());
Logger orders = lm.getLogger("com.orders"); orders.setAdditive(false); orders.addAppender(fa);
runThreads(8, 1_000, (t, i) -> orders.info("order {} from thread {}", i, t));
fa.close();
List<String> lines = Files.readAllLines(file); Files.delete(file);
long whole = lines.stream().filter(s -> s.matches("\\S+ INFO \\[w\\d\\] com\\.orders - order \\d+ from thread \\d")).count();
check(lines.size() == 8_000 && whole == 8_000, "8 threads x 1,000 lines to one file: 8,000 lines, every one whole");
System.out.println("Async appender: a bounded queue between the app and the slow disk");
MemoryAppender slowDisk = new MemoryAppender();
AsyncAppender drop = new AsyncAppender(slowDisk, 100, OverflowPolicy.DROP, 0); // writer not started yet: the queue only fills
for (int i = 0; i < 1_000; i++) drop.append(ev(Level.INFO, "e" + i));
check(drop.queued() == 100 && drop.dropped.get() == 900, "DROP, capacity 100, 1,000 events before the writer thread starts: 100 queued, 900 dropped and counted");
drop.start().close();
check(slowDisk.events.size() == 100 && slowDisk.events.get(99).message().equals("e99"), "close() writes out all 100 queued events, in order, then stops");
MemoryAppender keep = new MemoryAppender();
AsyncAppender th = new AsyncAppender(keep, 100, OverflowPolicy.DROP, 20);
for (int i = 0; i < 100; i++) th.append(ev(Level.INFO, "i" + i));
for (int i = 0; i < 30; i++) th.append(ev(Level.ERROR, "x" + i));
th.start().close();
check(keep.events.stream().filter(e -> e.level() == Level.INFO).count() == 80 && keep.events.stream().filter(e -> e.level() == Level.ERROR).count() == 20,
"threshold 20: INFO stops at 80 queued, the last 20 slots are kept for WARN and ERROR (20 kept, 10 more dropped when full)");
MemoryAppender all = new MemoryAppender();
AsyncAppender block = new AsyncAppender(e -> { all.append(e); if (all.events.size() % 100 == 0) sleep(1); }, 16, OverflowPolicy.BLOCK, 0).start();
runThreads(4, 2_500, (t, i) -> block.append(ev(Level.INFO, t + ":" + i)));
block.close();
boolean ordered = true;
for (int t = 0; t < 4; t++) { int last = -1; for (LogEvent e : all.events) if (e.message().startsWith(t + ":")) { int n = Integer.parseInt(e.message().substring(2)); if (n != last + 1) ordered = false; last = n; } }
check(all.events.size() == 10_000 && block.dropped.get() == 0 && ordered, "BLOCK, capacity 16, 4 threads x 2,500: all 10,000 written, 0 dropped, each thread's lines in order");
block.append(ev(Level.ERROR, "late"));
check(block.dropped.get() == 1 && all.events.size() == 10_000, "an event after close() is counted as dropped, never lost silently");
}
static LogEvent ev(Level l, String m) { return new LogEvent(Instant.now(), l, "test", Thread.currentThread().getName(), m); }
static void sleep(long ms) { try { Thread.sleep(ms); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }
interface Job { void run(int thread, int i); }
static void runThreads(int n, int each, Job job) throws Exception {
List<Thread> ts = new ArrayList<>();
for (int t = 0; t < n; t++) { final int tt = t; ts.add(new Thread(() -> { for (int i = 0; i < each; i++) job.run(tt, i); }, "w" + t)); }
for (Thread t : ts) t.start(); for (Thread t : ts) t.join();
}
static void check(boolean ok, String what) { System.out.println((ok ? " ok " : " FAIL ") + what); if (!ok) System.exit(1); }
}
/* Output of this exact program (javac + java 21, 2026-10-07):
* Levels and the logger tree
* ok same name gives the same logger; com.shop.cart -> com.shop -> com -> ROOT
* ok com.shop.cart has no level, inherits DEBUG from com.shop; com.billing inherits INFO from ROOT
* ok debug on an INFO logger is skipped before the message is built (0 toString calls)
* ok debug on com.shop.cart reaches com.shop's appender and ROOT's (additivity)
* ok after com.shop.setAdditive(false), events stop at com.shop
* ok pattern format: 2026-10-07T09:30:00Z WARN [main] com.shop.cart - stock low: "sku-1"
* ok json format escapes quotes: {"time":"2026-10-07T09:30:00Z","level":"WARN","logger":"com.shop.cart","msg":"stock low: \"sku-1\""}
* Thread safety
* ok 8 threads x 1,000 lines to one file: 8,000 lines, every one whole
* Async appender: a bounded queue between the app and the slow disk
* ok DROP, capacity 100, 1,000 events before the writer thread starts: 100 queued, 900 dropped and counted
* ok close() writes out all 100 queued events, in order, then stops
* ok threshold 20: INFO stops at 80 queued, the last 20 slots are kept for WARN and ERROR (20 kept, 10 more dropped when full)
* ok BLOCK, capacity 16, 4 threads x 2,500: all 10,000 written, 0 dropped, each thread's lines in order
* ok an event after close() is counted as dropped, never lost silently
*/How it works

Start with the names. A logger is named with dots, like com.shop.cart, and its parent is the name without the last part, com.shop. Above them all sits the root logger. The LogManager is a single object for the whole program. It keeps a map from name to logger and hands back one object per name, creating missing parents on the way. Logback documents this too: calling getLogger with one name always returns a reference to one logger object.
Each logger may have a level, or may leave it empty. An empty level means: use my parent's. So the effective level is found by walking up the tree until a level is found. The root always has one. A line is written only if its level is at or above the effective level, in the order TRACE, DEBUG, INFO, WARN, ERROR. In the program, com.shop is set to DEBUG, so com.shop.cart logs debug lines, while com.billing uses the root's INFO and skips them.
The level check comes before any work. The message is a pattern with {} slots, the style SLF4J uses, and the slots are only filled after the level check passes. The program proves this: a debug call on an INFO logger never calls the argument's toString.
When a line is on, the logger makes one LogEvent and gives it to its own appenders. Then it passes that event to its parent's appenders, and up to the root. This is called additivity. Turning it off on a logger stops the climb there, which is how you send one part of the app to its own file only.
Appenders and formatters are both small interfaces. A console appender, a file appender and a test appender in memory all take an event. A pattern formatter or a JSON formatter turns it into text. The file appender locks while it writes one line, so 8 threads writing 1,000 lines each give 8,000 whole lines in the program's test.
The hard part is speed. Writing to disk on the app's own thread makes every request wait for the disk. The AsyncAppender puts events on a queue of fixed size and returns. One background thread writes them out. The fixed size matters: a queue with no limit can use up all memory when the disk is slow. But a full queue forces a choice. BLOCK makes the app wait for a free slot. No line is lost, but the app slows down. DROP throws the line away and adds 1 to a dropped counter. The app stays fast, but some lines are gone. Real libraries face this choice too. Logback's AsyncAppender has a queue of 256 by default and blocks when full, unless neverBlock is set to true. By default it also starts dropping TRACE, DEBUG and INFO lines when only 20% of the queue is left, to keep room for WARN and ERROR. Log4j 2's AsyncAppender holds 1,024 events by default and also blocks by default. The program tests both policies and the threshold. With DROP and a queue of 100, 1,000 events give 100 queued and 900 counted as dropped.
Shutdown is the last trap. On close, the appender stops taking new events, puts an end marker on the queue, waits for the writer to finish everything before the marker, and then closes the file. An event that arrives after close is counted as dropped.
Good follow-ups: rolling files by size or date, sending logs to a central server, adding a request id to every line, and writing many lines to disk in one call to save time.
Edge cases & gotchas
- A debug line on a logger set to INFO. The message is never built, so an expensive toString is never called.
- A logger with no level whose parent also has none. The search keeps going up to the root.
- Two threads ask for a new logger name at once. The map's computeIfAbsent makes sure only one logger is created.
- The disk stalls and the queue fills. BLOCK slows the app down to the disk's speed. DROP keeps the app fast but loses lines, so it counts them.
- The queue is nearly full and an ERROR arrives. With a discard threshold, low-level lines are dropped first so the last slots are kept for WARN and ERROR.
- A line is logged after shutdown starts. It is counted as dropped, not written after the end marker and lost.
- A message contains a quote character. The JSON formatter escapes it, so the line is still valid JSON.
FAQ
Master LLD and system design interviews
770 interactive lessons and 90 real systems taken apart. One payment, lifetime access, no subscription.
course 1
System Design Masterclass
From absolute beginner to principal engineer, drawn step by step.
- 770 interactive lessons
- Step-by-step system design diagrams
- Live code editors
- Quizzes with instant feedback
- Progress tracking and streaks
- Lifetime access and all future lessons