Logger/LogLevel/Appender/Formatter classes, Chain of Responsibility for level filtering across multiple appenders, and thread safety for concurrent writes.
Published September 23, 2026
enum LogLevel { DEBUG(0), INFO(1), WARN(2), ERROR(3); final int severity; LogLevel(int s) { severity = s; } }
interface Appender { void write(LogLevel level, String message); LogLevel getMinLevel(); }
interface Formatter { String format(LogLevel level, String message, Instant timestamp); }
class ConsoleAppender implements Appender {
private final LogLevel minLevel;
private final Formatter formatter;
public void write(LogLevel level, String message) { System.out.println(formatter.format(level, message, Instant.now())); }
public LogLevel getMinLevel() { return minLevel; }
}
class FileAppender implements Appender { /* similar, writes to a file */ }
class NetworkAppender implements Appender { /* similar, ships to a log aggregator */ }
class Logger {
private final List<Appender> appenders;
void log(LogLevel level, String message) {
for (Appender appender : appenders) {
if (level.severity >= appender.getMinLevel().severity) { // each appender independently decides to handle or pass
appender.write(level, message);
}
}
}
}
Each Appender independently decides whether a given log call meets its own minimum severity — a ConsoleAppender configured for INFO and a FileAppender configured for ERROR can both be registered on the same Logger, each filtering independently. This is Chain of Responsibility's core idea (see Chain of Responsibility) applied without an explicit "pass to next" call — every appender in the list gets a chance to handle the same log event, rather than one handler claiming it exclusively, which fits logging's actual requirement (multiple destinations, not a single winner).
class FileAppender implements Appender {
private final BufferedWriter writer; // NOT thread-safe by default
public synchronized void write(LogLevel level, String message) { // synchronize the actual I/O
try { writer.write(formatter.format(level, message, Instant.now())); writer.newLine(); }
catch (IOException e) { /* handle */ }
}
}
Multiple application threads logging concurrently to the same file (or console) need the actual write operation synchronized — without it, two threads' output can interleave mid-line, producing garbled, unreadable log output (not a crash, just corrupted data). A synchronized method on the appender is the simplest correct fix; a production logging framework typically also buffers writes and flushes asynchronously on a dedicated thread, to avoid every application thread blocking on I/O directly for every single log call.
Q: Why does each Appender hold its own minLevel rather than the Logger filtering once globally? A: Different destinations often need different verbosity — you might want DEBUG-level detail in a local file for troubleshooting but only ERROR-level alerts sent over the network, which requires per-destination filtering, not one global level applied uniformly everywhere.
Q: How would you avoid the console/file write blocking the calling thread on every log call? A: Route log messages through an internal queue (a BlockingQueue, see Concurrent Utilities & Coordination) that a dedicated background thread drains and writes — the calling application thread just enqueues and returns immediately, decoupling application throughput from I/O latency, at the cost of a small risk of losing buffered-but-unflushed messages on a crash.
Q: Is synchronized the right choice long-term, or would a lock-free approach be better under high log volume? A: For genuinely high-throughput logging, a lock-free ring buffer (the approach libraries like Log4j2's async appender use) avoids the contention a single synchronized method creates under many concurrent logging threads — worth naming as the production-grade evolution of this design, while synchronized remains a correct, simple starting point.
Q: How does this design relate to Distributed Tracing's correlation ID propagation? A: A production Logger implementation typically includes the current correlation/trace ID (from thread-local or reactive context, see Distributed Tracing) automatically in every formatted log line — extending this design's Formatter to pull that context in is the natural connection point between structured logging and distributed tracing.