6. Logging Framework Intermediate (Pattern Showcase)

A. Realistic Interview Prompt

"Design a logging framework. It should support multiple severity levels, multiple output destinations (console, file, network), configurable formatting, and must not become a bottleneck for the application threads that call it."

B. Difficulty

Intermediate, but unusually dense with legitimate design patterns for a problem this small — often used specifically as a "how many patterns can you correctly justify, and which ones do you correctly avoid" showcase rather than a test of any single hard algorithm.

C. What This Problem Tests

D. Requirements

Functional

  1. Log messages at a severity level (DEBUG, INFO, WARN, ERROR, FATAL)
  2. Filter by a configurable minimum level, globally or per-logger
  3. Write to multiple destinations simultaneously (console, file, network)
  4. Support pluggable message formatting (plain text, JSON)
  5. Allow configuration (level, destinations, format) without restarting the application

Non-Functional

E. Clarifying Questions

#QuestionWhy It MattersDesign Decision Affected
1Do we need per-logger (per-class/module) level thresholds, or one global level?Determines whether Logger is keyed by name with its own configLoggerFactory registry shape
2Should logging be synchronous or asynchronous by default?Drives the biggest architectural decision in this problemWhether an async decorator wraps appenders by default
3Can multiple appenders be attached to one logger at once?Determines whether appenders form a list or a single referenceLogger holds a list, not one appender
4Is log rotation (size/time-based file rollover) required?Adds real complexity to FileAppender specificallyFileAppender scope
5What happens if a destination is slow or down — block, drop, or buffer?This is the crux of the concurrency sectionAsync queue policy (block/drop-oldest/drop-newest)
6Do different appenders need independent minimum levels (e.g. console shows INFO+, file captures DEBUG+)?Determines whether filtering happens once at the logger or again per-appenderAppender-level threshold field
7Is structured/contextual logging (e.g. request-ID, user-ID tags) needed?Affects whether LogRecord needs an extensible context mapLogRecord shape

F. Assumptions

G. Actors

ActorResponsibility
Application codeCalls logger.info(...)/error(...) etc.
Operator/ConfigAdjusts levels and destinations at runtime
SystemFilters, formats, and dispatches records to destinations without blocking the caller unduly

H. Core Domain Entities

ClassResponsibilityKey AttributesKey MethodsDoes NOT Handle
LogLevel (enum)Ordered severityDEBUG<INFO<WARN<ERROR<FATAL—Formatting or routing
LogRecordImmutable snapshot of one log eventtimestamp, level, loggerName, message, threadName, context map, exception?—Deciding whether it should be written anywhere
LoggerPer-name entry point; owns a level threshold + a list of appendersname, level, appenderslog(level, msg), convenience info()/error() etc.Actual writing (delegates to appenders)
LogFormatter (interface)Turns a LogRecord into a string—format(record)Where that string goes
LogAppender (interface)Writes a formatted record somewhereown minLevel, a formatterappend(record)Deciding message content
AsyncAppenderDecoratorWraps any appender to move its I/O off the calling threadbounded queue, worker threadappend(record) (enqueues instead of writing directly)The actual write logic (delegates to the wrapped appender)
LoggerFactory / LogManager (Singleton)Process-wide registry of loggers by name + global configname→Logger mapgetLogger(name)Formatting or writing — purely a registry

I. Class Relationships

J. Class Diagram

creates/registers

wraps

«enumeration»

LogLevel

DEBUG

INFO

WARN

ERROR

FATAL

LogRecord

timestamp

level

message

loggerName

context

Logger

name

level

+log(level,msg)

«interface»

LogFormatter

+format(record)

«interface»

LogAppender

+append(record)

AsyncAppenderDecorator

-wrapped: LogAppender

+append(record)

LoggerFactory

+getLogger(name)

ConsoleAppender

FileAppender

PlainTextFormatter

JsonFormatter

