If you remember five things
The whole answer in one glance. Everything below is the defense of these five calls.
- Split whether, where, how. Logger decides whether to log, Appender decides where it goes, Formatter decides how it looks. Three reasons to change, three classes.
- Cheap reject on the hot path. A log below threshold costs one integer compare and zero allocation. That is the line between a logger you keep and one someone rips out at 2 a.m.
- Appender is the open seam. New destination means a new subclass, never an edit to Logger. Open/closed carries the whole extensibility story.
- AsyncAppender is a decorator. It wraps any Appender and moves the write off the caller's thread. Same interface in, same interface out, a queue plus a drain thread in the middle.
- Name the drop-versus-block tradeoff. A full async queue either drops logs or blocks the caller. Say which you picked and when you would flip it.
1Clarify the requirements#
Pin down what "logging framework" means before drawing a box. The word covers everything from a println wrapper to a distributed pipeline with Kafka and Elasticsearch behind it. Guess wrong and I design the wrong thing, so I settle it in under two minutes.
- Library or service? "Is this a library that applications embed, like Log4j or SLF4J, or a logging service that other systems send events to over the network?" Usually the in-process library, and I assume that unless told otherwise.
- Levels and filtering? "Do we filter by level?" Almost always yes. This is the first-class feature, not a nice-to-have.
- How many destinations? "Console, file, maybe network to a collector?" Whether I need more than one destination shapes the whole class hierarchy.
- Multi-threaded callers? The answer is always yes, so I design for concurrency from line one instead of bolting it on later.
- Formatting? "Timestamps, thread names, logger names, maybe JSON?" This decides whether I need a separate formatting layer or can inline a toString.
- Performance sensitive? "Can logging block the caller's thread?" This is the interesting one. It is what pushes a good answer toward async writes and a cheap reject path.
Functional requirements:
- Log a message at a level: TRACE, DEBUG, INFO, WARN, ERROR, FATAL.
- Filter out messages below a configured threshold. At threshold INFO, DEBUG and TRACE drop cheaply, before any work happens.
- Send each log to one or more destinations (console, file, network).
- Format the output in a configurable way (plain text, JSON, custom pattern).
- Let an application configure all of this without changing a line of calling code.
Non-functional requirements:
- Thread-safe. Many threads log concurrently into the same logger and the same destination.
- Fast. A dropped log costs almost nothing: one comparison, zero allocation. A real log should not stall the caller.
- Extensible. A new destination or format should not touch the core Logger class.
- No message loss on a normal shutdown. In-flight logs get flushed before the process exits.
"Below threshold should cost almost nothing" is a real design constraint, not filler. A logger sitting in a loop that runs a million times a second lives or dies on that path. It comes back in the code and in the edge cases, so lock it in now.
2Identify the core entities#
I think in responsibilities, and each distinct responsibility becomes a class. The test: if two things change for different reasons, they are different classes.
- LogLevel. An enum with an ordering. The ordering is the whole point: ERROR outranks WARN outranks INFO, which lets me compare a message level against a threshold with one integer compare.
- LogMessage (or LogEvent). The immutable record of one log call: level, text, timestamp, thread name, logger name. Immutable on purpose, because it is going to cross a thread boundary into a background writer, and shared mutable state across threads is where bugs breed.
- Logger. What the application holds and calls, like
logger.info("user logged in"). It owns a level threshold and a list of appenders. Its one decision is whether to log. - Appender. The destination. One appender knows how to write a formatted line somewhere: ConsoleAppender, FileAppender, and so on. This is the extension point, the seam I keep open.
- Formatter (or Layout). Turns a LogMessage into a String. Pluggable, so I can switch between a human pattern and JSON without touching the appender.
- LoggerFactory. Hands out and caches Logger instances by name. Applications never
newa Logger themselves, which gives me one shared instance per name and one place to wire config. - LogManager / configuration. Wires default level, default appenders, and default formatter together.
I am listening for the three-way split: whether, where, how. The Logger decides whether to log, the Appender decides where it goes, the Formatter decides how it looks. Three reasons to change, three classes. Mix any two into one god-logger and I have quietly marked you junior before you write a single method.
3Class design#
The relationships matter as much as the classes, so I am explicit about them.
- A Logger holds many Appenders (aggregation, since the same appender is shared across loggers).
- Each Appender holds one Formatter (composition, since the formatter lives and dies with the appender).
- ConsoleAppender and FileAppender both extend an abstract Appender.
- The LoggerFactory creates and caches Loggers.
- Every
Logger.logcall produces one LogMessage.
AsyncAppender *-- Appender edge). That decorator shape is how async behavior appears without the Logger or the FileAppender knowing anything about threads or queues. Same interface in, same interface out, extra behavior in the middle.4Key implementation#
I will show the parts that carry the design and skip getters and obvious constructors so the signal stands out.
LogLevel with the ordering baked in. The integer rank is what makes the hot-path compare a single machine instruction:
public enum LogLevel {
TRACE(0), DEBUG(1), INFO(2), WARN(3), ERROR(4), FATAL(5);
private final int rank;
LogLevel(int rank) { this.rank = rank; }
public boolean isAtLeast(LogLevel other) {
return this.rank >= other.rank;
}
}
LogMessage, immutable, captured at the call site:
public final class LogMessage {
private final LogLevel level;
private final String text;
private final long timestamp;
private final String threadName;
private final String loggerName;
public LogMessage(LogLevel level, String text, String loggerName) {
this.level = level;
this.text = text;
this.loggerName = loggerName;
this.timestamp = System.currentTimeMillis();
this.threadName = Thread.currentThread().getName();
}
// getters
}
Grabbing the thread name and timestamp in the constructor is deliberate, not lazy:
- If I defer the write to a background thread, I have to capture the caller's context now, before the message leaves the calling thread.
- Capture it later and every line reads "log-async" as the thread name while the timestamp drifts to whenever the worker got around to it.
Logger, where the cheap reject lives:
public class Logger {
private final String name;
private volatile LogLevel threshold;
private final List<Appender> appenders;
public Logger(String name, LogLevel threshold, List<Appender> appenders) {
this.name = name;
this.threshold = threshold;
this.appenders = appenders;
}
public void log(LogLevel level, String text) {
if (!level.isAtLeast(threshold)) {
return; // the fast path: one int compare, no allocation
}
LogMessage message = new LogMessage(level, text, name);
for (Appender appender : appenders) {
appender.append(message);
}
}
public void info(String text) { log(LogLevel.INFO, text); }
public void warn(String text) { log(LogLevel.WARN, text); }
public void error(String text) { log(LogLevel.ERROR, text); }
public void setLevel(LogLevel level) { this.threshold = level; }
}
Two things to point at here:
- A filtered-out log never even allocates a LogMessage. That is the "below threshold should cost almost nothing" requirement honored in code, not promised in a slide.
- The
volatileon threshold means a config change pushed from another thread becomes visible to every logging thread immediately, with no lock sitting on the hot path.
Appender and a concrete one:
public abstract class Appender {
protected final Formatter formatter;
protected Appender(Formatter formatter) {
this.formatter = formatter;
}
public abstract void append(LogMessage message);
public void close() { }
}
public class ConsoleAppender extends Appender {
public ConsoleAppender(Formatter formatter) { super(formatter); }
@Override
public synchronized void append(LogMessage message) {
System.out.println(formatter.format(message));
}
}
The synchronized on append keeps two threads from interleaving half-lines on the console. It is cheap and correct for stdout, and I will call out below exactly when it stops being the right tool.
Reveal the answer
AsyncAppender, the decorator that takes logging off the caller's thread:
public class AsyncAppender extends Appender {
private final Appender delegate;
private final BlockingQueue<LogMessage> queue;
private final Thread worker;
private volatile boolean running = true;
public AsyncAppender(Appender delegate, int capacity) {
super(null);
this.delegate = delegate;
this.queue = new ArrayBlockingQueue<>(capacity);
this.worker = new Thread(this::drain, "log-async");
this.worker.setDaemon(true);
this.worker.start();
}
@Override
public void append(LogMessage message) {
// offer, not put: never block the application thread
queue.offer(message);
}
private void drain() {
while (running || !queue.isEmpty()) {
try {
LogMessage m = queue.poll(200, TimeUnit.MILLISECONDS);
if (m != null) delegate.append(m);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
@Override
public void close() {
running = false;
try { worker.join(2000); } catch (InterruptedException ignored) { }
delegate.close();
}
}
Formatter, pluggable. One interface, one concrete pattern, room for a JSON one later:
public interface Formatter {
String format(LogMessage message);
}
public class PatternFormatter implements Formatter {
@Override
public String format(LogMessage m) {
return String.format("%tF %<tT [%s] %-5s %s - %s",
m.getTimestamp(), m.getThreadName(), m.getLevel(),
m.getLoggerName(), m.getText());
}
}
LoggerFactory, so loggers are shared, not duplicated. ConcurrentHashMap.computeIfAbsent gives thread-safe one-logger-per-name without a global lock:
public class LoggerFactory {
private static final Map<String, Logger> cache = new ConcurrentHashMap<>();
private static LogLevel defaultLevel = LogLevel.INFO;
private static List<Appender> defaultAppenders =
List.of(new ConsoleAppender(new PatternFormatter()));
public static Logger getLogger(String name) {
return cache.computeIfAbsent(name,
n -> new Logger(n, defaultLevel, defaultAppenders));
}
}
Application code stays boring, which is exactly the goal. Two lines, no wiring, no idea there is a queue and a background thread underneath:
Logger log = LoggerFactory.getLogger("PaymentService");
log.info("charge succeeded for order 4821");
5Design decisions and patterns used#
I did not reach for patterns to sound clever. Each one solved a concrete problem in this exact design, and I can defend why.
- Strategy. Formatter is a strategy. The Appender delegates "how do I turn this message into text" to an interchangeable object. Swap PatternFormatter for a JsonFormatter and nothing else moves.
- Decorator. AsyncAppender wraps any Appender and adds behavior (an in-memory buffer, a background drain thread) while keeping the same append interface. I can wrap a FileAppender or a future network appender identically, and the caller cannot tell the difference.
- Factory. LoggerFactory hides construction and gives caching plus shared config. Callers get a Logger by name and never worry how it was wired.
- Singleton-ish manager. The factory's static cache is effectively a single source of truth for logger instances: one logger per name, reused across the process.
- Composite (lightly). A Logger holding a list of appenders and fanning one message out to all of them is the composite idea without a formal tree. One call in, many destinations out.
New destination? New Appender. New format? New Formatter. The Logger class never changes.
The open/closed principle is doing the heavy lifting. When the interviewer says "now add Kafka output," I write one class, plug it into the factory, and I am done. That moment is the design proving itself.
6Handling edge cases and concurrency#
This is where the interview is won or lost, so I slow down and say each one out loud.
- The cheap reject. I keep mentioning it because a senior interviewer checks it first. A DEBUG log below the INFO threshold costs one integer compare and a return: no LogMessage, no string building, no appender loop. In a path that runs a million times a second, this is the difference between a logger you leave in production and one someone rips out at 2 a.m. because it is eating CPU.
- Thread safety on the shared appender. Many threads call the same ConsoleAppender. Without coordination, two println outputs interleave into two half-lines. I synchronize append. For a FileAppender I would do the same around the write, or better, hand the file to an AsyncAppender so exactly one thread touches the file descriptor and the contention disappears instead of being locked through.
- The async queue is a tradeoff, not a free lunch. Moving writes off the caller keeps it fast but adds two hazards. A bounded queue can fill under load. I chose
offeroverput, so under extreme pressure I drop logs rather than freeze the application. That is a deliberate policy: an app should never seize up because its log sink fell behind. If the interviewer prefers back-pressure, I switch toputand accept that logging can now block the caller. Both are valid. The mark of seniority is saying which one I picked and when I would flip it. - Capturing context before the thread boundary. The timestamp and thread name are taken in the LogMessage constructor, on the caller's thread. Grab them in the background worker instead and every line claims it came from "log-async" while timestamps slide to drain time. Easy to get wrong, embarrassing to miss, so I point to the constructor and say why it lives there.
- Shutdown without losing logs. An async appender holds unflushed messages in its queue. If the JVM just dies, those are gone. My
close()flips running to false and lets the worker drain the rest before joining with a 2-second timeout. In production I register a JVM shutdown hook that calls close on every appender, so a normal exit flushes cleanly. - Config changes at runtime. Flipping a level from INFO to DEBUG while chasing a live incident is a common move. The volatile threshold makes that visible to all logging threads immediately, with no restart and no lock on the hot path. Cheap to change, instant to take effect.
Claiming the async queue "just makes logging fast" and never naming its failure mode. A bounded queue under load either drops or blocks, full stop. If you cannot say which policy you chose, and when you would flip it, I read that as never having run a logger that fell behind in production.
7Extensions and follow-ups#
Interviewers push past the base design to see how far it bends. Each answer leans on a seam I already built.
- Hierarchical loggers. Real frameworks let "com.app.payment" inherit level and appenders from "com.app". I add parent pointers in the factory and walk up the dotted name until I find a configured level. Named loggers with dotted names make this fall out naturally.
- Deferred message construction.
log.debug("state: " + expensiveToString())builds that string even when DEBUG is off, because Java evaluates the argument before the call. I add aSupplier<String>overload:log.debug(() -> "state: " + expensive()). The lambda runs only if the level passes, so the expensive work is skipped on the reject path. - Rolling file appender. Rotate by size or by day. A RollingFileAppender subclass, zero changes elsewhere, the design holding up under pressure.
- Rate limiting and dedup. Collapse "same error 10,000 times" into "same error (x10000)". A decorator appender, the same trick as AsyncAppender, wrapping the real destination.
- Structured logging. A JsonFormatter emitting key-value pairs that aggregators like Elasticsearch can index. Just a new Strategy, no other change.
- Multiple frameworks under one facade. For SLF4J-style decoupling, an API module that applications compile against and a swappable backend bound at runtime. The LoggerFactory becomes the seam between the two.
"The async queue is full. What happens to the next log?"
"Why capture the timestamp and thread name in the constructor instead of at write time?"
"How do you avoid losing in-flight logs on shutdown?"
"Add a network destination that sometimes fails. Where does that logic go?"
"DEBUG is off but `log.debug("x" + costly())` still runs costly(). Fix?"
8What the interviewer is really checking#
They already know you can call System.out.println. Here is what they are actually watching for:
- Did you separate whether, where, and how into different classes? One god-logger is the junior move and it shows instantly.
- Is the destination an open extension point, not a switch on a destination enum? Adding Kafka should be a new class, not an edit to the core. That is the open/closed test in one question.
- Did you treat concurrency as first-class from the start, not a bolt-on? The capture-context-on-the-caller-thread detail signals you have actually debugged a threaded logger.
- Did you reason about the cost of a dropped log? Reasoning about the thing that runs a million times a second, not just correctness, is what separates staff from mid-level.
- Did you name your tradeoffs out loud? Drop-versus-block on a full queue is the clearest. "I chose to drop, and here is when I would flip to block" beats pretending the queue has no failure mode.
What you can now explain in the room
- Why logging splits cleanly into whether (Logger), where (Appender), and how (Formatter).
- Why the cheap reject path must avoid even allocating a LogMessage, and how the integer rank makes it one compare.
- Why AsyncAppender is a decorator, and how it takes writes off the caller's thread without the rest of the system knowing.
- Why context (timestamp, thread name) must be captured before the message crosses the thread boundary.
- The drop-versus-block tradeoff on a full async queue, and when you would pick each.
- How open/closed lets you add Kafka, JSON, or rolling files as one new class with zero core edits.
If you liked the decorator and strategy work here, try Design a Rate Limiter and Design a Notification Service next. Both lean on the same pluggable-backend thinking.
Get a prep plan built around your Design a Logging Framework interview
Tell us the role and your interview date. We will send back a dated, day by day plan. Free.