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.
Writing to a file or network on the calling thread adds I/O latency to every request that logs. The standard fix is an async appender: the calling thread puts the event into a queue, and a background thread writes it out. The important design decisions are what happens when the queue fills up, and how to shut down without losing logs.
record LogEvent(Instant timestamp, LogLevel level, String loggerName, String thread,
String message, Throwable error, Map<String, String> context) {}
class AsyncAppender implements Appender, AutoCloseable {
private final Appender delegate;
private final BlockingQueue<LogEvent> queue;
private final Thread worker;
private final AtomicLong dropped = new AtomicLong();
private volatile boolean running = true;
AsyncAppender(Appender delegate, int capacity) {
this.delegate = delegate;
this.queue = new ArrayBlockingQueue<>(capacity); // BOUNDED: never grow without limit
this.worker = new Thread(this::drain, "async-log-writer");
this.worker.setDaemon(true);
this.worker.start();
}
@Override public void append(LogEvent e) {
if (e.level().severity >= LogLevel.WARN.severity) {
try { queue.put(e); } // never drop warnings or errors: wait briefly
catch (InterruptedException ie) { Thread.currentThread().interrupt(); }
} else if (!queue.offer(e)) {
dropped.incrementAndGet(); // shed DEBUG/INFO under pressure
}
}
private void drain() {
List<LogEvent> batch = new ArrayList<>(256);
while (running || !queue.isEmpty()) {
try {
LogEvent first = queue.poll(200, TimeUnit.MILLISECONDS);
if (first == null) continue;
batch.add(first);
queue.drainTo(batch, 255); // write in batches: far fewer syscalls
batch.forEach(delegate::append);
delegate.flush();
} catch (InterruptedException ie) {
running = false;
} catch (Exception writeFailure) {
System.err.println("log write failed: " + writeFailure); // never throw back into the app
} finally {
batch.clear();
}
long lost = dropped.getAndSet(0);
if (lost > 0) delegate.append(droppedNotice(lost)); // make the loss visible
}
}
@Override public void close() throws InterruptedException {
running = false;
worker.join(5_000); // flush what's queued at shutdown
delegate.flush();
}
}
The trade-offs to state explicitly:
OutOfMemoryError.AsyncAppender makes a similar choice with discardingThreshold.close() via the framework) flushes the queue, otherwise the last seconds of logs, often the most important ones, are lost on exit.Request IDs and user IDs should appear on every log line of a request without being passed to every method. A Mapped Diagnostic Context stores them per thread:
final class LogContext {
private static final ThreadLocal<Map<String, String>> CTX = ThreadLocal.withInitial(HashMap::new);
static void put(String k, String v) { CTX.get().put(k, v); }
static Map<String, String> snapshot() { return Map.copyOf(CTX.get()); } // copied into each LogEvent
static void clear() { CTX.remove(); } // essential on pooled threads
}
Logger copies LogContext.snapshot() into each LogEvent at the moment of logging, because the async writer runs on another thread, where the ThreadLocal is empty. A web filter sets requestId at the start of each request, and clears it in finally. Otherwise, a pooled thread leaks one request's ID into the next request's logs.
Emit logs as structured JSON ({"ts":…,"level":"ERROR","requestId":…,"msg":…}) rather than free text, so the log platform can filter by field instead of using regular expressions. A JsonFormatter implementing the existing Formatter interface makes this a configuration choice.
log.debug("Order state: " + order.toDetailedString()); // string built even when DEBUG is off
log.debug("Order state: {}", order); // formatted only if DEBUG is enabled
if (log.isDebugEnabled()) log.debug("Dump: {}", expensiveDump()); // guard genuinely expensive arguments
Check the level before formatting. Parameterized messages and level guards are why real logging APIs look the way they do.
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.
Q: What should an async logger do when its queue is full? A: Choose deliberately. Dropping low-severity events keeps the application fast, and blocking protects important events at the cost of latency. A common compromise: drop DEBUG and INFO, block briefly for WARN and ERROR, and count drops so they're visible.
Q: Why must MDC context be cleared at the end of each request? A: Server threads are pooled and reused. If the context isn't removed, the next request handled by that thread inherits the previous request's IDs, producing misleading logs and possibly leaking user data into the wrong entries.
Q: Why prefer log.debug("x={}", x) over string concatenation?
A: Concatenation builds the string (and calls toString()) even when DEBUG is disabled. Parameterized messages defer formatting until the level check passes, which saves CPU and garbage-collection pressure on hot paths.