1. What is a Logger Service and Why Does Every App Need One?
Imagine you deploy a payment service to production. A user reports their transaction failed. You have no idea why. The database is up, the servers are running — but something went wrong somewhere. How do you find out what happened?
This is exactly why logging exists. A logger service records events as your application runs — what happened, when it happened, and on which thread. These records are written to a console, a file, or a remote storage system so engineers can read them later when something goes wrong.
A good logger service needs to answer three questions at any point in time:
- What happened? — the message
- How serious was it? — the level (INFO, ERROR, etc.)
- When and where? — the timestamp and the thread name
2. What Are Log Levels and Why Do They Exist?
Not all log messages are equally important. A message that says "user profile loaded successfully" is very different from "database connection lost". Log levels let you express that difference.
| Level | What it means | Example |
|---|---|---|
DEBUG |
Detailed internal information, useful during development | "Entering method getUserById with id=42" |
INFO |
Normal application events worth recording | "User 42 logged in successfully" |
WARN |
Something unexpected happened but the app recovered | "Retry attempt 2 of 3 for payment API" |
ERROR |
Something failed — action required | "Payment failed: timeout after 5000ms" |
FATAL |
The application cannot continue | "Database connection pool exhausted — shutting down" |
incomingLevel >= minimumLevel. You cannot do that with plain strings.
public enum LogLevel {
DEBUG(1), INFO(2), WARN(3), ERROR(4), FATAL(5);
private final int level;
LogLevel(int level) { this.level = level; }
int getLevel() { return level; }
public boolean isMinimumLevel(LogLevel threshold) {
return this.level >= threshold.getLevel();
}
}
So ERROR.isMinimumLevel(WARN) returns true. DEBUG.isMinimumLevel(INFO) returns false — filtered out.
3. The Minimum Level Config — How Real Services Use Levels
Here is something every real production service does: it configures a minimum log level. Any message below that level is silently ignored.
This is configured per destination. You might have:
- Console → minimum level INFO (normal operation visibility)
- File → minimum level DEBUG (full detail for investigation)
- Alert system → minimum level ERROR (only page on-call for serious issues)
The same log message can go to some destinations and be ignored by others — all based on each destination's configured minimum level.
4. Designing the Core Data — LogRecord
Before writing any logic, we need a data container that represents a single log entry. This is the LogRecord.
Thread.currentThread().getName() gives the correct thread name, and System.currentTimeMillis() gives the exact moment the log was created. If the caller passes these values in, nothing stops them from passing stale or incorrect values.
record.setTimestamp() then record.setThread() then record.setMessage(). If Thread A is partway through this sequence and the CPU switches to Thread B, Thread A's record could end up with Thread B's values. Immutability with constructor-captured values eliminates this class of bug entirely.
@Getter
public class LogRecord {
private final long timeStamp;
private final String threadName;
private final String message;
private final LogLevel logLevel;
LogRecord(String message, LogLevel logLevel) {
this.message = message;
this.logLevel = logLevel;
this.threadName = Thread.currentThread().getName();
this.timeStamp = System.currentTimeMillis();
}
}
The @Getter annotation (Lombok) generates getters for all fields. No setters — intentionally. Once created, a LogRecord is frozen.
5. Formatter — The Strategy Pattern
Different destinations need the log in different formats. A file might need JSON so it can be parsed by a log aggregator. A console might need plain readable text. A monitoring system might need XML.
We model this with a Formatter interface. Every formatter takes a LogRecord and returns a String — the formatted output ready to write.
Analogy: your GPS knows it must give directions. You pick the strategy — fastest route, avoid tolls, scenic. Same GPS, swappable strategy. Swap the strategy without changing the GPS.
public interface Formatter {
String format(LogRecord logRecord);
}
// Plain text: [2026-06-09 10:23:01] [thread-1] INFO - user logged in
public class TextFormatter implements Formatter {
@Override
public String format(LogRecord record) {
return "[" + record.getTimeStamp() + "] "
+ "[" + record.getThreadName() + "] "
+ record.getLogLevel() + " - "
+ record.getMessage();
}
}
// JSON: {"timestamp":...,"thread":"thread-1","level":"INFO","message":"user logged in"}
public class JsonFormatter implements Formatter {
@Override
public String format(LogRecord record) {
return "{\"timestamp\":" + record.getTimeStamp()
+ ",\"thread\":\"" + record.getThreadName() + "\""
+ ",\"level\":\"" + record.getLogLevel() + "\""
+ ",\"message\":\"" + record.getMessage() + "\"}";
}
}
6. Sink — The Physical Writer
A Sink is the component that actually writes bytes somewhere — console, file, network socket. It is intentionally kept thin. A Sink knows nothing about log levels or formatting. It receives a ready-made String and writes it. That is its entire job.
public interface Sink {
void write(String formatted);
}
public class ConsoleSink implements Sink {
@Override
public void write(String formatted) {
synchronized (this) {
System.out.println(formatted);
}
}
}
The synchronized(this) block is critical — we will explain exactly why in the concurrency section below.
7. Destination — Putting It All Together
A Destination is the coordinator. It composes three things:
- A minimum LogLevel — the threshold filter
- A Formatter — how to format the record
- A Sink — where to write it
Example: ERROR threshold + JsonFormatter + FileSink = only ERROR and above, in JSON, written to a file.
final communicates intent — these are not meant to change — and prevents accidental reassignment.
public class Destination {
private final LogLevel level;
private final Sink sink;
private final Formatter formatter;
private final LinkedBlockingQueue<LogRecord> queue = new LinkedBlockingQueue<>(1000);
Destination(LogLevel level, Sink sink, Formatter formatter) {
this.level = level;
this.sink = sink;
this.formatter = formatter;
Thread consumer = new Thread(() -> {
while (true) {
try {
LogRecord record = queue.take();
if (record.getLogLevel().isMinimumLevel(level))
sink.write(formatter.format(record));
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
}
}
});
consumer.start();
}
public void send(LogRecord record) {
queue.offer(record); // non-blocking: drops record if queue is full
}
}
8. Logger — The Entry Point
Logger is what your application code actually calls. It is the public face of the entire system. Its job is simple:
- Receive a message and level from the caller
- Create a LogRecord
- Broadcast it to every registered Destination
Logger does not filter. It does not care about levels. It just creates and broadcasts. Each Destination decides for itself whether to act.
public class Logger {
private final List<Destination> destinations;
Logger(List<Destination> destinations) {
this.destinations = destinations;
}
private void log(LogLevel level, String message) {
LogRecord record = new LogRecord(message, level);
for (Destination destination : destinations) {
destination.send(record);
}
}
public void info(String message) { log(LogLevel.INFO, message); }
public void error(String message) { log(LogLevel.ERROR, message); }
public void debug(String message) { log(LogLevel.DEBUG, message); }
public void warn(String message) { log(LogLevel.WARN, message); }
}
Destination consoleDestination = new Destination(LogLevel.INFO, new ConsoleSink(), new JsonFormatter());
Destination fileDestination = new Destination(LogLevel.DEBUG, new FileSink("app.log"), new TextFormatter());
Logger logger = new Logger(List.of(consoleDestination, fileDestination));
logger.info("User logged in"); // goes to both
logger.debug("Cache miss"); // file only (below INFO for console)
logger.error("DB timeout"); // goes to both
9. Design Patterns Used — Observer and Strategy
Logger = Subject. It holds a list of Destinations and broadcasts every LogRecord to all of them.
Destinations = Observers. Each one reacts independently based on its own threshold.
Analogy: a YouTube channel (subject) uploads a video. All subscribers (observers) get notified. YouTube does not know or care what each subscriber does — watch it, ignore it, share it. Not YouTube's problem. Each subscriber decides independently.
Formatter is a strategy — Destination knows it must format, but delegates how to whatever Formatter was injected. Swap JsonFormatter for TextFormatter without touching Destination.
Sink is a strategy — Destination knows it must write, but delegates where to whatever Sink was injected. Swap ConsoleSink for FileSink without touching Destination.
Analogy: your GPS knows it must give directions. You pick the strategy — fastest, avoid tolls, scenic. Same GPS. Swappable strategy.
10. Concurrency — Where Does the Lock Live?
Your application runs with many threads simultaneously. A web server might have 200 HTTP threads all calling logger.info() at the same time. What happens?
System.out.println() at the exact same moment, their output can interleave. You might see:[INFO] user [ERROR] DB downlogged inInstead of two clean separate lines. This is called output smearing. The write operation is not atomic.
If you lock inside Destination, and two separate Destination objects share the same ConsoleSink, each Destination locks on its own
this — two different locks. They do not protect each other. The race condition remains.
synchronized on a method locks the entire method using this as the monitor. A synchronized(this) block lets you lock only the specific critical section — tighter scope, better throughput.In ConsoleSink the only critical operation is println, so both are equivalent here. But in a FileSink that opens a connection, seeks, then writes — you want the lock only around the write, not the setup. Tighter scope = more concurrency.
tryLock(timeout)— try to get the lock, give up after N milliseconds instead of waiting foreverlockInterruptibly()— allow a waiting thread to be cancelled- Multiple condition queues — e.g. "not full" and "not empty" conditions in a bounded queue, where producers wait on one condition and consumers wait on another
11. The Problem With Synchronous Logging
In the synchronous version, the caller thread (your HTTP thread) does everything — creates the record, formats it, and waits for the sink to finish writing. If the sink is a slow network or a busy file on disk, the caller thread is blocked the entire time.
Thread-1: [business logic: 5ms] [waiting for FileSink lock: 50ms] = 55ms total
Thread-2: [business logic: 5ms] [waiting for FileSink lock: 50ms] = 55ms total
...200 threads all queueing for the same lock
In a server handling 10,000 requests per second, logging can become the bottleneck. Threads pile up waiting for the lock. Latency climbs. All because of logging — a side concern that should never impact core business logic.
12. Async Logging — The BlockingQueue Solution
The fix: decouple the caller from the writer using a queue.
- Caller thread drops the LogRecord into a LinkedBlockingQueue and immediately returns
- A dedicated background thread drains the queue and calls sink.write()
Thread-1: [business logic: 5ms] [queue.offer: 0.1ms] = 5.1ms total ✓
Thread-2: [business logic: 5ms] [queue.offer: 0.1ms] = 5.1ms total ✓
...200 threads all free
Background thread: drains queue, writes to sink at its own pace
LinkedBlockingQueue is built for concurrent producers and consumers — thread-safe, FIFO ordering preserved, and the consumer thread blocks (sleeps) when the queue is empty instead of spinning and burning CPU.
ArrayBlockingQueue uses a single lock for both operations — a producer must wait if the consumer is active, even though they are touching different ends.
For a logger with many producer threads and one consumer, LinkedBlockingQueue gives better throughput.
queue.offer(record) — it returns false and drops the record instead of blocking the caller. The newest record is dropped. Records already in the queue are more valuable.
queue.take() blocks — the thread sleeps when the queue is empty and wakes up the instant a record arrives. Zero CPU waste.A polling loop (
while (!queue.isEmpty())) keeps checking constantly even when there is nothing to do, burning a full CPU core for no reason.
sink.write() is synchronized anyway, multiple consumer threads would just queue at that lock — no real parallelism gained. One thread keeps it simple and maintains write order.
13. Full Architecture Summary
1. Logger creates an immutable LogRecord — captures message, level, timestamp, thread name
2. Logger broadcasts to all Destinations via send() — Observer pattern
3. Each Destination puts the record into its own LinkedBlockingQueue
4. Caller thread is now free — returns immediately
5. Each Destination's background thread
take()s from its queue6. Background thread checks LogLevel threshold — skips if below minimum
7. Calls Formatter.format() — Strategy pattern — produces a formatted String
8. Calls Sink.write() —
synchronized block ensures one thread writes at a time
| Component | Responsibility | Pattern | Key Design Choice |
|---|---|---|---|
LogRecord |
Immutable snapshot of one log event | Value Object | Constructor captures thread + timestamp |
LogLevel |
Ordered severity enum | — | Integer comparison enables filtering |
Formatter |
Converts LogRecord to String | Strategy | Interface — swap JSON/Text without changes |
Sink |
Writes String to a physical target | Strategy | Thin interface — knows nothing about levels |
Destination |
Composes threshold + Formatter + Sink | Observer (receiver) | Owns queue + background thread |
Logger |
Creates LogRecord, broadcasts to all Destinations | Observer (subject) | No filtering — just broadcast |
LinkedBlockingQueue |
Decouples caller from writer | Producer-Consumer | Two locks — producers and consumer never block each other |
Written as an LLD interview prep walkthrough — covering immutability, Strategy pattern, Observer pattern, synchronized blocks, ReentrantLock, LinkedBlockingQueue internals, and async producer-consumer logging architecture.