View Mermaid source
classDiagram
  class LogLevel {
    <<enumeration>>
    DEBUG
    INFO
    WARN
    ERROR
    FATAL
  }
  class LogRecord {
    timestamp
    level
    message
    loggerName
    context
  }
  class Logger {
    name
    level
    +log(level,msg)
  }
  class LogFormatter {
    <<interface>>
    +format(record)
  }
  class LogAppender {
    <<interface>>
    +append(record)
  }
  class AsyncAppenderDecorator {
    -wrapped: LogAppender
    +append(record)
  }
  class LoggerFactory {
    +getLogger(name)
  }
  LoggerFactory --> Logger : creates/registers
  Logger --> LogAppender
  Logger --> LogFormatter
  LogAppender <|.. ConsoleAppender
  LogAppender <|.. FileAppender
  LogAppender <|.. AsyncAppenderDecorator
  AsyncAppenderDecorator --> LogAppender : wraps
  LogFormatter <|.. PlainTextFormatter
  LogFormatter <|.. JsonFormatter

K. Responsibility Mapping

ClassResponsibilityDoes NOT Handle
LoggerLevel filtering + fan-out to appendersFormatting, writing, async-ing
LogFormatterRecord → stringWhere the string is sent
LogAppenderString → a destinationDeciding if this record should even be written (that's the level check, done before append is called)
AsyncAppenderDecoratorMoving I/O off the caller's threadWhat the I/O actually is (delegates)
LoggerFactoryOne registry, one place configuration is read fromFiltering, formatting, writing

L. SOLID Analysis

PrincipleHow It's AppliedNaive Violation
SRPFiltering (Logger), formatting (Formatter), writing (Appender), and async-ing (Decorator) are four separate concernsOne Logger class with an if (async) { ... } else { ... } branch and inline string-building for every destination
OCPNew destination = new LogAppender; new format = new LogFormatter; zero changes to Loggerif (dest == "file") ... else if (dest == "console") ... inline in Logger.log()
LSPEvery LogAppender, including the async decorator, honors the same append(record) contract and can be substituted anywhere a plain appender is expectedAn async appender whose append() sometimes returns before enqueuing and sometimes blocks — inconsistent enough callers can't reason about it
ISPOne-method LogAppender and LogFormatter interfacesA fat "OutputHandler" interface also demanding rotate(), compress(), authenticate()
DIPLogger depends on the LogAppender/LogFormatter interfaces, injected via the factory/configLogger directly calling System.out.println and file APIs inline

M. Design Patterns

PatternProblem It SolvesWhere It AppearsWhen NOT to Use
SingletonProcess-wide, name-keyed access to loggers without threading a reference through every function signature in the codebaseLoggerFactory/LogManagerN/A here — see the explicit justification below
StrategyFormatting genuinely varies by output shape (text vs. JSON)LogFormatter hierarchySkip if only one format will ever exist
DecoratorAsync-ifying I/O is a cross-cutting wrapper over any appender, not an alternative appenderAsyncAppenderDecoratorSkip if everything can safely stay synchronous (e.g. console-only tooling)
Chain of Responsibility (thin fit)Each appender independently re-checks its own minimum level before acting, after the logger's own check already passedLogger-level check → per-appender level check, each an independent linkDon't force a full CoR class hierarchy for this — a simple per-appender if is honest and sufficient; presenting it as "CoR" is fine conceptually but doesn't need heavyweight handler-chaining machinery
Builder (optional)LogRecord has several optional fields (exception, context tags, thread name)LogRecord.BuilderSkip if the record's fields are few and mostly required — a constructor is fine then

Why Singleton is actually correct here, unlike Problems 1 and 5: real logging frameworks (Log4j, java.util.logging, Python's logging module) all use a global, name-keyed registry, and for good reason — logging is called from every layer of a codebase, and threading a Logger instance through every constructor via DI would be pure ceremony with no real benefit. Unlike ParkingLot or RideService, a Logger is a nearly stateless dispatcher: tests don't need two independent parking lots or ride services, but they very much benefit from one global logging entry point they can point at a test appender. The risk Singleton usually carries — hidden coupling that makes unit testing hard — is mitigated by swapping appenders in tests, not the registry itself.

N. What Is Likely to Change?

BehaviorCurrent DesignAnticipated ChangeProtecting Abstraction
Output destinationsConsole, File, NetworkCloud log aggregators (e.g. a hosted logging service)LogAppender interface
FormatPlain text, JSONStructured binary (protobuf) for high-volume pipelinesLogFormatter interface
Delivery guaranteeBest-effort async, may drop under overloadGuaranteed delivery via a durable local buffer that survives a crashAsyncAppenderDecorator's queue implementation, swappable
Filtering granularityPer-logger, per-appender levelPer-context-tag filtering (e.g. only this request ID at DEBUG)LogRecord.context as an extensible map from day one

O. Design Evolution

What stays stable: Logger.log()/convenience method signatures. New destinations, formats, and delivery guarantees are all additive via new implementations, not edits to Logger.

P. API / Interface Design

interface LogFormatter { String format(LogRecord record); }
interface LogAppender  { void append(LogRecord record); }

class Logger {
  void log(LogLevel level, String message, Map<String,Object> context);
  void debug(String msg); void info(String msg); void warn(String msg);
  void error(String msg, Throwable t); void fatal(String msg, Throwable t);
  void setLevel(LogLevel level);
  void addAppender(LogAppender appender);
}

class LoggerFactory {
  static Logger getLogger(String name);   // process-wide registry
}

Ownership/validation notes: log() checks the logger's own level threshold before doing any formatting work — cheap early exit for filtered-out records is the whole point of the level check existing before formatting, not after.

Q. Pseudocode — Critical Logic

Logger.log(level, message, context):
  if level < this.minLevel: return                 // cheap early exit, no formatting done
  record = LogRecord.create(level, message, context, now(), currentThread())
  for appender in this.appenders:
    if level >= appender.minLevel:                 // per-appender re-check
      appender.append(record)

AsyncAppenderDecorator.append(record):
  if !queue.offer(record):                         // non-blocking enqueue attempt
    applyOverflowPolicy(record)                     // e.g. drop-oldest, drop-this, or block briefly
    return
  // background worker thread, separate from callers:
  loop:
    record = queue.take()
    formatted = formatter.format(record)
    wrapped.writeRaw(formatted)                     // the actual, possibly slow, I/O

R. End-to-End Use Case

Application calls logger.error("payment failed", ex)
  │
  ▼
Logger.log(ERROR, ...) → passes level check
  │
  ▼
For each appender: ConsoleAppender (sync) and FileAppender wrapped in AsyncAppenderDecorator
  │                         │
  ▼                         ▼
writes to stdout      queue.offer(record) → returns immediately to caller
                              │
                    (background worker thread)
                              ▼
                    JsonFormatter.format(record) → FileAppender.writeRaw(json)

S. Data Structures

StructureUsed ForWhyAlternative & Trade-off
Bounded blocking queue (or ring buffer)AsyncAppenderDecorator's internal bufferDecouples producer (app thread) speed from consumer (I/O) speed with a bounded memory footprintA lock-free ring buffer (LMAX Disruptor-style) gives higher throughput under heavy contention at the cost of real implementation complexity — worth naming, rarely worth building live
Map<String, Logger>LoggerFactory registryO(1) lookup by name, matches every real-world logging framework's patternA hierarchical name tree (like Log4j's dotted-package hierarchy with level inheritance) is more powerful but unnecessary unless explicitly asked for

T. Complexity Analysis

OperationTimeSpace
Logger.log (filtered out)O(1)—
Logger.log (passes filter, N appenders)O(N) dispatch, formatting cost is separate per appenderO(1) per record before queuing
AsyncAppenderDecorator.appendO(1) amortized (queue offer)O(queue capacity)

U. Testing Strategy

V. Interviewer Follow-Ups

Follow-UpTestingDesign ChangeClasses ChangedPattern/Trade-off
"Logging is slowing down our hot path."Recognizing the sync/async trade-off unpromptedWrap the offending appender in AsyncAppenderDecoratorNone beyond composition — no changes to LoggerValidates Decorator's whole reason for existing
"We're losing log messages under heavy load."Naming a concrete overflow policy instead of hand-wavingExplicit bounded-queue policy: block-briefly / drop-oldest / drop-newest, each named with a trade-offAsyncAppenderDecoratorAn unbounded queue "fixes" this by risking OOM instead — call that out as the wrong answer
"Add log rotation by size and by day."Extensibility within a single appender, not a redesignFileAppender gains a rotation policy checked before each writeFileAppender onlyContained change — doesn't ripple into Logger or other appenders
"Why is LoggerFactory a Singleton but ParkingLot wasn't?"Judgment, not pattern-matching by roteN/A — this is a discussion questionN/ASee M's explicit justification; a strong candidate can articulate the difference unprompted
"Support per-request correlation IDs across log lines."Context propagation without a global mutable variableLogRecord.context map populated from a thread-local or explicit parameter at call sitesLogRecord, call-site disciplineThread-locals are convenient but leak across thread-pool reuse if not cleared — worth flagging

W. Concurrency

Shared mutable state: a Logger's appender list (if reconfigured at runtime) and each async appender's internal queue.

X. Persistence

Logs are themselves usually the "persistence layer" for other systems' auditing, but the logging framework's own concern is durability-vs-latency for its own queue: an in-memory bounded queue is fast but loses buffered-not-yet-written records on a crash; a queue backed by a small local disk-based buffer survives a crash but costs latency on every enqueue. Naming this trade-off explicitly, and picking in-memory-with-a-stated-loss-window as the default (since logging is rarely the system of record for financial-grade durability), reads stronger than assuming one is obviously correct.

Y. Scaling Boundary

LayerWhat Belongs Here
LLDLogger/Appender/Formatter classes, the async decorator, the level-filtering logic, the registry
HLDA log shipping/aggregation pipeline (e.g. Fluentd/Logstash-style forwarders), centralized log storage and indexing (e.g. an ELK-style stack)
Distributed SystemsCross-service correlation IDs, sampling strategies under extreme volume, and log-pipeline backpressure across many producing services

Z. Failure Scenarios

AA. Common Bad Designs

AB. Design Trade-Offs

DecisionOption AOption BTrade-off
Sync vs. async appendersSynchronous, simple, in-orderAsync via Decorator + queueSync is simplest and never loses ordering guarantees; async protects app latency but adds an overflow policy to reason about
Queue overflow policyDrop-oldest/newestBlock brieflyDropping preserves app latency but loses data; blocking preserves data but reintroduces the latency async was meant to avoid
Registry patternSingleton LoggerFactoryFull dependency injection everywhereSingleton matches how every real framework does it and avoids ceremony; DI is more "pure" but impractical at the call-site density logging requires

AC. Interviewer Evaluation

AD. Interview Simulation

Interviewer: Design a logging framework.
Candidate: Quick scoping question — should logging ever block the calling thread, say if a network destination is slow?
Interviewer: Good question. It should not block meaningfully.
Candidate: Then I'll separate "what gets written" (a LogFormatter strategy) from "where it goes" (a LogAppender interface), and wrap any I/O-bound appender in an AsyncAppenderDecorator that enqueues onto a bounded queue and lets a background thread do the actual write.
Interviewer: What happens if that queue fills up?
Candidate: That needs an explicit policy — drop the oldest record, drop the new one, or block briefly. An unbounded queue would "solve" it by risking an out-of-memory crash instead, which is worse than dropping a few log lines.
Interviewer: You used a Singleton for the logger registry — didn't you argue against Singletons in the parking lot problem?
Candidate: Intentionally, yes — the difference is call-site density and statefulness. A parking lot has real state you want isolated and testable per-instance; a logger registry is called from every layer of the codebase, is nearly stateless, and every production logging framework I know of uses exactly this pattern. Singleton isn't wrong by default — it's wrong when it hides meaningful state that testing needs to control.
Interviewer: Add JSON-formatted output alongside plain text.
Candidate: That's a new JsonFormatter implementing the existing LogFormatter interface — zero changes to Logger or any appender.

AE. Online Solution Comparison

SourceApproachStrengthWeaknessWorth Learning
ashishps1/awesome-low-level-design (GitHub)LogLevel enum, LogMessage, LogAppender interface (Console/File/Database), Logger as a Singleton, explicit thread-safety requirement statedCorrectly identifies Singleton as appropriate here — validated and expanded on in M aboveNo formatter abstraction (formatting appears baked into appenders); thread-safety is stated as a requirement but the specific mechanism isn't detailedThe Singleton-is-fine judgment call — one of the only reference solutions in this set where the default pattern choice is fully endorsed rather than critiqued
Real-world frameworks (Log4j / java.util.logging / Python logging) — general prior knowledge, not a single URLNamed-logger hierarchies, appender chains, level inheritance from parent loggersBattle-tested at massive scale; the async-appender pattern here mirrors Log4j's AsyncAppender directlyProduction frameworks add hierarchical level inheritance this document intentionally simplifies away for interview scopeThe registry-by-name pattern and per-appender level override, both adopted directly here

AF. Interview Cheat Sheet

ProblemLogging Framework
DifficultyIntermediate (pattern showcase)
Core requirementsLevels, multiple simultaneous destinations, pluggable formatting, non-blocking under load
Key clarificationsPer-logger levels? Sync or async by default? Multiple appenders at once? Rotation needed? Overflow behavior?
Core entitiesLogLevel, LogRecord, Logger, LogFormatter, LogAppender, AsyncAppenderDecorator, LoggerFactory
Key relationshipsLogger owns Appenders; AsyncAppenderDecorator wraps any Appender (not a Strategy); Formatter is a genuine Strategy
Key patternsSingleton (justified here), Strategy (formatter), Decorator (async), thin Chain-of-Responsibility (per-appender level re-check), optional Builder
Most important decisionDecorator for async, not a parallel "AsyncAppender" strategy — and correctly defending Singleton for the registry
Likely-changing behaviorNew destinations, new formats, delivery-guarantee strength, filtering granularity
Concurrency concernBounded queue + explicit overflow policy; single consumer thread per destination for write ordering
Top follow-upsHot-path latency, message loss under load, log rotation, Singleton justification, correlation-ID propagation
Common mistakesSynchronous slow appenders on the hot path; unbounded queues; formatting logic duplicated per appender; global mutable statics instead of a real registry
What NOT to over-engineerDon't build a lock-free ring buffer live — name it as the real-world high-throughput answer and move on

AG. Full Implementation

A complete, compilable reference implementation of every class in the diagram above (composition, inheritance, interface realization, and dependency-injected strategies all preserved per section I), with a runnable demo exercising the primary flow — in Java, C++, JavaScript, and TypeScript.

// Logging Framework — full LLD implementation with demo.
// Single-file Java translation of the Mermaid class diagram in section J.
//
// Note on concurrency: this problem's difficulty badge ("Intermediate — Pattern Showcase") does not
// call out concurrency, so no real locks/threads are used here. AsyncAppenderDecorator's queue-drain
// is exposed as an explicit flush() rather than a background java.lang.Thread, to keep the demo
// single-threaded and deterministic while still faithfully modeling the enqueue/overflow/drain logic.
import java.io.*;
import java.util.*;

public class p6 {

    // ---------- LogLevel ----------
    enum LogLevel { DEBUG, INFO, WARN, ERROR, FATAL } // ordinal() gives natural severity ordering

    // ---------- LogRecord ----------
    /** Immutable snapshot of one log event. Does not decide whether/where it gets written. */
    static final class LogRecord {
        final long timestamp;
        final LogLevel level;
        final String message;
        final String loggerName;
        final String threadName;
        final Map<String, Object> context;
        final String exceptionInfo; // nullable summary of an attached Throwable

        LogRecord(LogLevel level, String message, String loggerName, Map<String, Object> context, String exceptionInfo) {
            this.timestamp = System.currentTimeMillis();
            this.level = level; this.message = message; this.loggerName = loggerName;
            this.threadName = Thread.currentThread().getName();
            this.context = context == null ? Collections.emptyMap() : context;
            this.exceptionInfo = exceptionInfo;
        }
    }

    // ---------- LogFormatter (Strategy: mutually exclusive rendering choices) ----------
    interface LogFormatter { String format(LogRecord record); }

    static final class PlainTextFormatter implements LogFormatter {
        public String format(LogRecord r) {
            StringBuilder sb = new StringBuilder();
            sb.append('[').append(r.timestamp).append("] ").append(r.level).append(' ')
              .append('[').append(r.loggerName).append("] [").append(r.threadName).append("] ")
              .append(r.message);
            if (!r.context.isEmpty()) sb.append(' ').append(r.context);
            if (r.exceptionInfo != null) sb.append(" EXCEPTION: ").append(r.exceptionInfo);
            return sb.toString();
        }
    }

    static final class JsonFormatter implements LogFormatter {
        public String format(LogRecord r) {
            StringBuilder sb = new StringBuilder("{");
            sb.append("\"timestamp\":").append(r.timestamp).append(',');
            sb.append("\"level\":\"").append(r.level).append("\",");
            sb.append("\"logger\":\"").append(r.loggerName).append("\",");
            sb.append("\"thread\":\"").append(r.threadName).append("\",");
            sb.append("\"message\":\"").append(escape(r.message)).append("\",");
            sb.append("\"context\":{");
            boolean first = true;
            for (Map.Entry<String, Object> e : r.context.entrySet()) {
                if (!first) sb.append(',');
                sb.append('"').append(e.getKey()).append("\":\"").append(escape(String.valueOf(e.getValue()))).append('"');
                first = false;
            }
            sb.append('}');
            if (r.exceptionInfo != null) sb.append(",\"exception\":\"").append(escape(r.exceptionInfo)).append('"');
            sb.append('}');
            return sb.toString();
        }
        private String escape(String s) { return s.replace("\"", "\\\""); }
    }

    // ---------- LogAppender (Strategy-like injected destinations) ----------
    interface LogAppender {
        void append(LogRecord record);
        LogLevel getMinLevel();
    }

    static final class ConsoleAppender implements LogAppender {
        private final LogLevel minLevel;
        private final LogFormatter formatter; // dependency, not hardcoded
        ConsoleAppender(LogLevel minLevel, LogFormatter formatter) { this.minLevel = minLevel; this.formatter = formatter; }
        public void append(LogRecord record) { System.out.println(formatter.format(record)); }
        public LogLevel getMinLevel() { return minLevel; }
    }

    static final class FileAppender implements LogAppender {
        private final LogLevel minLevel;
        private final LogFormatter formatter;
        private final String path;
        FileAppender(LogLevel minLevel, LogFormatter formatter, String path) {
            this.minLevel = minLevel; this.formatter = formatter; this.path = path;
        }
        public void append(LogRecord record) {
            try (BufferedWriter w = new BufferedWriter(new FileWriter(path, true))) {
                w.write(formatter.format(record));
                w.newLine();
            } catch (IOException e) {
                System.err.println("FileAppender write failed: " + e.getMessage());
            }
        }
        public LogLevel getMinLevel() { return minLevel; }
    }

    /**
     * Decorator: wraps ANY appender to move its I/O off the calling thread. Applies to
     * console/file/network alike — a cross-cutting wrapper, not a rival implementation of "how to write".
     */
    static final class AsyncAppenderDecorator implements LogAppender {
        private final LogAppender wrapped;
        private final int capacity;
        private final Deque<LogRecord> queue = new ArrayDeque<>();

        AsyncAppenderDecorator(LogAppender wrapped, int capacity) { this.wrapped = wrapped; this.capacity = capacity; }

        public void append(LogRecord record) {
            if (queue.size() >= capacity) {
                queue.pollFirst(); // overflow policy: drop-oldest to keep the caller non-blocking
            }
            queue.offerLast(record); // non-blocking enqueue
        }

        /** Simulates the background worker thread draining the queue and doing the real (slow) I/O. */
        void flush() {
            LogRecord r;
            while ((r = queue.pollFirst()) != null) {
                wrapped.append(r); // the actual write logic is delegated to the wrapped appender
            }
        }

        public LogLevel getMinLevel() { return wrapped.getMinLevel(); }
    }

    // ---------- Logger ----------
    /** Per-name entry point: owns a level threshold + a list of appenders. */
    static final class Logger {
        private final String name;
        private LogLevel level;
        private final List<LogAppender> appenders = new ArrayList<>(); // composition

        Logger(String name, LogLevel level) { this.name = name; this.level = level; }

        void setLevel(LogLevel level) { this.level = level; }
        void addAppender(LogAppender appender) { appenders.add(appender); }

        void log(LogLevel level, String message, Map<String, Object> context, String exceptionInfo) {
            if (level.ordinal() < this.level.ordinal()) return; // cheap early exit, no formatting done
            LogRecord record = new LogRecord(level, message, name, context, exceptionInfo);
            for (LogAppender appender : appenders) {
                if (level.ordinal() >= appender.getMinLevel().ordinal()) { // per-appender re-check
                    appender.append(record);
                }
            }
        }

        void debug(String msg) { log(LogLevel.DEBUG, msg, null, null); }
        void info(String msg) { log(LogLevel.INFO, msg, null, null); }
        void warn(String msg) { log(LogLevel.WARN, msg, null, null); }
        void error(String msg, Throwable t) { log(LogLevel.ERROR, msg, null, describe(t)); }
        void fatal(String msg, Throwable t) { log(LogLevel.FATAL, msg, null, describe(t)); }
        private String describe(Throwable t) { return t == null ? null : t.getClass().getSimpleName() + ": " + t.getMessage(); }
    }

    // ---------- LoggerFactory (Singleton registry) ----------
    static final class LoggerFactory {
        private static final LoggerFactory INSTANCE = new LoggerFactory();
        private final Map<String, Logger> registry = new HashMap<>();
        private LogLevel globalDefaultLevel = LogLevel.INFO;
        private LoggerFactory() {}

        static Logger getLogger(String name) {
            return INSTANCE.registry.computeIfAbsent(name, n -> new Logger(n, INSTANCE.globalDefaultLevel));
        }

        static void setGlobalDefaultLevel(LogLevel level) { INSTANCE.globalDefaultLevel = level; }
    }

    // ---------- Demo ----------
    public static void main(String[] args) throws Exception {
        String logFile = "p6-demo.log";
        new File(logFile).delete(); // start clean

        Logger appLogger = LoggerFactory.getLogger("app.orders");
        appLogger.setLevel(LogLevel.DEBUG);
        appLogger.addAppender(new ConsoleAppender(LogLevel.INFO, new PlainTextFormatter())); // console: INFO+
        FileAppender fileAppender = new FileAppender(LogLevel.DEBUG, new JsonFormatter(), logFile); // file: DEBUG+ as JSON
        AsyncAppenderDecorator asyncFile = new AsyncAppenderDecorator(fileAppender, 100);
        appLogger.addAppender(asyncFile);

        appLogger.debug("Loading order pipeline"); // filtered out of console (INFO min) but queued for file
        appLogger.info("Order OR-1 received");
        try {
            throw new IllegalStateException("payment gateway timeout");
        } catch (Exception e) {
            appLogger.error("Order OR-1 failed", e);
        }
        appLogger.warn("Retrying order OR-1");
        appLogger.fatal("Out of memory processing OR-1", new OutOfMemoryError("heap exhausted"));

        System.out.println("--- flushing async file appender ---");
        asyncFile.flush(); // simulate the background worker draining the queue

        System.out.println("--- file contents (" + logFile + ") ---");
        try (BufferedReader r = new BufferedReader(new FileReader(logFile))) {
            String line;
            while ((line = r.readLine()) != null) System.out.println(line);
        }
        new File(logFile).delete(); // cleanup after demo
    }
}