Problem Statement
Every program needs a diary. When something goes wrong at 3 in the morning, the diary is the only witness. A logging framework is the code that writes that diary: it takes a message like "Card declined for order 1042", adds the time and a severity, and sends it to the right places, such as the screen, a file or a monitoring tool.
You may know Log4j, SLF4J or java.util.logging. The interview question is "design something like that". The point is not to copy those libraries. It is to show that you can split the job into small parts that can be swapped: who writes the message, where it goes, and how it looks.
Questions to Ask First
| Question | Assumption I make |
|---|---|
| Which levels? | DEBUG, INFO, WARN, ERROR, in that order of severity. |
| Where can logs go? | Console, a file and memory (for tests). Adding more should be easy. |
| Can one message go to many places? | Yes, with a different level filter at each place. |
| Can the format change? | Yes, per destination. |
| One config for the whole app? | Yes, one shared manager that hands out loggers. |
| Many threads? | Yes. Lines must not get mixed up. |
| Can logging slow the app down? | It should not. An async option is needed. |
Log hierarchy like com.shop.cart? | No. Flat logger names only. |
Requirements
- Log at four levels, and ignore anything below the configured level.
- A logger sends each event to every appender (destination) that is registered.
- Each appender has its own minimum level and its own formatter.
- Provided appenders: console, file, in memory.
- A manager gives out loggers by name and holds the shared appenders.
- The time can be controlled, so that output is the same every run.
- Thread safe: two threads logging at once never garble a line.
- A failing appender must not crash the application or stop the other appenders.
- Follow up: an async appender that moves the slow write to a background thread.
Core Classes
Twelve files with Main.java. Level and LogEvent are plain data, Logger is the front door, and the Appender interface is where the variety lives.
How the Design Works
Levels
Levels are an enum, listed from the least serious to the most serious.
public enum Level {
DEBUG,
INFO,
WARN,
ERROR;
public boolean isAtLeast(Level other) {
return compareTo(other) >= 0;
}
}An enum's compareTo uses the order in which constants are written, so "is this serious enough?" is one comparison. If somebody reorders the constants later, the filter silently breaks. I would leave a comment on the enum for that reason.
The event
When the app calls logger.info("Order placed"), the logger builds one object that holds everything about that moment.
public record LogEvent(Instant time, Level level, String loggerName, String threadName, String message) {
}Why an object and not just a string? Because different destinations want different things. The console wants a readable line, a file might want key=value, a monitoring tool might want JSON. If you build the string early, you cannot change it later. A frozen event can be formatted any number of ways.
The logger
The logger checks its own level first. Below the level, it does nothing at all, and that is cheap.
public void log(Level wanted, String message) {
if (!isEnabled(wanted)) {
return;
}
// built here, on the caller's thread, so the thread name and time are the caller's
LogEvent event = new LogEvent(clock.instant(), wanted, name, Thread.currentThread().getName(), message);
for (Appender appender : appenders) {
deliver(appender, event);
}
}
// one broken appender must not stop the others, or the application
private void deliver(Appender appender, LogEvent event) {
try {
appender.append(event);
} catch (RuntimeException e) {
System.err.println("Appender failed: " + e);
}
}Two details matter here. The thread name is read on the caller's thread. If you waited and read it later in a background thread, every line would say "async-appender". And a full disk in the file appender should not take down the checkout page, so exceptions from appenders are caught and reported to System.err.
Appenders
An appender is a destination. The interface has a small trick: the level check is written once, as a default method, so no destination can forget it.
public interface Appender {
Level minLevel();
/** Writes one event. Called only for events that passed the level check. */
void write(LogEvent event);
default void append(LogEvent event) {
if (event.level().isAtLeast(minLevel())) {
write(event);
}
}
default void close() {
}
}The logger calls append. Each destination only fills in write. The console one is almost nothing:
@Override
public void write(LogEvent event) {
out.println(formatter.format(event));
}The file one has to be careful, because two threads may write at once:
// synchronized so two threads never mix their characters in the same line
@Override
public synchronized void write(LogEvent event) {
try {
writer.write(formatter.format(event));
writer.newLine();
writer.flush();
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}The in-memory one stores events in a CopyOnWriteArrayList, so a test can ask "what was logged?" afterwards. Testing code that logs is much easier when you can assert on the list instead of reading a console.
Formatters
A formatter is a function from event to text. The interface has one method, so a lambda works too.
@FunctionalInterface
public interface LogFormatter {
String format(LogEvent event);
}The demo uses the PlainTextFormatter class for the console and a method reference Main::keyValueFormat for the file. Same event, two shapes.
The manager
LoggerManager is the one shared registry. It gives out loggers by name (the same name returns the same object) and holds the appenders that every logger writes to.
private static final class Holder {
private static final LoggerManager INSTANCE = new LoggerManager();
}
private LoggerManager() {
}
public static LoggerManager getInstance() {
return Holder.INSTANCE;
}
public Logger getLogger(String name) {
return loggers.computeIfAbsent(name, n -> new Logger(n, defaultLevel, appenders, clock));
}The nested Holder class is a classic Java trick. The JVM creates INSTANCE the first time Holder is used, and it does that safely even with many threads, so you need no synchronized and no volatile for the singleton itself.
The appenders live in a CopyOnWriteArrayList, which is made for "read very often, change rarely". Every log call reads the list. Only start-up code changes it. All loggers share this same list, so an appender added later also works for loggers that already exist.
Why people argue about singletons
A singleton is one object that everyone reaches through a static method. People like it for logging because a logger is needed everywhere, and passing one into every constructor feels silly.
People dislike it for three reasons. It is hidden global state: you cannot see from a class's constructor that it writes logs. It makes tests leak into each other, because the state survives from one test to the next. And you cannot easily have two different setups in one program.
My view: for logging it is acceptable, since it is nearly always global in real life, and most big frameworks do it. But keep it thin. In this design Logger has an ordinary public constructor and knows nothing about the manager. The manager is only a convenience on top. That is why demo part 3 builds a Logger by hand with an in-memory appender and never touches the singleton, and reset() exists for the tests that do.
Async appender (the follow up)
Writing to a file or to the network is slow. If the request thread waits for it on every log line, the app gets slower because of its own diary. The fix: put the event in a queue and let one background thread do the writing.
AsyncAppender wraps any other appender. It is the decorator idea: it looks like an appender from outside and holds a real one inside.
@Override
public void write(LogEvent event) {
if (closed || !queue.offer(event)) {
dropped.incrementAndGet();
}
}
private void drainLoop() {
try {
while (!closed || !queue.isEmpty()) {
LogEvent event = queue.poll(50, TimeUnit.MILLISECONDS);
if (event != null) {
delegate.write(event);
}
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}The queue is bounded (ArrayBlockingQueue). When it is full, offer returns false straight away and the event is dropped and counted. This is a choice: I would rather lose a few debug lines than freeze the app. The other choice is to block the caller until there is room, which is safer for audit logs, and you can say that in the interview. close() stops new events, lets the worker finish what is queued, then closes the real appender.
Patterns Used and Why
| Pattern | Where | Why |
|---|---|---|
| Strategy | Appender and LogFormatter | Where the line goes and how it looks can change without touching Logger. |
| Observer (loosely) | Logger pushes each event to a list of appenders | The logger does not know how many appenders exist or what they do. They are subscribers. |
| Singleton | LoggerManager | One shared registry and one set of appenders for the app. |
| Decorator | AsyncAppender | Adds "write in the background" to any appender without changing it. |
| Template method (small) | Appender.append calls write | The level check is written once. |
What about chain of responsibility? In the textbook version, a message passes down a chain of handlers (DEBUG handler, INFO handler, ERROR handler), and each decides to handle it or pass it on. It can be done for levels, but it does not fit cleanly here. Levels are ordered numbers, so a single comparison replaces a whole chain of objects. Per-appender minimum levels already cover "errors go to the pager, everything goes to the file". The chain starts to make sense when the handlers do really different things and one can stop the message from reaching the next, for example "if it is a security event, alert and stop; else pass on". I did not build it, and I think that is the right call.
Walk Through One Full Example
The demo uses a fixed clock, so this is the exact output of every run.
Set up: three appenders and a default level of DEBUG. The console shows INFO and above, the file takes WARN and above, memory takes everything.
== 1. Three appenders, three different level filters ==
2026-01-15T10:30:00Z [main] INFO checkout - Order 1042 placed
2026-01-15T10:30:00Z [main] WARN payment - Card gateway answered slowly (2300 ms)
2026-01-15T10:30:00Z [main] ERROR payment - Card declined for order 1042
Same name gives the same logger: true
In memory appender saw 4 events (all levels)Four calls were made: one debug, one info, one warn, one error. The console printed three (the debug line is below its INFO limit). The in-memory appender stored all four.
Next, the payment logger is raised to ERROR, so its warnings are cut by the logger before any appender sees them:
== 2. Logger level cuts events before any appender sees them ==
2026-01-15T10:30:00Z [main] ERROR payment - Refund failed for order 1042
In memory appender now has 5 eventsOnly the error went through, so memory grew from 4 to 5. This shows the two levels: the logger's level is the first gate for all destinations, and each appender's level is a second gate just for that destination. The file got its lines in a different format:
time=2026-01-15T10:30:00Z level=WARN logger=payment msg="Card gateway answered slowly (2300 ms)"
time=2026-01-15T10:30:00Z level=ERROR logger=payment msg="Card declined for order 1042"
time=2026-01-15T10:30:00Z level=ERROR logger=payment msg="Refund failed for order 1042"Part 3 builds a Logger by hand with an in-memory appender, the way a unit test would:
INFO Item added
ERROR Out of stockPart 4 starts 4 threads, each logging 500 events through an AsyncAppender with room for 10,000:
Stored 2000, dropped 0Part 5 floods an async appender with room for just 1 event, with 100,000 calls in a loop:
Stored + dropped = 100000 (nothing is lost without being counted)How many are stored and how many are dropped changes from run to run, because it depends on how fast the background thread is. The total never changes, and that is the part the demo prints.
Edge Cases
- Logging below the level costs one comparison. But
logger.debug("big " + expensiveCall())still builds the string before the call. Guard it withisEnabled(Level.DEBUG), or take aSupplier<String>. I left the supplier out to keep the code short. - An appender throws (disk full, closed file). The logger catches it, prints to
System.err, and carries on with the other appenders. - A level change while threads are logging. The level field is
volatile, so every thread sees the new value. - Adding an appender after loggers were made. It works, since all loggers share the same list.
- Closing the async appender with events still in the queue. They are written first, then the worker ends. Events that arrive after
close()are counted as dropped. - The JVM exits while the async queue is not empty. The worker is a daemon thread, so it will not keep the JVM alive, and events could be lost unless someone calls
close(). A real framework adds a shutdown hook.
Follow Up Questions an Interviewer May Ask
"How would you add JSON output?"
Write a
JsonFormatter implements LogFormatterand pass it to an appender. Nothing else changes.
"How would you add a network or database appender?"
Another class that implements
Appender. Wrap it inAsyncAppender, because network calls are slow and fail.
"How do you do logger hierarchy, like com.shop and com.shop.cart?"
Split the name on dots and let a child logger inherit the level from its parent when it has none of its own. The manager keeps the tree. It is more code and I would only add it if asked.
"Block or drop when the async queue is full?"
Drop for debug and info, block (or write directly) for warnings and errors or audit trails. A good answer is that it depends on how bad losing a line is.
"How do you add structured fields, like a request id?"
Add a map of key and value to
LogEvent. Real libraries also keep a per-thread "context" map (called MDC) that gets copied into every event.
"How do you test the singleton?"
Call
reset()before each test, or avoid the singleton in the test and build aLoggerwith anInMemoryAppender.
Common Mistakes
- Building the final string inside the logger, so no destination can have its own format.
- Reading the thread name or the time on the worker thread in the async version.
- One giant
if (type == "file") ... else if (type == "console")instead of an interface. - A lazy singleton with a plain null check (
if (instance == null) instance = new ...). Two threads can both create it. - Letting a logging exception reach the application.
- An unbounded async queue. Under load it grows until the app runs out of memory, and the diary kills the patient.
- Forgetting to close the file and the async worker, which loses the last lines.
How to Run
From the repository root. The class files go to /tmp. The demo writes its log file to the system temp folder and deletes it at the end.
javac --release 17 -d /tmp/logger-out $(find src/LLD/Logger/src -name '*.java')
java -cp /tmp/logger-out com.blueprint.lld.logger.MainJava 17, no libraries.
Where the Design Is Simplified
- Flat logger names, no hierarchy.
- Messages are plain strings. No
{}placeholders and no exception stack traces. - No file rotation, and the file appender flushes after every line (safe, but slow).
- The async appender has one worker thread and one fixed drop policy.
- No shutdown hook, no config from a file, and no per-thread context.