Understanding the Problem
đ What is a Logger? A logger is the in-process library an application uses to record whatâs happening at runtime. Code calls logger.info("user signed in") from anywhere in the app, and the library timestamps the message, attaches the severity level, and writes it to one or more places like the console, a file, or both. Think Log4j, SLF4J, or Pythonâs logging module. Weâre designing the library that lives inside one application, not a distributed log aggregation service.
Requirements
You sit down for the interview and the prompt comes in deliberately short:
âDesign a logging service. Or call it a logger, whichever you prefer.â
âDesign a logging service. Or call it a logger, whichever you prefer.â
Most of the design is hidden in what they didnât say. Spend the first few minutes pulling it apart before drawing anything.
Clarifying Questions
The first thing to nail down is what kind of logging system this is. âLoggerâ can mean wildly different things.
You: âWhen you say âlogging service,â do you mean an in-process library the application links against? Or something that ships logs over the network to a central aggregator?â Interviewer: âIn-process library. Network shipping, ingestion pipelines, and central aggregation are someone elseâs problem.â
You: âWhen you say âlogging service,â do you mean an in-process library the application links against? Or something that ships logs over the network to a central aggregator?â
Interviewer: âIn-process library. Network shipping, ingestion pipelines, and central aggregation are someone elseâs problem.â
That answer cuts most of the design space. No queues, no schema registries, no fan-out across services. The deliverable is an object model that runs inside one applicationâs process and writes to local destinations like stdout and files.
You: âWhat severity levels should we support, and is there an ordering between them?â Interviewer: âDEBUG, INFO, WARN, ERROR, FATAL. Ordered from least to most severe in that order.â
You: âWhat severity levels should we support, and is there an ordering between them?â
Interviewer: âDEBUG, INFO, WARN, ERROR, FATAL. Ordered from least to most severe in that order.â
Five levels with a natural ordering. Thatâs a finite set with no per-level behavior, which is a textbook enum. If you reach for a Level class hierarchy with DebugLevel, InfoLevel, and so on, youâre doing too much.
You: âCan a single logger write to multiple destinations at the same time? Like sending the same record to both the console and a file?â Interviewer: âYes. Thatâs the common case. A developer running locally wants logs in the console and also persisted to a file for later inspection. Each call should fan out to every configured destination.â
You: âCan a single logger write to multiple destinations at the same time? Like sending the same record to both the console and a file?â
Interviewer: âYes. Thatâs the common case. A developer running locally wants logs in the console and also persisted to a file for later inspection. Each call should fan out to every configured destination.â
Now you know âdestinationâ is a first-class concept and that one log call hits all of them. That implies the library holds a list of destinations and iterates over them on every call.
You: âDoes each destination decide its own filter level, or is there one global level on the logger?â Interviewer: âPer destination. Each destination has its own minimum level. The console might want everything from DEBUG up, and the file destination might only care about WARN and above. Records below a destinationâs threshold should be dropped before being written.â
You: âDoes each destination decide its own filter level, or is there one global level on the logger?â
Interviewer: âPer destination. Each destination has its own minimum level. The console might want everything from DEBUG up, and the file destination might only care about WARN and above. Records below a destinationâs threshold should be dropped before being written.â
This rules out putting the level filter on the logger and pushes it down to the destination. It also means the same record gets evaluated independently by each destination, which is fine because the record itself is immutable once created.
You: âAnd the format the records are written in. Is that fixed, or does it vary?â Interviewer: âVaries. Sometimes plain text, sometimes JSON. And the format is independent of the destination type. You should be able to write JSON to the console, plain text to a file, or any combination.â
You: âAnd the format the records are written in. Is that fixed, or does it vary?â
Interviewer: âVaries. Sometimes plain text, sometimes JSON. And the format is independent of the destination type. You should be able to write JSON to the console, plain text to a file, or any combination.â
This is the requirement that shapes the class model. If format and destination were coupled, youâd end up with a class for every (format, target) pair like JsonFileDestination, PlainConsoleDestination, and so on. Add a third format and a third target and you have nine classes. Since the requirement says they vary independently, the right move is to compose them instead of multiplying classes.
When a requirement gives you two dimensions that vary independently, thatâs almost always a signal to use composition over inheritance. Two interfaces composed together let you mix any combination without writing NĂM classes. Watch for these axes-of-variation hints in any LLD prompt.
You: âWhat about concurrency? Multiple threads in the same app are going to be calling
log()simultaneously. Whatâs the expectation?â Interviewer: âThread-safe. Each recordâs bytes have to land on a destination atomically â one recordâs bytes canât be split across or mixed with anotherâs. For a single thread, records appear in call order. Across threads, no strict ordering beyond each recordâs timestamp.â
You: âWhat about concurrency? Multiple threads in the same app are going to be calling log() simultaneously. Whatâs the expectation?â
Interviewer: âThread-safe. Each recordâs bytes have to land on a destination atomically â one recordâs bytes canât be split across or mixed with anotherâs. For a single thread, records appear in call order. Across threads, no strict ordering beyond each recordâs timestamp.â
Concurrency is in scope, so locking is part of the design, not a cleanup pass at the end. Per-record atomicity is the bar â two threads racing to a stdout buffer canât smear one recordâs bytes across anotherâs. Strict global submission order across threads is a harder requirement that would push toward queues and single-writer threads, and theyâre not asking for that.
You: âLast one. Is configuration static, set at startup, or do we need to handle hot-reloading destinations and levels at runtime?â Interviewer: âStatic. Configured once at startup. Hot-reload, async or buffered writes, log rotation, and network destinations are all out of scope, though the design should not block adding a remote destination later.â
You: âLast one. Is configuration static, set at startup, or do we need to handle hot-reloading destinations and levels at runtime?â
Interviewer: âStatic. Configured once at startup. Hot-reload, async or buffered writes, log rotation, and network destinations are all out of scope, though the design should not block adding a remote destination later.â
The last clause is the one that shapes the design. You donât build remote destinations now, but the model needs an extension point so adding one later doesnât force a rewrite of Logger. As long as destinations are pluggable behind an interface, youâre fine.
Final Requirements
After that back-and-forth, youâd write this on the whiteboard:
Requirements:
1. Five severity levels: DEBUG < INFO < WARN < ERROR < FATAL.
2. Each record carries timestamp, level, message, emitting thread name.
3. Logger writes each record to one or more destinations, set at startup.
4. Each destination has its own min-level threshold and its own format.
Format and destination type vary independently.
5. Concurrent calls are safe. A record's bytes never interleave with
another record's bytes on the same destination.
Out of scope:
- Hot-reloading config at runtime
- Async / buffered writes
- Remote / network destinations in v1 (design should accommodate)
- Hierarchical / named loggers (com.app.service inheriting from com.app)
Notice we explicitly scoped out async writes, hot-reload, and remote destinations. Each of those is a real production concern, but each is a layer that sits on top of the core object model rather than being part of it. Calling them out by name signals that you considered them and chose not to build them, which reads very differently from forgetting they exist. Most of them come back as natural extensions in the follow-up section anyway.
Core Entities and Relationships
With requirements pinned down, the next step is figuring out what objects make up the system. Look for nouns in your requirements, but donât turn every one into a class. Some are fields on other classes. Some are enums. Some are just strings passed between methods. The filter is whether the noun owns state, enforces a rule, or has its own lifecycle. If it doesnât, it doesnât deserve a class.
Letâs walk through the candidates:
The application â not an entity. The thing calling logger.info("...") lives outside our system. We donât model it. Same goes for threads. The OS owns those. We just read the current threadâs name at the call site and put it on the record weâre building.
Severity level / timestamp / message â fields, not entities. None of these own state or enforce a rule. The level is one of five fixed values, the timestamp is a primitive, the message is a string. Each is a field on something else. The interesting question is what theyâre a field on, which leads to the first real entity.
LogRecord â entity. Every call to log() generates a unit of data made up of a timestamp, a level, a message, and a thread name. These fields always travel together. Every destination receives them, every formatter serializes them, and they never change after creation. Thatâs the textbook case for a value object. Modeling them as one class instead of passing four parameters everywhere gives formatters a stable shape and lets you add fields later (a logger name, a request ID, a context map) without changing every method signature in the system.
Logger â entity. Something has to be the public face of the library. When the application calls logger.info("..."), something has to capture the timestamp and thread name, build a LogRecord, and dispatch it to every configured destination. Thatâs Logger. It owns the list of destinations and exposes log() plus the convenience helpers (debug, info, warn, error, fatal) that callers actually use. Itâs the entry point and the only class the application ever touches.
Destination â entity. Each output target (console, file, future remote) needs three things. A minimum level threshold to filter on. A format to serialize the record. And the actual write mechanism. Thatâs enough state and behavior to deserve a class of its own. Itâs also the natural home for the per-destination lock, since synchronization belongs with the resource being protected.
Formatter â entity. The âformat and destination type are independentâ requirement decides this one. If Destination did formatting itself, youâd need a separate destination class for every (format, target) pair. Thatâs the 2D class explosion. Pulling format into its own interface means a single Destination composes a formatter and a write target, and any combination is just a constructor argument. Two implementations exist now (plain text, JSON) and more are real possibilities, so the interface earns its place.
After filtering, weâre left with these:
Beyond those four, one enum rounds out the model. LogLevel is the five-valued enum from the requirements, with a natural ordering. It shows up on both the recordâs severity and the destinationâs threshold. No per-level behavior, no class-per-level. A DebugLevel / InfoLevel hierarchy is the classic over-modeling mistake; weâre not doing that.
The relationships are simple. Logger holds many Destination instances, fixed at construction. Each Destination holds exactly one Formatter. Logger creates a LogRecord per log() call and hands the same record to every destination. Each destination independently checks the level, formats the record (or doesnât, if filtered out), and writes. No back-references, no cycles, no shared mutable state outside each destinationâs own resource.
Youâll notice we havenât introduced an abstraction for âthe thing that actually writes bytesâ yet. Thatâs deliberate. Itâs natural to look at ConsoleDestination and FileDestination and want to factor out a write target up front, but the cleanest version of that abstraction only becomes obvious once we work through the inheritance-vs-composition tradeoff during class design. Forcing it into the entity stage skips the inheritance-vs-composition reasoning, which is the most useful piece of this section.
Class Design
With our four entities identified, itâs time to define their interfaces. What state does each one hold, and what methods does it expose?
Weâll work top-down, starting with Logger, then handling the data types (LogRecord, LogLevel), then Formatter, and finishing with Destination. Destination is where most of the interesting design decisions live.
For each class, weâll ask two questions:
- What does this class need to remember to satisfy the requirements (its state)?
- What operations does it need to support (its methods)?
Logger
Logger is the orchestrator. The application calls logger.info("..."), and Logger is the only class the application ever needs to know about. Everything else lives behind it.
From the requirements:
Thatâs it. Logger has exactly one field:
class Logger:
- destinations: List<Destination> // immutable after construction
Why destinations is immutable after construction. Config is set once at startup, so the list never changes. Iteration is safe under concurrent calls with no locking. A mutable list with addDestination() would force locking around iteration without buying anything the requirements asked for.
Now the operations:
class Logger:
- destinations: List<Destination>
+ Logger(destinations: List<Destination>)
+ log(level: LogLevel, message: String)
+ debug(message: String)
+ info(message: String)
+ warn(message: String)
+ error(message: String)
+ fatal(message: String)
The constructor takes the destinations once and stores an immutable copy. Thereâs deliberately no addDestination, no setters, no builder. Config is fixed at startup, so the API doesnât expose a way to mutate it. The convenience methods are trivial delegation. info(msg) just calls log(INFO, msg). Theyâre worth including because they match the API callers actually use. Calling log(LogLevel.INFO, "...") everywhere works, but itâs noisier than info("..."), and a logger is infrastructure that tens of thousands of call sites will touch so the ergonomics matter.
The interesting part of log() is what it captures and what it doesnât. Timestamp and thread name come from the calling thread at the moment log runs (now() and Thread.currentThread().getName(), or the equivalent in your language). Both go into a freshly built LogRecord, which is handed to every destination. The level and message come from the caller. No state on Logger mutates. Iteration over destinations is sequential and runs on the calling thread. No fan-out across worker threads, because async dispatch is out of scope.
LogRecord
LogRecord is the value object flowing through the system. Every log() call creates one, every destination consumes one, and the fields never change after construction. Itâs a dumb container by design â no behavior, just data, packed tightly so a record is cheap to allocate on the hot path.
class LogRecord:
- timestamp: Instant
- level: LogLevel
- message: String
- threadName: String
+ LogRecord(timestamp, level, message, threadName)
+ getters for all fields
Why all fields are read-only. A record represents what happened at one moment in one thread. None of those facts change after the call site. Making the fields immutable closes off a whole class of bugs. Any destination, any formatter, any future extension can read a record without worrying about racing another thread or accidentally mutating shared state. For value objects, immutable fields are the default. Here we donât even have collections to defensively copy, just primitives, an enum, and a timestamp.
Why this is a class and not four parameters. It would be tempting to skip LogRecord entirely and have Logger.log() pass (timestamp, level, message, threadName) to every destination. That works for v1 but rots fast. The day you add a logger name, a request ID, or a context map, every method signature in the system has to change. Grouping the data into a record means adding a field touches one class definition and the orchestrator that builds the record, instead of changing every signature on Destination and Formatter. This is the same reason Ticket is its own class in the Parking Lot breakdown. Both are immutable records that group data the orchestrator manages.
The pseudocode shows getters for clarity, but in a modern language this collapses to one line. Java records, Kotlin data classes, Python @dataclass(frozen=True), TypeScript readonly fields, Go structs with unexported fields. All of them give you immutable records without the Java-from-2005 ceremony.
Formatter
Formatter is an interface for serializing a LogRecord to a string. Two implementations exist now (plain text, JSON) and the extension axis is real (CSV, key-value, XML, language-specific structured formats), so the interface earns its keep. This is the Strategy pattern â pulling format behind its own interface lets a Destination compose whichever formatter it wants, and adding a CSV formatter tomorrow is one new class with zero changes to anything that already exists.
interface Formatter:
+ format(record: LogRecord) -> String
class PlainTextFormatter implements Formatter
class JsonFormatter implements Formatter
Formatters are pure functions. They take a record, return a string, end. That makes them safe to share across threads and across destinations. Two destinations can hold a reference to the same JsonFormatter instance without any synchronization, because thereâs nothing to synchronize.
Keeping formatting behind its own interface (rather than as a method on Destination) is the entire reason this design avoids the 2D class explosion. Two formats Ă two destination types would be four classes if format and destination were coupled. Pulling format into its own type means one Destination class composes a formatter, and any combination is a constructor argument. The formatter owns serialization. The destination owns the write. Neither knows the otherâs internals.
Destination
Destination is the most fun class to design. It owns a minimum level threshold for filtering, a formatter for serializing, and the actual write mechanism for output. It also owns the per-destination lock for concurrent safety.
A candidateâs first pass at Destination typically takes one of two shapes. The first is a single concrete class that branches on a type field inside its write() method:
class Destination:
- formatter, minLevel, type, filePath
+ write(record):
if type == CONSOLE: ...
else if type == FILE: ...
The second is an inheritance hierarchy with abstract Destination and ConsoleDestination / FileDestination subclasses. Both are reasonable starting points, and either one can be made to work. The difference between the two, plus the third option that beats them both, is worth a real discussion.
Approach
One concrete Destination class with a type field. The write() method branches on the type to decide what to do.
class Destination:
- type: DestinationType // CONSOLE | FILE
- formatter: Formatter
- minLevel: LogLevel
- filePath: String // only used when type == FILE
+ write(record):
if record.level < minLevel: return
formatted = formatter.format(record)
if type == CONSOLE:
stdout.write(formatted)
else if type == FILE:
fileWriter(filePath).write(formatted)
It seems reasonable. Thereâs only one class to learn. The fields a destination needs are all in one place. The branching is local to one method. For two output targets, this might feel like the path of least resistance.
Challenges
The seams break the moment you add a third destination. Every new target adds a branch in write(), and most of them add fields that only apply to that one target (filePath for files, socketAddress for remote, kafkaTopic for Kafka). You end up with a class where most fields are null for any given instance, and a write() method thatâs a switch statement masquerading as polymorphism. This is the textbook anti-pattern that interfaces and inheritance both exist to fix. The design problem is âbehavior varies by type,â and a switch statement is the answer that scales worst as types are added. It also violates Open/Closed, since every new destination type forces you to crack open Destination and edit existing logic.
Approach
Destination becomes an abstract base class that owns the filter-and-format logic. Subclasses implement the actual write step.
abstract class Destination:
- formatter: Formatter
- minLevel: LogLevel
- lock: Lock
+ write(record):
if record.level < minLevel: return
formatted = formatter.format(record)
lock.acquire()
try:
doWrite(formatted)
finally:
lock.release()
# doWrite(formatted: String) // abstract â subclass fills this in
class ConsoleDestination extends Destination:
+ doWrite(formatted): stdout.write(formatted)
class FileDestination extends Destination:
- filePath: String
+ doWrite(formatted): fileWriter(filePath).write(formatted)
This is the template method pattern and it does the most important job. It puts the filter-and-format invariant in exactly one place. A new subclass author canât forget the level check or skip the formatter, because they donât write write(). They only write doWrite(). Thatâs a real win over the type-branching version. The base class also gives you a natural home for the lock.
Challenges
The wrinkle is that inheritance is a heavy commitment for whatâs mostly a single axis of variation. ConsoleDestination and FileDestination donât actually share any data with their parent. They just inherit a method. Thatâs a thin reason to use inheritance. The bigger pain shows up when you want to wrap a destination in something, say buffering or retry or a fan-out to two underlying targets. With an inheritance hierarchy, âwrapâ means âsubclass and override,â which composes badly. You canât easily build a BufferedFileDestination without re-implementing the file-handling logic, and stacking two wrappers means three levels of inheritance for what should be one wrapping operation.
Approach
Pull the actual write step out into its own interface. Destination becomes one concrete class that owns the filter, the formatter, and delegates the byte-writing to a Sink.
interface Sink:
+ write(formatted: String)
class ConsoleSink implements Sink
class FileSink implements Sink:
- filePath: String
class Destination:
- formatter: Formatter
- minLevel: LogLevel
- sink: Sink
+ Destination(formatter, minLevel, sink)
+ write(record):
if record.level < minLevel: return
formatted = formatter.format(record)
sink.write(formatted)
Same shared logic, but now Destination is concrete and thereâs no hierarchy. New output targets are new Sink implementations, not new Destination subclasses. Testing is straightforward, since you can pass a fake Sink that records calls. And the public API for adding a destination stays refreshingly simple, just new Destination(formatter, minLevel, sink) with whichever Sink you want.
Challenges
Thereâs slightly more structure here than the inheritance version, one extra interface and the named role of âsink.â For a system that will only ever have two output targets, thatâs mild overhead. But this is the shape real loggers converge on (Log4j Appenders separate layout from the underlying output stream; Pythonâs logging separates formatters from handlers and stream handlers from file handlers). And itâs the shape the requirements explicitly asked for when they said adding a remote destination later shouldnât force a rewrite of Logger. A RemoteSink slots in next to ConsoleSink and FileSink, no other class touched.
Weâre going with the composition variant. It keeps the filter-and-format invariant in one place (the same win as the inheritance approach), avoids a hierarchy that pays no real dividend, and gives the requirement-stated remote destination a clean place to land as a new Sink. Composition wins over inheritance unless inheritance is genuinely earned, and here it isnât.
Destination itself stays concrete. Thereâs only one valid filter-format-lock-delegate shape, and variation lives behind the Sink and Formatter interfaces, not in Destination. Abstraction earns its place. Adding an IDestination interface for one implementation would be indirection for its own sake. This is Dependency Inversion used where it matters. The high-level workflow class depends on Sink and Formatter abstractions, not on ConsoleSink or JsonFormatter.
Inheritance variant is also defensible in an interview. The senior signal is articulating the choice, not memorizing one answer. If you walk through both options and pick inheritance for its simplicity at small scale, youâll get credit. The design fails only if you canât explain why you didnât pick the other one.
So our Destination is one concrete class composing a formatter, a level threshold, and a sink.
class Destination:
- formatter: Formatter
- minLevel: LogLevel
- sink: Sink
+ Destination(formatter, minLevel, sink)
+ write(record: LogRecord)
Sink
Sink emerged from the inheritance-vs-composition discussion. Itâs a small interface, one method and one responsibility, and itâs where future extensions will plug in.
interface Sink:
+ write(formatted: String)
class ConsoleSink implements Sink
class FileSink implements Sink:
- filePath: String
ConsoleSink writes to stdout. FileSink opens (or appends to) the file at filePath and writes. Both are dumb on purpose. They donât filter, they donât format, they donât lock. Those concerns live one layer up in Destination. A Sink just turns a string into bytes on a target. That narrowness is what makes the requirement-stated future remote destination drop in cleanly. A RemoteSink that writes to a network endpoint is a new Sink implementation, no other class in the system has to change.
Final Class Design
Thatâs the complete model. Logger orchestrates the dispatch. LogRecord is the immutable unit of data. LogLevel is the five-valued enum. Formatter is the serialization interface, Sink is the output interface. Destination composes them. The pieces fit together with no cycles, no shared mutable state across classes, and a clear extension axis (new sinks, new formatters) that doesnât require touching Logger.
enum LogLevel:
DEBUG
INFO
WARN
ERROR
FATAL
// ordered: DEBUG < INFO < WARN < ERROR < FATAL
class LogRecord:
- timestamp: Instant
- level: LogLevel
- message: String
- threadName: String
+ LogRecord(timestamp, level, message, threadName)
+ getters for all fields
interface Formatter:
+ format(record: LogRecord) -> String
class PlainTextFormatter implements Formatter
class JsonFormatter implements Formatter
interface Sink:
+ write(formatted: String)
class ConsoleSink implements Sink
class FileSink implements Sink:
- filePath: String
class Destination:
- formatter: Formatter
- minLevel: LogLevel
- sink: Sink
+ Destination(formatter, minLevel, sink)
+ write(record: LogRecord)
class Logger:
- destinations: List<Destination>
+ Logger(destinations: List<Destination>)
+ log(level: LogLevel, message: String)
+ debug(message: String)
+ info(message: String)
+ warn(message: String)
+ error(message: String)
+ fatal(message: String)
The design demonstrates Separation of Concerns all the way through, with orchestration in Logger, data in LogRecord, classification in LogLevel, serialization in Formatter, output in Sink, and the per-destination invariants (filter, compose) in Destination. Every class owns exactly one reason to change. Thatâs Single Responsibility the way it actually matters in practice, not âtiny classesâ but âone axis of change per class.â Adding a new format, a new sink, or a new destination configuration touches one place, not the entire model.
Implementation
With the class design locked in, we need to implement the actual method bodies. Before diving in, check with your interviewer. Some want working code in a specific language, others prefer pseudocode, and some just want you to talk through the logic. Weâll use pseudocode here. Itâs the most common interview output and it lets us focus on the design choices rather than language-specific syntax.
For each method, weâll follow a pattern:
- Define the core logic - The happy path that fulfills the requirement
- Handle edge cases - Invalid inputs, boundary conditions, unexpected states
Interviewers usually focus on the most interesting methods. For our logger, those are:
Logger.log()- shows how per-call data gets captured and fanned out to destinationsDestination.write()- shows the filter-format-lock-write pipeline and how to handle a failing sink
Logger
Logger.log() is the entry point for every call into the library. The body is short â four lines of pseudocode â but the sequencing of those lines matters.
Core logic:
- Capture the current timestamp.
- Capture the current threadâs name.
- Build a
LogRecordfrom those plus the callerâs level and message. - Iterate over destinations and call
write(record)on each.
Edge cases:
- Null or empty message
- A destinationâs
write()throws
log(level, message):
record = new LogRecord(
timestamp = now(),
level = level,
message = message,
threadName = currentThread().name
)
for destination in destinations:
destination.write(record)
now() and currentThread().name get called once at the top of log() rather than once per destination, so every destination sees the same record with the same timestamp and the same thread name. That matches the requirement exactly. One call fans out, and every destination filters and writes the same data independently. Thereâs also no level filtering at the Logger level, because the threshold is a per-destination concern in our design, and the filter has to live wherever the threshold lives. And thereâs no locking around the iteration itself. The destinations list is immutable after construction, so concurrent threads can walk it without stepping on each other, and the synchronization that the requirements actually demand lives one layer down inside Destination.write(), next to the shared resource itâs protecting.
On the edge cases, a null or empty message gets accepted as-is rather than guarded against. The logger isnât in the business of validating what callers hand it, and bolting a defensive null check onto a code path that fires from tens of thousands of call sites adds noise everywhere for almost no real protection. The right move is to pick a stance, say it out loud in the interview, and keep going. The more interesting case is what happens when one of the destinations throws partway through the iteration. We donât want a flaky file destination to silently swallow the same record that would have made it to the console, and we definitely donât want Logger.log() itself to start throwing back to the caller. The fix lives one layer down, inside Destination.write(), which catches its own failures so the loop in Logger.log() never sees them. The Destination section walks through exactly how that works.
One temptation is to âspeed upâ the iteration by fanning out across worker threads, one per destination, so a slow file write doesnât block the console write. Donât. The destinations are already independent (each has its own lock), so the only thing parallel iteration buys you is faster latency on log() itself. The cost is a thread per call, lifecycle management, and (depending on your language) reordering guarantees that get harder to reason about. If async dispatch matters, thatâs a separate decision covered in the concurrency DeepDive, and it doesnât live in Logger.log(). Keep this loop sequential and dumb.
The convenience methods are one-liners. They exist purely so callers can write info("...") instead of log(INFO, "...") at every call site:
debug(message): log(DEBUG, message)
info(message): log(INFO, message)
warn(message): log(WARN, message)
error(message): log(ERROR, message)
fatal(message): log(FATAL, message)
Simple!
Capturing the timestamp at the top of log() rather than later in Destination.write() is a small but real decision. If you captured it inside the destination, two destinations would record slightly different timestamps for the same record (different by microseconds, but still). Capturing once at the call site means every destination sees the same moment, which is what callers expect when they read a log file later.
Destination
Destination.write() filters, then formats, then writes to the sink under a lock. Two interesting decisions show up in the body â the locking strategy, and what to do when the sink throws.
Core logic:
- Drop the record if its level is below the destinationâs threshold.
- Format the record using the formatter.
- Acquire the per-destination lock.
- Hand the formatted string to the sink.
- Release the lock (always, even if the sink throws).
Edge cases:
- Level below threshold
- Lock contention
- Sink throws on write
Below-threshold records get a silent drop â no error, no diagnostic, thatâs the whole point of a threshold. Lock contention just blocks the calling thread until the lock is free; v1 doesnât bother with timeouts. The interesting case is the sink throwing, which gets its own DeepDive below. First we need to settle where the lock lives, because that decision shapes the rest of write().
Requirement 5 says concurrent calls must be safe and a recordâs bytes must not interleave with another recordâs on the same destination. Thatâs a correctness problem â the file handle and the stdout buffer are shared state that two threads can corrupt by racing each other. Class design pointed at a per-destination lock â the destination owns the resource, so it owns the lock. The first instinct is usually coarser, like wrapping synchronized around Logger.log(). Two options worth walking through to motivate the choice.
Approach
Synchronize the entire log() method on Logger. Every call from every thread is serialized through one lock.
class Logger:
+ synchronized log(level, message):
record = new LogRecord(...)
for d in destinations:
d.write(record)
Itâs correct. No two threads ever execute log simultaneously, so the file handle, stdout, and any future shared resource are safe by construction. Itâs also the simplest possible answer to âmake this thread-safe.â
Challenges
Itâs a sledgehammer. A slow file write (disk full, contended I/O, unreachable network destination) now blocks every other call to log, including console writes that should have been instant. The right default for per-resource concurrency is one lock per resource. Here weâre using one lock to protect five unrelated resources. This also blocks formatting, which doesnât need locking at all (records are immutable, formatters are stateless). Coarse locking is fine when the workload is sparse and the resources are uniform. Ours is neither.
Approach
One lock per Destination instance. The critical section covers sink.write(). Filter check and formatting happen outside the lock since the record is immutable and the formatter is a pure function.
class Destination:
+ write(record):
if record.level < minLevel: return
formatted = formatter.format(record)
lock.acquire()
try:
sink.write(formatted)
finally:
lock.release()
This is the per-resource locking pattern. Each destination protects its own resource with its own lock, so a slow file write doesnât block console output, and a hung remote destination doesnât block file or console. The critical section is the smallest correct one, just the parts of write() that touch shared state (the sink). The parts that donât (filter, format) stay outside.
Challenges
One caveat worth knowing. Formatting happens outside the lock, which means two threads can format the same destinationâs records simultaneously. Thatâs safe because the record is immutable and the formatter is a pure function, so thereâs no shared state for them to step on. Holding the lock across format and write is also defensible and slightly simpler, and the throughput cost of doing it that way only matters if formatting is expensive (say, JSON serialization of a large object) and contention is high. Either choice is a fine interview answer as long as you can articulate the tradeoff.
Per-destination lock around sink.write is the right default. Itâs the same per-resource locking pattern that shows up in BookMyShowâs per-showtime seat reservations and Inventory Managementâs per-warehouse stock. Each shared resource gets its own lock, and the orchestrator (here, Logger) never holds a single global lock across all of them. Thatâs what lets a slow file write coexist with an instant console write without one blocking the other.
Itâs tempting to lift the lock up to Logger.log() because âthread safety on the loggerâ sounds like the right framing. It usually isnât. The shared state lives on each destinationâs sink, not on the logger itself, so by default the lock belongs next to the sink. When the shared state lives somewhere else, thatâs a strong signal the lock belongs there too.
If an interviewer pushes on caller latency, batching, or strict per-destination sequencing, the natural next step is async writes per destination. We cover that as an extension below (How would you make log() non-blocking?). For v1, the per-destination lock is enough.
With locking settled, hereâs the actual method body:
write(record):
if record.level < minLevel:
return // silent drop
formatted = formatter.format(record) // outside the lock
lock.acquire()
try:
sink.write(formatted)
catch e:
// see DeepDive below
...
finally:
lock.release()
That leaves the catch block. What goes there when sink.write() throws?
Approach
Donât catch anything. If sink.write() throws, the exception bubbles through Destination.write() and into Logger.log()âs iteration loop. The loop terminates and the exception keeps going up into the application code that called logger.error(...).
write(record):
if record.level < minLevel: return
formatted = formatter.format(record)
lock.acquire()
try:
sink.write(formatted)
finally:
lock.release()
// any exception just bubbles up
Itâs the simplest thing that compiles. No extra error-handling code. The exception is honest about what happened.
Challenges
The cost is severe in a multi-destination setup. Imagine youâve configured a console sink, a file sink, and a future remote sink. The disk fills up. FileSink.write throws IOException. Logger.log() is mid-iteration when the exception fires, so the console got the record, but the remote never sees it.
Worse, the exception now propagates into the application code that called logger.error("payment failed"). A logger is infrastructure that should never crash the caller. If a payment-processing function throws because logging failed, youâve just turned a logging problem into a payment problem. Anyone reading the resulting stack trace will spend an hour chasing a payment bug that doesnât exist.
Approach
Catch any exception from sink.write() and discard it. The destinationâs failure is its own problem. Other destinations and the calling code never see it.
write(record):
if record.level < minLevel: return
formatted = formatter.format(record)
lock.acquire()
try:
sink.write(formatted)
catch e:
// swallow; don't crash the logger
finally:
lock.release()
This keeps Logger.log() simple. No try/catch in the iteration loop, no early termination. Other destinations still receive the record. The application doesnât crash because logging failed.
Challenges
The downside is silent failure. If your file destination is broken, you get no signal at all. The application keeps running. The console keeps showing logs. But the file you depend on for forensic analysis has been empty for three days, and you wonât notice until something else goes wrong and you go looking. For a production logger this is a real operational problem - the logger is failing exactly when you most need it to be working.
Approach
Catch the exception, but before swallowing, write a one-line diagnostic to a known-good fallback stream. The original record is still lost, but the failure itself is visible. The fallback is typically stderr (or a separate âinternal loggerâ if your library has one).
write(record):
if record.level < minLevel: return
formatted = formatter.format(record)
lock.acquire()
try:
sink.write(formatted)
catch e:
stderr.write("logger: sink write failed: " + e.message)
finally:
lock.release()
This is what production loggers actually do. Log4j has a StatusLogger that writes to stderr when an appender fails. Pythonâs logging defaults to writing to sys.stderr when handlers raise. It balances âdonât crash the callerâ with âdonât fail silently.â
Challenges
Youâre adding a dependency on something to be reliable. If stderr is also broken, youâre back to silent failure, and at some point you have to trust at least one stream. You also need to make sure the diagnostic write itself is bounded. If every recordâs sink fails, you donât want one diagnostic per attempt flooding stderr with millions of lines. Real loggers rate-limit the diagnostic channel, log only the first failure, or back off after the first few errors. For interview scope, mention that youâd add rate-limiting in production but keep the implementation simple.
The âgreatâ option is the right default for production. For interview scope, âgoodâ is a defensible answer - swallow the exception and move on. Mention youâre aware of the silent-failure problem and would add a stderr diagnostic in a real implementation, then keep going.
Formatter implementations
Formatters take a LogRecord and return a string. Theyâre pure functions, which is what makes them safe to share across destinations and across threads with no synchronization.
format(record):
return record.timestamp + " [" + record.level + "] " +
"[" + record.threadName + "] " + record.message
format(record):
return jsonEncode({
"timestamp": record.timestamp,
"level": record.level,
"thread": record.threadName,
"message": record.message
})
Both formats are illustrative. Real plain-text formats typically follow a configurable template like "{timestamp} {level} [{thread}] {message}" so the output shape can change without code changes. For interview scope, hardcoding is fine. Mention youâd accept a format string in the constructor for a real implementation if asked.
Sink implementations
ConsoleSink writes to stdout. FileSink writes to an opened file handle.
write(formatted):
stdout.println(formatted)
FileSink(filePath):
this.fileWriter = openFile(filePath, mode = APPEND)
write(formatted):
fileWriter.append(formatted + "\n")
The FileSink constructor opens the file once and keeps the handle. Donât reopen on every write. The open syscall is expensive, and reopening per call would dwarf the actual write cost by orders of magnitude. The file gets closed when the application shuts down, or when you call an explicit close() if you want a graceful path.
Note both sinks delegate the actual byte-writing to the languageâs I/O primitive. Buffering, encoding, OS-level flushing - those are the underlying OutputStream or FileWriterâs concern. The sinkâs job is just to call the right method.
In a real FileSink, youâd usually want explicit flush() after each write (or after some bounded interval) so the most recent log lines arenât sitting in an OS buffer when the process crashes. The default behavior in most languages is to flush on close, which is exactly the wrong moment for a logger - you want recent logs visible before the crash, not after a clean shutdown.
LogRecord
The constructor takes the four fields and stores them. Getters return them. Thatâs the entire class.
LogRecord(timestamp, level, message, threadName):
this.timestamp = timestamp
this.level = level
this.message = message
this.threadName = threadName
getTimestamp(): return timestamp
getLevel(): return level
getMessage(): return message
getThreadName(): return threadName
Complete Code Implementation
While most interviews only require pseudocode, some ask for working code. Below is a complete implementation in common languages for reference.
from enum import IntEnum
class LogLevel(IntEnum):
DEBUG = 10
INFO = 20
WARN = 30
ERROR = 40
FATAL = 50
Verification
In an LLD interview, you always want to be proactive about verifying your design. Hereâs a quick check with three scenarios that should cover everything: one happy path with filtering, one with concurrent access, and one with a sink failure.
Scenario 1. Two destinations with different thresholds, single thread.
We have a console destination at minLevel = DEBUG and a file destination at minLevel = WARN. The application calls logger.info("user logged in").
Logger.log(INFO, "user logged in"):
record = LogRecord(2026-05-06T10:00:00, INFO, "user logged in", "main")
iterate destinations:
consoleDestination.write(record):
INFO >= DEBUG â continue
formatted = "2026-05-06T10:00:00 [INFO] [main] user logged in"
lock.acquire()
consoleSink.write(formatted) â line appears on stdout
lock.release()
fileDestination.write(record):
INFO < WARN â silent drop, return
Result: console wrote the line, file ignored it.
The record reaches the console because INFO clears the DEBUG threshold; the file destination filters it out because INFO is below WARN. Each destination decided independently using its own threshold.
Scenario 2. Two threads, same destination, contended write.
Thereâs a single file destination at minLevel = WARN, and Thread A and Thread B both call logger.error("...") at the same moment.
Thread A: Thread B:
log(ERROR, "A failed"): log(ERROR, "B failed"):
record_A = LogRecord(t1, ERROR, ...) record_B = LogRecord(t2, ERROR, ...)
fileDestination.write(record_A): fileDestination.write(record_B):
ERROR >= WARN â continue ERROR >= WARN â continue
formatted_A = format(record_A) formatted_B = format(record_B)
// both threads have formatted in parallel â safe, formatter is pure
lock.acquire() â Thread A wins
lock.acquire() â blocked, waits
sink.write(formatted_A)
lock.release()
lock.acquire() â unblocks
sink.write(formatted_B)
lock.release()
Result: both records hit the file in lock-acquisition order (A then B here).
No interleaved bytes â the lock made each sink.write atomic relative to
other sink.writes on the same destination.
The order on the wire reflects who got the lock first, not who called log() first. Thatâs fine. The requirements only ask that writes donât interleave or corrupt output, not that they preserve global call order across threads, and the per-destination lock guarantees exactly that. If an application needs strict per-destination sequencing, the async-queue extension above is what gets you there.
Scenario 3. Sink failure, other destinations unaffected.
We have both a console destination and a file destination at minLevel = DEBUG, but the disk has filled up so FileSink.write throws IOException on every call. The application calls logger.warn("disk space low") â a logger trying to write a disk-full warning to the disk thatâs full.
Logger.log(WARN, "disk space low"):
record = LogRecord(now(), WARN, "disk space low", "main")
iterate destinations:
consoleDestination.write(record):
WARN >= DEBUG â continue
formatted = format(record)
lock.acquire()
consoleSink.write(formatted) â "disk space low" appears on stdout
lock.release()
fileDestination.write(record):
WARN >= DEBUG â continue
formatted = format(record)
lock.acquire()
try: fileSink.write(formatted) â throws IOException
catch: stderr.write("logger: sink write failed: disk full")
finally: lock.release()
// returns normally â exception did not escape Destination.write()
Result: console line written, file line dropped, one diagnostic line on stderr,
application keeps running.
The console got the record. The file silently failed but emitted a stderr diagnostic so the failure isnât invisible. The application never saw an exception. Logger.log() finished its iteration normally because Destination.write() swallowed the failure inside its own try/catch. One destinationâs failure doesnât propagate to the others or to the caller.
Extensibility
If thereâs time left after implementation, interviewers often ask âwhat ifâ questions to see whether your design can evolve cleanly. You typically wonât implement these changes, youâll just explain where theyâd fit.
For a logger, the two most common follow-ups by far are async writes (the most visible production concern, and the one we explicitly scoped out in requirements) and hierarchical named loggers (every real framework has them, so anyone whoâs used Log4j, SLF4J, or Pythonâs logging is going to ask). Other natural extensions like hot-reload of config, log rotation, and per-message deduplication windows all slot in cleanly without rewriting the core model. The two below are the ones worth walking through in detail.
1. âHow would you make log() non-blocking?â
The current design holds a per-destination lock around sink.write(). Thatâs correct, but it means a slow file write or a flaky network destination blocks the calling thread for as long as the I/O takes. For a logger that fires from tens of thousands of call sites, that adds up to real latency in the application.
âIâd put a bounded blocking queue in front of each destinationâs sink.
log()enqueues the record and returns immediately, and a dedicated worker thread per destination drains the queue and does the actual write. Concurrent producers, single consumer per resource, which means the consumer side doesnât even need a lock anymore.â
âIâd put a bounded blocking queue in front of each destinationâs sink. log() enqueues the record and returns immediately, and a dedicated worker thread per destination drains the queue and does the actual write. Concurrent producers, single consumer per resource, which means the consumer side doesnât even need a lock anymore.â
class Destination:
- formatter, minLevel, sink
- queue: BlockingQueue<LogRecord> // bounded
- worker: Thread
+ Destination(formatter, minLevel, sink, capacity):
this.queue = new BlockingQueue(capacity)
this.worker = startThread(drain)
+ write(record):
if record.level < minLevel: return
queue.put(record) // blocks if full (or drop / throw)
- drain(): // runs on the worker thread
while running:
record = queue.take()
formatted = formatter.format(record)
sink.write(formatted) // single consumer, no lock needed
This is what Log4jâs AsyncAppender and Pythonâs QueueHandler ship in production. The benefits: caller latency drops to âenqueue an object,â each destination gets strict per-destination sequencing for free (single consumer pulls from a FIFO queue), batching is now possible if the worker pulls in chunks, and the bounded queue gives you an explicit place to define a backpressure policy.
Itâs not free, though. A good interviewer will push on three things.
Worker lifecycle. Each destination now owns a thread that runs for the life of the application. Shutdown is the tricky part â you have to signal the worker to stop, drain whateverâs already in the queue, and wait for it to finish before the process exits. Skip that and you lose every record that was buffered when the JVM (or whatever) died.
Overflow policy. A bounded queue forces a decision about what happens when it fills up. Youâve got four reasonable choices and each one is wrong for some workload. block the producer (which defeats the whole point of going async), drop the new record (which silently loses data right when somethingâs going wrong), drop the oldest record (same problem, different end of the queue), or throw an exception (which turns logging back into something callers have to catch). Most production loggers default to drop-newest with a stderr diagnostic, but the right answer depends on whether you care more about not losing records or not blocking callers.
Debuggability. The actual write now happens on a different thread than the call site. A stack trace at the moment of an I/O failure no longer points back to the code that emitted the record, which makes âwhy did this log line fail to write?â meaningfully harder to answer. You can mitigate by logging the recordâs call-site info into the diagnostic, but the gap is real.
Worth flagging in the interview that âasync writesâ and âthread-safe writesâ solve different problems. The lock is about correctness (no interleaved bytes on the wire). The queue is about coordination (donât block the caller). The two compose cleanly. A single-consumer queue per destination actually lets you drop the lock in this exact design, since only the worker thread ever touches the sink. Youâd add it back the moment two destinations share an underlying stream, but for the common case the queue is enough on its own.
2. âHow would you support hierarchical named loggers?â
Production loggers donât construct one global Logger and pass it around. They expose LoggerFactory.getLogger("com.app.service.payments"), and the returned logger inherits configuration from its parent in the dotted-name tree. The team that owns com.app.service can set its threshold once and have everything underneath pick it up unless overridden. If the candidate has used Log4j, SLF4J, or Pythonâs logging, theyâll recognize this immediately.
âIâd add a
namefield and aparentpointer toLogger, and put aLoggerFactoryin front of construction. The factory keeps a registry keyed by name, looks up the parent from the dotted prefix, and falls back to the root when none exists. Effective level and effective destinations walk the parent chain when theyâre not set on the logger itself.â
âIâd add a name field and a parent pointer to Logger, and put a LoggerFactory in front of construction. The factory keeps a registry keyed by name, looks up the parent from the dotted prefix, and falls back to the root when none exists. Effective level and effective destinations walk the parent chain when theyâre not set on the logger itself.â
Thatâs enough to land the question. Two things worth naming if asked:
- Caching.
log()is the hottest path in the system. Real frameworks cache the effective level on eachLoggerand invalidate it when configuration changes, instead of walking the parent chain on every call. - The factory is a deliberate global. The whole point of the registry is that two callers anywhere in the codebase asking for
getLogger("com.app.service")get back the same instance. Thatâs the rare case where shared application state is the requirement, not an accident.
What is Expected at Each Level?
So, as an interviewer, what am I looking for at each level?
Junior
At the junior level, Iâm checking whether you can break a one-sentence prompt into a working object model. You should land on a Logger that owns a list of destinations, an immutable LogRecord carrying timestamp, level, message, and thread name, and a LogLevel enum with the five required values. Your log() method should build a record and fan it out to every destination, and each destination should drop records below its threshold before writing. Basic error handling matters: a null message shouldnât crash, an out-of-range level shouldnât either. Itâs fine if your first cut couples format and destination together (a JsonFileDestination class, for example) and you only see the 2D explosion problem after I push on it.
Mid-level
For mid-level candidates, I expect the format-vs-destination split without much guidance. You should reach for composition the moment you hear âany format with any targetâ and pull Formatter into its own interface, and you should recognize that one Destination class composing a formatter and a write target beats NĂM subclasses. You should immediately see that LogRecord deserves to be its own immutable type so that adding a logger name or request ID later doesnât ripple through every method signature. I donât expect you to nail concurrency, but we should be able to have a conversation about it, and my hints should enable you to arrive at a reasonable solution.
Senior
Senior candidates should produce a design that demonstrates systems thinking. The class boundaries should be obvious without deliberation, and you should proactively walk through the inheritance-vs-composition tradeoff for Destination instead of waiting for me to ask. I expect you to land on a Sink interface (or call out the inheritance variant and explain why youâd still pick composition) and articulate why the high-level workflow class shouldnât depend on ConsoleSink or JsonFormatter directly. You should catch the time-of-capture decision yourself: that timestamp and thread name belong at the top of Logger.log() so every destination sees the same moment, not inside Destination.write(). On concurrency, you should reach per-destination locks without prompting, explain why a global lock on log() is the wrong default (a slow file write blocking instant console output), and call out that format happens outside the critical section because records are immutable and formatters are pure functions. On failure handling, you should propose swallowing per-destination exceptions with a stderr diagnostic so the logger never crashes the caller but doesnât fail silently either. Strong candidates finish early and can discuss how the design evolves for async writes (bounded queue per destination, overflow policy, worker lifecycle on shutdown) and hierarchical named loggers (the registry as the one defensible global, additivity along the parent chain, caching the effective level on the hot path).