Logging framework
Product wants DEBUG on the console, INFO in a file, and ERROR also on a pager. If Logger.log grows an if for every sink, Slack or JSON forces you to edit the core. Treat logging as a so a new sink is a new handler at the end.
This page is in-process machine coding, not a logging SaaS and not a reimplementation of log4j. You show a testable chain and a seam.
Safe evolution path
- 1. Event. Build an immutable stamped by .
- 2. Chain. Each may write, then always forwards unless you stop on purpose.
- 3. Format. Inject a so JSON is not a Logger switch.
- 4. Wire. A owns
setNext; Logger stays one method.
How do I add a sink without editing the core?
The question above is the whole design. Logger builds a and sends it to the first . A FileHandler with min INFO still forwards ERROR so the pager can see it. The chain is the configuration. See Chain of Responsibility.
Format is a Strategy () if you want JSON versus text without subclassing every handler. Destination is the ’s job. Async logging is a queue in front of the chain (producer-consumer); mention it as an extension and start synchronous so tests can assert FakeAppender counts.
Filter, write, then forward
After the story, draw the path once so the whiteboard and the code share the same arrows.
Read the diagram definition
flowchart LR
L[Logger.log] --> C[ConsoleHandler DEBUG+]
C --> F[FileHandler INFO+]
F --> P[PagerHandler ERROR+]
P --> X[end]Responsibility walk
- 01
Logger
Entry point. Builds LogEvent with Clock. Does not know files or pagers.
- 02
LogHandler
setNext plus handle. Filter and append, then forward to next.
- 03
Formatter
Strategy for text vs JSON, injected into handlers that write.
- 04
Appender / destination
Console, file, or pager sink behind one write interface.
- 05
Level
Ordered enum. Compare ordinals; never stringify for filtering.
- 06
Builder / factory
Wires the chain from config so Logger stays one boring method.
Java API sketch
The walk above becomes types. Notice pass always runs so a skipped write is not a swallowed chain.
enum Level { DEBUG, INFO, WARN, ERROR }
final class LogEvent {
final Level level;
final String message;
final Instant at;
}
interface LogHandler {
void setNext(LogHandler next);
void handle(LogEvent event);
}
abstract class AbstractLogHandler implements LogHandler {
private LogHandler next;
public void setNext(LogHandler next) { this.next = next; }
protected void pass(LogEvent event) {
if (next != null) next.handle(event);
}
}
interface Formatter {
String format(LogEvent event);
}
class LevelFilterHandler extends AbstractLogHandler {
private final Level min;
private final Appender appender;
LevelFilterHandler(Level min, Appender appender) {
this.min = min;
this.appender = appender;
}
public void handle(LogEvent event) {
if (event.level.ordinal() >= min.ordinal()) {
appender.write(event);
}
pass(event); // always forward so ERROR still reaches the pager
}
}
class Logger {
private final LogHandler head;
private final Clock clock;
void log(Level level, String message) {
head.handle(new LogEvent(level, message, clock.instant()));
}
}Failures, concurrency, and follow-ups
With the API in hand, name the bugs interviewers expect and the extensions you will not start with.
Invalid cases
if (level) inside Logger for every sink
The next sink edits the core; handlers must own destinations instead.
Chain that swallows without forwarding
ERROR never reaches the pager; filter and write, then forward unless you stop.
Null next without a terminal handler
The last handler should tolerate next == null and still finish cleanly.
Real files in unit tests with no seam
Inject FakeAppender that records events so assertions stay deterministic.
DEBUG handler that compares strings "debug"
Level is an ordered enum; ordinal compare is the model, not string equals.
Concurrency
Synchronous chain: one lock on Logger.log or thread-confined logging. Async means a BlockingQueue and a worker, and you must say what happens when the queue is full. Start sync. See Decorator if they want wrapping formatters instead of a chain; both can be honest if you name the difference.
What the follow-up changes
- SlackHandler: new class appended to the chain; Logger.java stays closed for that change.
- JSON formatter: Strategy on handlers, not a new Logger method or switch.
- Async: queue in front of head; tests still use sync FakeAppender until that path is green.
Wire the chain on paper first
Before coding, prove the forward rule with two walks so you do not ship a swallowed chain.
Write three handlers: Console (DEBUG+), File (INFO+), Pager (ERROR+). Log ERROR: each writes and forwards until the tail. Log DEBUG: console writes; file skips write but still forwards; pager skips. If File returns early without next, ERROR dies when order swaps. Forwarding is not optional.
Tests: FakeAppender records events. Logger.log(ERROR, "disk") with a frozen Clock. Assert console, file, and pager each saw one event. Logger.log(DEBUG, "trace"): only console. A SlackHandler is a fourth FakeAppender in production behind the same interface.
Builder
Logger.builder().toConsole(DEBUG).toFile(INFO, path).toPager(ERROR).formatJson().build() allocates handlers and setNext; Logger still only has log().
Decorator alternative
A FormatterDecorator around an Appender is honest for wrapping format. Ordered sinks with independent level filters still fit Chain of Responsibility better.
Tests that prove the seams
@Test
void errorReachesEverySink() {
FakeAppender console = new FakeAppender();
FakeAppender file = new FakeAppender();
FakeAppender pager = new FakeAppender();
Logger log = Logger.builder()
.clock(Clock.fixed(T0, UTC))
.handler(new LevelFilterHandler(DEBUG, console))
.then(new LevelFilterHandler(INFO, file))
.then(new LevelFilterHandler(ERROR, pager))
.build();
log.log(ERROR, "disk");
assertEquals(1, console.size());
assertEquals(1, file.size());
assertEquals(1, pager.size());
}Wrap-up
You have the story, the API, and the seam tests. Use these reminders and links before you open Logger.java in the interview.
Say this before Logger.java exists
The product is a chain, not log4j. You will not parse XML config. You will show that ERROR reaches a pager without Logger knowing the pager’s class name. Clock stamps the event. Tests never wait for a real file lock. If they ask async, put a queue in front of head and say what happens when it is full after sync FakeAppender is green.
- Level is an ordered enum, never a string compare for filters.
- Forward after a skipped write; filtering is not the same as stopping.
- Clock stamps the event so tests freeze time without Thread.sleep.
- Builder wires handlers; Logger stays one method closed for new sinks.
Forty-five minutes
- 0–8Draw the chain: console, file, pager. Write forward-always on the board.
- 8–18LogEvent plus Logger.log plus FakeAppender; ERROR reaches three sinks.
- 18–28DEBUG reaches only console; file still forwards without writing.
- 28–38Builder wires setNext; JSON Formatter strategy if time remains.
- 38–45Mention async queue. Do not implement a disruptor in this round.
Read next
Key takeaway
Slack is a handler in the chain, not a new branch inside Logger. Prove it with FakeAppender and a forward-always link. Async delivery waits until those two tests are boring and green. Formatter is Strategy; destination is the handler; Logger is the controller.