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.

Real-world example: Every time a user logs into LinkedIn, dozens of log lines are written — the request arrived, authentication passed, profile loaded, response sent. If authentication fails, the log line says why. Without logging, debugging production issues is essentially guesswork.

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"
Why not just use a String like "INFO" or "ERROR"? Why assign integers to levels?
Integers enable comparison. You need to answer the question: "is this message important enough to log?" That question requires ordering — DEBUG is less important than ERROR. With integer values assigned to each level, you can compare: 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.

Why this matters in production: A busy service might handle 10,000 requests per second. If DEBUG logging is on, you could be writing millions of lines per second to disk — slowing down the app and filling up storage in minutes. In production, most services set the minimum level to INFO or WARN. DEBUG is only turned on temporarily when investigating a specific issue.

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.

Should LogRecord be mutable (setters) or immutable (final fields set in constructor)?
Immutable. A log entry is a snapshot of what happened at a specific moment. It should never change after creation. If it were mutable, a bug or a race condition could modify the record mid-flight as it travels from Logger to Destination to Formatter — corrupting your log data.
Who should capture the timestamp and thread name — the caller, the Logger, or LogRecord's constructor?
The LogRecord constructor itself. The constructor runs on whichever thread called it — which is always the caller's thread. So 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.
The multi-thread trap with mutable construction: Imagine building LogRecord in multiple steps using setters — 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.

Strategy Pattern: Define a family of algorithms (formatting strategies), put each one in its own class, and make them interchangeable. The caller (Destination) holds a reference to the interface — it never knows or cares which concrete formatter it has.

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.

Why keep Sink completely ignorant of formatting and levels?
Single responsibility. If Sink knew about formatting, you would need a new Sink class every time you wanted a new format. By keeping Sink dumb, you can combine any Formatter with any Sink freely. ConsoleSink + JsonFormatter, FileSink + TextFormatter, NetworkSink + JsonFormatter — any combination works without changing any class.
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.

Why are Sink and Formatter fields final?
A Destination's configuration is fixed at startup. You would never swap a FileSink for a ConsoleSink at runtime. 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:

  1. Receive a message and level from the caller
  2. Create a LogRecord
  3. 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); }
}
How you use it:
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

Observer Pattern: When something happens to me, I notify everyone who cares.

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.
Strategy Pattern: I know what to do, but I let someone else decide how.

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?

Each thread creates its own LogRecord. Is that a problem?
No. Each thread creates its own LogRecord on its own call stack. There is no sharing between threads here. No race condition, no problem.
All 200 threads call destination.send() on the same Destination, which eventually calls sink.write(). What is the race condition?
If two threads call System.out.println() at the exact same moment, their output can interleave. You might see:
[INFO] user [ERROR] DB down
logged in

Instead of two clean separate lines. This is called output smearing. The write operation is not atomic.
Where should the lock be placed — in Destination.send() or inside ConsoleSink.write()?
Inside ConsoleSink.write(). The Sink owns the shared resource — the console output stream. The lock must live with the resource it protects.

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 method vs synchronized block — what is the difference?
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.
When would you choose ReentrantLock over synchronized?
When you need features synchronized does not have:
  • tryLock(timeout) — try to get the lock, give up after N milliseconds instead of waiting forever
  • lockInterruptibly() — 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
For a simple Sink, synchronized is correct and cleaner. ReentrantLock shines in async queuing patterns.

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
What data structure should the queue be, and why not a plain ArrayList?
A plain ArrayList is not thread-safe. Two threads adding simultaneously can corrupt its internal array. 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.
LinkedBlockingQueue vs ArrayBlockingQueue — which is better for a logger?
LinkedBlockingQueue uses two separate locks — one for the head (consumer) and one for the tail (producers). Producers adding records and the consumer reading records never block each other since they operate on opposite ends.

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.
Why set a capacity bound on the queue?
Without a bound, if producers are faster than the consumer, the queue grows forever and eventually causes an OutOfMemoryError. A bound caps memory usage. When the queue is full, use 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.
Why does the background thread use queue.take() instead of polling in a loop?
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.
How many background consumer threads do you need?
One per Destination. Since 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.
Where is the background thread created?
In the Destination constructor. Destination owns the queue, so it owns the thread that drains it. The thread is born when the Destination is created and runs for the application's lifetime.

13. Full Architecture Summary

Complete flow for logger.info("user logged in"):

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 queue
6. 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.