Why this matters: the logging implementation is short — which is why interviewers watch placement rather than volume. The level check on the first line, the event built exactly once, the formatter invoked by the appender and not the logger: each placement is a design claim from lesson 02 made checkable. This walkthrough builds it in the order you'd write it live.
Levels and the event
enum Level { DEBUG, INFO, WARN, ERROR } // ordinal order IS severity
final class LogEvent { // immutable — shared freely
final Instant timestamp;
final Level level;
final String loggerName;
final String message;
final Map<String, String> fields; // already merged
final long threadId;
}Say the two decisions aloud: severity comparisons ride the enum's ordering (one >=, no case analysis), and the event is final-everything so concurrent appenders can read it without coordination.
The two seams
interface Formatter {
String format(LogEvent e);
}
interface Appender {
Level minLevel(); // the destination's own gate
void append(LogEvent e); // filter, format, write
}
class ConsoleAppender implements Appender {
final Formatter formatter;
final Level minLevel;
public void append(LogEvent e) {
if (e.level.ordinal() < minLevel.ordinal()) return; // gate #2
String line = formatter.format(e);
synchronized (this) { out.println(line); } // per-appender ordering
}
}
class LineFormatter implements Formatter {
public String format(LogEvent e) {
return e.timestamp + " [" + e.level + "] " + e.loggerName
+ " " + e.message + " " + e.fields;
}
}The appender owns a formatter and its own minimum — gate #2 from the design, applied to an already-built event. The small synchronized block is the per-destination ordering promise from the requirements: one thread's messages can't interleave mid-line at this destination. Narrate its scope — it serializes writes to this one appender, nothing else.
The logger: gate first, build once, dispatch
final class Logger {
final String name;
final Level minLevel; // gate #1
final Map<String, String> boundFields; // immutable
final List<Appender> appenders; // shared dispatch list
void log(Level level, String message, Map<String, String> callFields) {
if (level.ordinal() < minLevel.ordinal()) return; // FIRST LINE — the hot path rule
Map<String, String> merged = new HashMap<>(boundFields);
merged.putAll(callFields); // per-call overrides bound — the ONE merge point
LogEvent e = new LogEvent(now(), level, name, message,
unmodifiable(merged), currentThreadId());
for (Appender a : appenders) a.append(e); // built once, consumed many times
}
void debug(Supplier<String> lazyMessage) { // caller-side cost extension
if (Level.DEBUG.ordinal() < minLevel.ordinal()) return;
log(Level.DEBUG, lazyMessage.get(), Map.of());
}
}Three placements to narrate as you write them. The gate is the first statement — a suppressed call does one integer compare and returns, allocating nothing. The field merge happens once, here, with its collision rule (putAll = per-call wins) visible in a single line rather than re-decided per appender. And the appender loop hands out the same event object — the built-once claim in code.
Child loggers: with() returns new
Logger with(Map<String, String> fields) {
Map<String, String> merged = new HashMap<>(boundFields);
merged.putAll(fields);
return new Logger(name, minLevel, unmodifiable(merged), appenders);
}Four lines, and the aliasing bug from lesson 02 is structurally impossible: the original logger is untouched, so a request-scoped child can't bleed fields into a neighbor's messages. The child shares the appender list by reference — destinations are process-wide, context is per-scope, and this constructor is where that distinction lives.
Wiring at startup
List<Appender> shared = List.of(
new ConsoleAppender(new LineFormatter(), Level.INFO),
new FileAppender(new DetailFormatter(), Level.DEBUG)
);
Logger checkout = new Logger("payments.checkout", Level.DEBUG, Map.of(), shared);Console at INFO while the file collects DEBUG — the two-gate requirement, satisfied by construction arguments rather than special cases. Configuration resolved at startup means this wiring runs once; no reload machinery, no concurrent config mutation to defend against.
What you'd say about complexity
A suppressed message: one integer comparison, zero allocation — and the justification is placement, the gate preceding all construction. An emitted message: one O(fields) merge, one event allocation, then O(appenders) dispatch with per-appender formatting. Formatting cost lands only on destinations whose gate passes. Say the honest footnote: the synchronized write serializes concurrent emitters at each destination — correctness for the ordering promise, paid for in contention only when many threads emit to the same appender at once.
Key takeaway
The implementation is placement: gate #1 as the first line of log() (suppressed = one compare, zero allocation), the event built once and shared immutably, gate #2 and formatting inside the appender where the destination's concerns live, the field-merge collision rule in exactly one line, and with() returning a new logger so context can't alias. Short code, every line standing on a requirement.