The idea
What it is
“Design a logging framework.” It is the friendliest-sounding prompt in the set, and it is the one most candidates fail without noticing. Everybody has used a logger. Almost nobody has been asked to take one apart.
Here is the naive answer, and it takes about ninety seconds to write: a Logger class with a log(level, message) method, a switch on the level, and a System.out.println at the bottom. It works. It also has no seams, and the interviewer is about to press on every one of the places where a seam should have been.
Because a single call — log.warn("disk 91% full") — is really three independent questions stacked on top of each other. Does this message get through at all? Where does it go? What does it look like when it gets there? Each one changes for a different reason, at a different time, decided by a different person. Weld them together and every one of them costs you an edit to the class the whole application depends on.
The whole system in three sentences
A Logger checks one number — is my level at least the threshold? — and returns immediately if not. If it passes, it builds one immutable LogEvent and hands the same object to every Appender on its list. Each appender applies its own threshold, asks its own Formatter to turn the event into bytes, and writes them wherever it writes.
What is actually being graded
- Are the three axes separate? Level, sink and format must be three interfaces you can change independently. If any two live in the same class, the design has already failed and everything after is decoration.
- Are levels ordered?
TRACE < DEBUG < INFO < WARN < ERROR < FATALas an enum with an ordinal. Levels as strings make a threshold impossible, and that is the single most common wrong turn. - Is the level check before the work? The filter must run before the message is formatted, before the event is built, before anything is allocated. Formatting first and filtering second is a real performance bug in a real system.
- Can you add a sink without touching the core? “Now also send errors to Slack.” The correct answer is one new class implementing
Appenderand one line of configuration. Any answer that editsLoggerloses the point. - Does it run under threads? Many threads log at once. Locking per appender, one event object shared by all of them, and an async option for the slow sink — that is the difference between a diagram and a framework.
Mechanics
How it works
Step 1 · Clarify — 4 minutes
- Are we building the framework or using one? — the framework. Say it back, because the whole round hinges on it: you are writing the thing that
slf4jis, not the code that calls it. - How many destinations? — at least three: console, file, and something over the network. Fewer than two and the fan-out never appears, which kills the most interesting half of the design.
- Can two destinations want different formats? — yes, and ask this deliberately. “Plain text on the console for humans, JSON in the file for machines” is the sentence that forces
Formatterto be its own interface. - Can I turn on debug for one part of the app only? — yes. That is the hierarchical logger name, and it is the follow-up they always have ready.
- Is it multi-threaded? — yes. Assume it, do not ask timidly. A logger that garbles lines when two threads write is not a logger.
- Search, dashboards, shipping to a collector? — out of scope. One sentence, then move on.
Do not start coding at minute 5
This problem punishes the fast starter. If you open with class Logger and a switch, you will spend minutes 30 through 55 retrofitting seams into code that has none, in front of someone watching you do it. Spend four minutes drawing three boxes — gate, appender, formatter — and the code afterwards writes itself.
Step 2 · The welded version, and what it costs
Write the naive one down. Do not skip this — showing the interviewer that you know what you are avoiding is worth more than quietly avoiding it.
class Logger {
boolean toConsole = true;
boolean toFile = true;
String level = "INFO"; // a String. remember this.
void log(String level, String message) {
String line = "[" + level + "] " + new Date() + " " + message; // formatted FIRST
switch (level) {
case "DEBUG": if (!this.level.equals("DEBUG")) return; break;
case "INFO": if (this.level.equals("ERROR")) return; break;
// ... and it grows every time somebody adds a level
}
if (toConsole) System.out.println(line);
if (toFile) writeToFile(line);
}
}It works today. Now the interviewer says the sentence they were always going to say: “Now also send errors to Slack, as JSON, but only in production.” Count what you touch.
Step 3 · Axis one — WHAT gets through
Levels are not labels. They are an ordered scale, and the ordering is the only reason a threshold can exist. Make the enum, and one line of code does the entire job:
enum Level { TRACE, DEBUG, INFO, WARN, ERROR, FATAL } // order IS the meaning
// inside Logger.log(...)
if (level.ordinal() < threshold.ordinal()) return; // before ANYTHING is built<, so there is no threshold, so you end up with the switch that grows a case every time someone invents a level.The check goes first, or it is not a check
if (isTooLow) return; must be the first statement, before the timestamp is read, before the event is allocated, before String.format runs. Filtering after formatting is the classic version of this bug: you pay the full cost of every disabled DEBUG line and then throw the result away. Real frameworks care about allocation on this path for exactly that reason.
The threshold is hierarchical — the follow-up they always have ready
“The database pool is misbehaving. Turn on DEBUG for it — but only it.” One global threshold cannot do this. What can is the logger name, which is a dotted path, and a threshold that is inherited down that path.
A logger named com.app.db.PoolManager asks itself: am I configured? No. So it asks com.app.db. Configured — DEBUG. Stop. That walk-up-until-someone-handles-it is Chain of Responsibility, and it is why turning on DEBUG for one package does not drown you in everything else.
Step 4 · Axes two and three — WHERE it goes and HOW it looks
An Appender answers where. It has one method, append(LogEvent), and the logger holds a list of them. When an event passes the gate, the logger walks the list and hands the same event to each one. That fan-out — one source, N interested parties, added and removed without the source knowing — is Observer.
A Formatter answers what the bytes look like. It also has one method, format(LogEvent) -> String. The appender owns one, calls it, and writes the result. Two interchangeable implementations behind one method is Strategy in its most literal form.
The point is that they compose. Each appender carries its own threshold and its own formatter, so console-at-DEBUG-in-plain-text and file-at-WARN-in-JSON is not a special case — it is just two objects configured differently. The two filters stack: the logger gate decides if the event exists at all, and each appender decides if it wants this one.
Step 5 · The LogEvent — build it once, share it with everyone
The event is the value object that travels down the pipeline. It carries everything an appender could want, it is immutable, and — this matters — it is built exactly once per log call and handed to every appender. Building a fresh one per appender is a real bug: three appenders means three timestamps for one event, and now your file and your console disagree about when something happened.
context map is the field candidates forget and interviewers love: it is where a trace id rides along, so one request can be followed across every log line it produced.append arrows: the logger does not know or care what happens after them. Both appenders got the identical event and reached different bytes on different devices. Notation: Sequence diagrams. The class layout is Class diagrams.The lazy-message trap — raise this unprompted
Here is a line that looks completely harmless and is not:
log.debug("user " + user.expensiveToString() + " has " + orders.size() + " orders");Arguments are evaluated before the call. So even with DEBUG switched off — even though debug() returns on its very first line — the concatenation and expensiveToString() have already run. On a hot path, called a million times, that is a million wasted strings for output nobody will ever see.
Step 6 · Threads — lock per appender, never globally
Twenty threads log at once. Two of them are halfway through writing a line to the same file, and what lands on disk is the front of one line spliced onto the back of another. So writes must be serialised — but serialised per appender, not across the whole framework.
One global lock and you have made every appender wait for the slowest one: a network sink taking 200ms stalls the console. A lock inside each appender lets the console write while the file writes while the network waits on its socket. The events themselves need no protection at all, because they are immutable. Background: Locks, Mutex, Semaphore.
The registry is the one legitimately-global thing
LogManager.getLogger(name) must return the same logger object for the same name, from any thread, forever. That is a cache: ConcurrentHashMap.computeIfAbsent(name, ...). It is worth being precise here — the manager is a registry, not a Singleton per logger. One manager, many loggers, each keyed by name. Candidates who say “Logger is a singleton” usually mean this and get corrected.
Step 7 · The async appender, and the two questions after it
A network sink is slow, and right now the request thread is paying for it. The fix is a decorator: AsyncAppender wraps another appender, drops the event into a bounded queue, and returns. One writer thread drains the queue into the real appender. That is Producer–Consumer, and mentioning it by name is free marks.
Then two questions arrive, and they arrive every single time.
The shutdown question is the one people forget
Async means events are in a queue when the process dies. If close() does not drain and flush, the last few seconds of logs — the seconds around the crash you are trying to debug — are exactly the ones that never reach disk. A close() on the Appender interface plus a shutdown hook is the whole answer, and it takes ten seconds to say.
The extensibility test
The 60 minutes
The follow-ups
- “The log file grows forever.” → rotation inside
FileAppender: roll when it passes a size, or at midnight, and keep the last N. Say that rotation belongs to the file appender and nothing else knows it happens. - “Why JSON instead of plain text?” → because at scale nobody greps. One line per event with real fields lets you filter by
level,traceIdoruserIdwithout regexes that break the first time a message contains a space. Plain text stays on the console for humans. - “This log line fires ten thousand times a second.” → sampling: keep 1 in N, and record the sampling rate so the count can be reconstructed. It belongs in a filter, not in the caller.
- “I need to follow one request across all its lines.” → the context map. Put a
traceIdin it at the edge of the request, and every formatter prints it because it is just another field on the event. - “What about secrets?” → never log passwords, tokens or card numbers. The framework's contribution is a masking filter and the discipline of logging identifiers rather than payloads. Worth ten seconds; it is a real-world scar.
- “Reload the configuration without a restart.” → the loggers are already registry objects, so changing a level is a field write; the atom you swap is the config, not the logger. It works because the level was never baked into a
switch. - “Two appenders both write to the same file.” → they each have their own lock, so their own locks do not help. Either share one appender instance, or make the file itself the locked resource — a good question to answer honestly rather than hand-wave.
How this round is lost
- One god
Loggerwith aswitch. No interfaces, no seams, and every follow-up becomes an edit to the same method. - Levels as strings. No ordering means no threshold, and you are back to comparing text and growing the switch.
- Formatting inside the appender. The
String.formatsits inConsoleAppender, so JSON on the console means a new appender rather than a new formatter — the axes have been welded and nobody noticed. - The level check after the string is built. Correct output, wasted work on every disabled line, and it signals you have not thought about the log call as a hot path.
- A single global lock. The slowest sink now sets the pace for all of them, and you have made logging a contention point across the whole process.
- No way to add a sink without editing the core. This is the one the whole problem exists to test. If
Loggerhas to change to gain a destination, nothing else you did will rescue it.
Interactive prototype
See it. Build it. Break it.
A sandboxed, hands-on simulation — no setup, no install. Play with it as you read.
About this simulation
A live pipeline you can rewire. Press ERROR and watch one event pass the threshold gate, fan out, and land on all three appender cards at once; press WARN and only two of them take it. Flip 📄 File to { } JSON and press ERROR again — the same event is on screen as plain text and as JSON at the same time. Set the root threshold to FATAL and press INFO: the gate says ✗ DROP and no formatter ever runs. Press ➕ Add Slack appender — a fourth sink joins the fan-out and the counter still reads core logger edited: 0 lines. Then switch the network card to ⚡ async and press 🔥 Burst 20 to watch the five-slot queue fill and the dropped counter start ticking.
Hands-on
Try these yourself
Open the prototype above, predict what happens, then verify.
Send one event through the whole pipeline
Press ERROR. The event passes the threshold gate, hits the fan-out, and lands on all three appender cards at once — one call, three writes, and the logger did not know any of the three existed. Now press WARN: it passes the gate just the same, but only two cards take it. The callline shows the real call each time — log.warn("disk 91% full").
Close the gate and watch nothing get formatted
The selected logger is com.app.db.PoolManager and the tree shows its effective level coming from com.app.db. Click 🏷 com.app instead — the tree marker jumps to root, because com.app is not configured. Now set the root threshold to FATAL and press INFO: the gate reads ✗ DROP, the passed gate counter does not move, and no card changes. Nothing was built, nothing was formatted, nothing was allocated.
Make the same event look three different ways
Reset the threshold to INFO and press ERROR — all three cards fill, two of them in plain text. Now flip 📄 File to { } JSON and press ERROR again: the same event is on screen simultaneously as a plain line and as two JSON objects. The formatter changed; the appender did not; the logger has no idea either happened.
Stack the two filters
Press INFO with the chips as they come: it passes the gate, reaches all three cards, and only 🖥 Console (≥ DEBUG) accepts it — the other two show ✗ dropped — below its own ≥ WARN / ≥ ERROR. Now click the console's own ≥ DEBUG chip round to ≥ ERROR and press INFO again: it still passes the gate and now lands nowhere. Two independent filters, composing without knowing about each other.
Add a sink and count the edits
Press ➕ Add Slack. A fourth card slides into the fan-out and immediately receives the next event. Look at the counter in the top row: core logger edited: 0 lines. That zero is the entire argument for the design — and it is what Open/Closed (OCP) means when you can measure it.
Fill a bounded queue and lose events on purpose
Press 🔥 Burst 20 with the network card on ⚡ sync: twenty errors go through one at a time and the card flashes ⏳ caller blocked on every write. Now click it to ⚡ async and burst again — events land in a five-slot queue instantly, one writer drains it at its own pace, and the moment the queue is full the dropped counter starts ticking. Decide which behaviour you would ship, and be ready to defend it.
Build it from memory
Blank file, in this order: Level enum → LogEvent (all fields final) → interface Formatter with plain and JSON → interface Appender with a threshold, a formatter and its own lock → Logger with the parent walk for the effective level → LogManager with a ConcurrentHashMap → a main() that configures console-at-DEBUG-plain and file-at-WARN-JSON and logs from three different logger names. If adding a fourth sink requires editing Logger, start again.
In practice
When to use it — and what trips people up
The shape you just learned
Strip the logging away and this is one event, three independent decisions, three interfaces: a filter that decides whether the event lives, a set of sinks that decide where it goes, and a rendering step that decides what it looks like. Once you can see that shape, you find it everywhere — and you find it welded together everywhere too.
- Metrics and tracing — the same pipeline with different payloads: a sampler instead of a threshold, exporters instead of appenders, wire formats instead of layouts.
- Notification systems — one event, N channels (email, SMS, push), each with its own audience filter and its own template. Identical structure, different nouns.
- Audit trails — same fan-out, but the full-queue answer flips from drop to block, because losing an audit record is not acceptable.
- Analytics event pipelines — collect, filter, enrich, fan out to several destinations in several encodings.
- Any render step at all — the moment you catch yourself putting
String.formatinside the thing that does the I/O, you are welding axis three onto axis two.
The three-sentence version to say out loud
“Levels are an ordered enum so one comparison filters everything, and that comparison runs before anything is built. The logger builds one immutable event and fans it out to a list of appenders, so a new sink is a new class and no edit. Each appender owns its own threshold, its own formatter and its own lock, so where it goes, what it looks like and how it is serialised all vary independently.” Twenty-five seconds, and it is the whole design.
Where this design stops working
- When throughput is extreme. At millions of events a second, even building the
LogEventobject is too much allocation. Real frameworks go to ring buffers and object reuse — which trades away the immutability you just relied on. Know the trade; do not build it in an interview. - When ordering across appenders must be exact. An async appender reorders events relative to the synchronous ones. If a downstream consumer needs a strict global order, async is off the table or needs sequence numbers.
- When logs are the system of record. Dropping on a full queue is right for diagnostics and wrong for anything you will be audited on. Then you block, or you write to a durable local queue first.
- When configuration must change constantly. Per-package levels solve “debug this component”. They do not solve “sample 1% of this specific customer's traffic” — that wants a filter chain evaluated per event, which is a bigger design.
If you only remember one thing
Three axes, three interfaces, never welded. Whether is an ordered level and a comparison that runs first. Where is an Appender you can add without editing anything. What it looks like is a Formatter the appender holds but does not know. Every follow-up in this round is answered by pointing at one of those three.
What it gives you
- Three orthogonal interfaces mean N sinks and M formats cost N+M classes instead of N×M branches, and a new destination is one new class with zero edits to the core.
- An ordered level enum turns the entire filtering rule into a single comparison that runs before any allocation, so disabled log lines cost almost nothing.
- Hierarchical logger names let two lines of configuration control the level of thousands of loggers, and let you enable DEBUG for one package without drowning in the rest.
- One immutable LogEvent per call, shared by every appender, means every sink agrees on the timestamp and the object is safe to hand across threads without copying.
- A per-appender lock keeps writes non-interleaved while still letting a fast console and a slow network sink proceed in parallel.
Common mistakes
- The layered version is genuinely more code than the one-class logger, and for a script that prints to stdout the extra structure earns nothing.
- The parent walk on every log call costs a few pointer hops; real frameworks cache the resolved level and then have to invalidate that cache when configuration changes.
- Async appenders break the ordering between sinks and can lose events on a hard crash, so the diagnostics you most want are the ones most at risk.
- Allocating a LogEvent per call is fine at normal rates and is exactly the thing that stops being fine at extreme throughput, where reuse and ring buffers take over.
- Per-appender locking silently fails when two appender instances point at the same file — the lock protects the object, not the resource.
Reference
Code & further reading
A minimal reference implementation and pointers worth bookmarking.
import java.time.Instant;
import java.util.*;
import java.util.concurrent.*;
/** Axis 1: WHAT gets through. Order IS the meaning — that is what makes a threshold possible. */
enum Level { TRACE, DEBUG, INFO, WARN, ERROR, FATAL }
/** The value object. Immutable, built ONCE per log call, shared by every appender. */
final class LogEvent {
final Instant timestamp;
final Level level;
final String loggerName;
final String message;
final String threadName;
final Throwable throwable;
final Map<String, String> context;
LogEvent(Level level, String loggerName, String message, Throwable throwable, Map<String, String> context) {
this.timestamp = Instant.now();
this.level = level;
this.loggerName = loggerName;
this.message = message;
this.threadName = Thread.currentThread().getName();
this.throwable = throwable;
this.context = Map.copyOf(context);
}
}
/** Axis 3: HOW it looks. One method, two implementations, zero knowledge of where it lands. */
interface Formatter { String format(LogEvent e); }
class PlainTextFormatter implements Formatter {
public String format(LogEvent e) {
StringBuilder sb = new StringBuilder()
.append(e.timestamp).append(' ')
.append(String.format("%-5s", e.level)).append(" [").append(e.threadName).append("] ")
.append(e.loggerName).append(" - ").append(e.message);
e.context.forEach((k, v) -> sb.append(' ').append(k).append('=').append(v));
if (e.throwable != null) sb.append(" | ").append(e.throwable);
return sb.toString();
}
}
class JsonFormatter implements Formatter {
public String format(LogEvent e) {
StringBuilder sb = new StringBuilder("{");
sb.append(q("ts")).append(':').append(q(e.timestamp.toString())).append(',');
sb.append(q("level")).append(':').append(q(e.level.name())).append(',');
sb.append(q("logger")).append(':').append(q(e.loggerName)).append(',');
sb.append(q("thread")).append(':').append(q(e.threadName)).append(',');
sb.append(q("msg")).append(':').append(q(e.message));
e.context.forEach((k, v) -> sb.append(',').append(q(k)).append(':').append(q(v)));
return sb.append('}').toString();
}
private static String q(String s) { return '"' + s.replace("\"", "\\\"") + '"'; }
}
/** Axis 2: WHERE it goes. Its own threshold, its own formatter, its OWN lock. */
interface Appender extends AutoCloseable {
void append(LogEvent e);
default void close() {}
}
abstract class AbstractAppender implements Appender {
final String appenderName;
private final Level threshold;
private final Formatter formatter;
private final Object writeLock = new Object(); // per appender — NOT global
AbstractAppender(String appenderName, Level threshold, Formatter formatter) {
this.appenderName = appenderName; this.threshold = threshold; this.formatter = formatter;
}
public final void append(LogEvent e) {
if (e.level.ordinal() < threshold.ordinal()) return; // second filter, composes with the logger's
String line = formatter.format(e); // format OUTSIDE the lock
synchronized (writeLock) { write(line); } // only the write is serialised
}
protected abstract void write(String line);
}
class ConsoleAppender extends AbstractAppender {
ConsoleAppender(Level threshold, Formatter formatter) { super("console", threshold, formatter); }
protected void write(String line) { System.out.println("[console] " + line); }
}
/** Rotation lives HERE and nowhere else — nothing outside knows the file rolls. */
class FileAppender extends AbstractAppender {
private final long maxBytes;
private long written = 0;
private int rollCount = 0;
FileAppender(Level threshold, Formatter formatter, long maxBytes) {
super("file", threshold, formatter);
this.maxBytes = maxBytes;
}
protected void write(String line) {
if (written + line.length() > maxBytes) {
rollCount++;
written = 0;
System.out.println("[file] -- rolled to app.log." + rollCount + " --");
}
written += line.length();
System.out.println("[file] " + line);
}
}
class NetworkAppender extends AbstractAppender {
NetworkAppender(Level threshold, Formatter formatter) { super("network", threshold, formatter); }
protected void write(String line) {
try { Thread.sleep(40); } catch (InterruptedException ie) { Thread.currentThread().interrupt(); }
System.out.println("[network] " + line);
}
}
/** A decorator: the caller returns immediately, one writer thread drains the queue. */
class AsyncAppender implements Appender {
private final Appender delegate;
private final BlockingQueue<LogEvent> queue;
private final Thread writer;
private volatile boolean running = true;
private int dropped = 0;
AsyncAppender(Appender delegate, int capacity) {
this.delegate = delegate;
this.queue = new ArrayBlockingQueue<>(capacity);
this.writer = new Thread(this::drain, "log-writer");
this.writer.setDaemon(true);
this.writer.start();
}
public void append(LogEvent e) {
// FULL QUEUE POLICY: drop and count. A log line is worth less than the request it describes.
if (!queue.offer(e)) dropped++;
}
private void drain() {
while (running || !queue.isEmpty()) {
try {
LogEvent e = queue.poll(20, TimeUnit.MILLISECONDS);
if (e != null) delegate.append(e);
} catch (InterruptedException ie) { Thread.currentThread().interrupt(); return; }
}
}
/** Without this, the last events — the interesting ones — never reach the sink. */
public void close() {
running = false;
try { writer.join(2000); } catch (InterruptedException ie) { Thread.currentThread().interrupt(); }
delegate.close();
System.out.println("[async] flushed; dropped=" + dropped);
}
}
/** The logger: a threshold, a parent, and a list of appenders. It knows nothing about formats. */
class Logger {
private final String loggerName;
private final Logger parent; // null for root
private volatile Level configured; // null = inherit
private final List<Appender> appenders = new CopyOnWriteArrayList<>();
Logger(String loggerName, Logger parent) { this.loggerName = loggerName; this.parent = parent; }
void setLevel(Level level) { this.configured = level; }
void addAppender(Appender a) { appenders.add(a); }
/** Chain of Responsibility: walk up until somebody is configured. */
Level effectiveLevel() {
for (Logger node = this; node != null; node = node.parent)
if (node.configured != null) return node.configured;
return Level.INFO;
}
boolean isEnabled(Level level) { return level.ordinal() >= effectiveLevel().ordinal(); }
void log(Level level, String message, Throwable t, Map<String, String> context) {
if (level.ordinal() < effectiveLevel().ordinal()) return; // FIRST statement. nothing built yet.
LogEvent e = new LogEvent(level, loggerName, message, t, context); // built ONCE
for (Logger node = this; node != null; node = node.parent)
for (Appender a : node.appenders) a.append(e); // the SAME object to everyone
}
/** Parameterised message: the substitution happens only after the gate. */
void log(Level level, String pattern, Object... args) {
if (level.ordinal() < effectiveLevel().ordinal()) return;
String message = pattern;
for (Object arg : args) message = message.replaceFirst("\\{\\}", String.valueOf(arg));
log(level, message, null, Map.of());
}
void trace(String p, Object... a) { log(Level.TRACE, p, a); }
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); }
}
/** A REGISTRY, not a singleton per logger: one manager, many loggers, cached by name. */
class LogManager {
private static final Map<String, Logger> CACHE = new ConcurrentHashMap<>();
private static final Logger ROOT = new Logger("root", null);
static { ROOT.setLevel(Level.INFO); CACHE.put("root", ROOT); }
static Logger root() { return ROOT; }
static Logger getLogger(String loggerName) {
return CACHE.computeIfAbsent(loggerName, n -> {
int dot = n.lastIndexOf('.');
Logger parent = dot < 0 ? ROOT : getLogger(n.substring(0, dot));
return new Logger(n, parent);
});
}
static void shutdown() { /* a real one walks every logger; here the demo closes explicitly */ }
}
public class Main {
public static void main(String[] args) throws Exception {
// ---- configuration: three sinks, three thresholds, two formats ----
Logger root = LogManager.root();
root.setLevel(Level.INFO);
root.addAppender(new ConsoleAppender(Level.DEBUG, new PlainTextFormatter()));
root.addAppender(new FileAppender(Level.WARN, new JsonFormatter(), 200));
AsyncAppender net = new AsyncAppender(new NetworkAppender(Level.ERROR, new JsonFormatter()), 8);
root.addAppender(net);
// ---- turn on DEBUG for ONE package only ----
LogManager.getLogger("com.app.db").setLevel(Level.DEBUG);
Logger web = LogManager.getLogger("com.app.web.Handler");
Logger pool = LogManager.getLogger("com.app.db.PoolManager");
System.out.println("web effective = " + web.effectiveLevel()); // inherits root
System.out.println("pool effective = " + pool.effectiveLevel()); // inherits com.app.db
web.debug("this never appears — web inherits INFO");
pool.debug("pool size={} idle={}", 8, 3); // DEBUG is on here
web.info("user {} signed in", 42);
pool.warn("disk {}% full", 91);
web.error("connection refused");
// ---- the lazy-message guard ----
if (pool.isEnabled(Level.TRACE)) pool.trace("expensive: " + expensiveDump());
Thread.sleep(300);
net.close();
}
static String expensiveDump() { return "…a very costly string…"; }
}
/* expected output (ordering of the async lines may vary):
web effective = INFO
pool effective = DEBUG
[console] …Z DEBUG [main] com.app.db.PoolManager - pool size=8 idle=3
[console] …Z INFO [main] com.app.web.Handler - user 42 signed in
[console] …Z WARN [main] com.app.db.PoolManager - disk 91% full
[file] {"ts":"…","level":"WARN","logger":"com.app.db.PoolManager","thread":"main","msg":"disk 91% full"}
[console] …Z ERROR [main] com.app.web.Handler - connection refused
[file] {"ts":"…","level":"ERROR","logger":"com.app.web.Handler","thread":"main","msg":"connection refused"}
[network] {"ts":"…","level":"ERROR","logger":"com.app.web.Handler","thread":"main","msg":"connection refused"}
[async] flushed; dropped=0
*/References & further reading
7 sources- Docslogging.apache.org
Log4j 2 — Architecture
The reference implementation of exactly this design: LoggerContext, Logger, Appender, Layout and Filter, with the object diagram spelled out.
- Docslogback.qos.ch
Logback — Appenders
Every appender variant you might be asked about, including rolling file policies and the async appender's discarding behaviour under a full queue.
- Docsslf4j.org
SLF4J FAQ — parameterised messages and the logging facade
The canonical explanation of why the placeholder form beats string concatenation, and what a facade over several backends actually buys.
- Articlelogging.apache.org
Log4j 2 — Asynchronous Loggers
What the queue-and-writer-thread idea looks like at production scale, including the trade-offs of dropping versus blocking.
- Docsdocs.python.org
Python logging HOWTO
The same three axes under different names — levels, handlers, formatters — plus the clearest write-up of hierarchical logger name resolution.
- Article12factor.net
The Twelve-Factor App — Logs
The argument that an application should emit an event stream and never manage log files, which is the boundary you name as out of scope.
- Specopentelemetry.io
OpenTelemetry — Logs specification
The modern structured-log data model: exactly the LogEvent fields from this lesson, standardised, with trace correlation built in.
Knowledge check
Did it land?
Quick questions, answers revealed on submit. Sign in to save your best score.
question 01 / 08
Why must log levels be an ordered enum rather than strings?
question 02 / 08
The interviewer says: “now also send errors to Slack, as JSON, but only in production.” In a correctly layered design, what do you touch?
question 03 / 08
log.debug("user " + user.expensiveToString()) runs with DEBUG switched off. What actually happens?
question 04 / 08
A logger named com.app.db.PoolManager has no configured level. com.app.db is set to DEBUG and root is set to INFO. What is its effective level, and what pattern is that?
question 05 / 08
Where should the level check happen relative to formatting the message?
question 06 / 08
Should the framework take one global lock, or a lock per appender?
question 07 / 08
An async appender's bounded queue is full. What should append() do, and why?
question 08 / 08
Why should the LogEvent be built once and handed to every appender, rather than built per appender?
0/8 answered