Definition
- Logger – holds a minimum
LogLevelthreshold; discards messages below the threshold cheaply. - LogLevel – ordered enum: DEBUG, INFO, WARN, ERROR.
- Appender – interface abstracting the destination (console, file, remote); a Strategy for where logs go.
- Formatter – turns a level, timestamp, and message into a formatted output line.
- LoggerFactory – hands out loggers, typically cached per name as a Singleton.
- Thread safety – appenders must synchronise writes or use a lock-free queue for concurrent logging.
Think of the logging framework as the audit department at National Bank of India. Every action Rajesh takes is reported. The department decides which reports are important enough to keep (level threshold), stamps each with a standard header (formatter), and files copies to several places: a local binder, a central archive, and a monitoring desk (appenders). Many tellers report at once, so the filing must be orderly and safe (thread safety).
Understanding the Concepts
What are the core entities?
The core entities are Logger, LogLevel, Appender, Formatter, and LoggerFactory.
package com.bank.logging;
public enum LogLevel {
DEBUG(0), INFO(1), WARN(2), ERROR(3);
private final int severity;
LogLevel(int severity) {
this.severity = severity;
}
public boolean isEnabled(LogLevel threshold) {
return this.severity >= threshold.severity;
}
}
How does the Logger work?
The Logger holds a minimum threshold and a list of appenders. Messages below the threshold are discarded cheaply.
package com.bank.logging;
import java.util.List;
import java.util.ArrayList;
import java.util.function.Supplier;
public class Logger {
private final String name;
private LogLevel threshold;
private final List<Appender> appenders;
private Formatter formatter;
public Logger(String name, LogLevel threshold,
Formatter formatter) {
this.name = name;
this.threshold = threshold;
this.appenders = new ArrayList<>();
this.formatter = formatter;
}
public void addAppender(Appender appender) {
appenders.add(appender);
}
public void log(LogLevel level, Supplier<String> messageSupplier) {
if (!level.isEnabled(threshold)) return;
String formatted = formatter.format(
level, name, messageSupplier.get());
for (Appender appender : appenders) {
appender.append(formatted);
}
}
public void debug(Supplier<String> msg) {
log(LogLevel.DEBUG, msg);
}
public void info(Supplier<String> msg) {
log(LogLevel.INFO, msg);
}
public void warn(Supplier<String> msg) {
log(LogLevel.WARN, msg);
}
public void error(Supplier<String> msg) {
log(LogLevel.ERROR, msg);
}
}
Why use supplier lambdas for messages?
Passing messages as a Supplier defers string building until the level is confirmed enabled. In a hot transfer path at National Bank of India, building log strings that get thrown away is pure waste.
// Efficient: string only built if INFO is enabled
logger.info(() -> "Transfer " + amount + " from " + from
+ " to " + to);
// Wasteful: string always built, even if disabled
logger.info("Transfer " + amount + " from " + from
+ " to " + to);
How does the Appender interface work?
The Appender is a Strategy for where logs go. Adding a new destination is a new class.
package com.bank.logging;
public interface Appender {
void append(String formattedMessage);
}
package com.bank.logging;
public class ConsoleAppender implements Appender {
@Override
public synchronized void append(String formattedMessage) {
System.out.println(formattedMessage);
}
}
package com.bank.logging;
import java.io.*;
public class FileAppender implements Appender {
private final PrintWriter writer;
public FileAppender(String filePath) throws IOException {
this.writer = new PrintWriter(
new FileWriter(filePath, true));
}
@Override
public synchronized void append(String formattedMessage) {
writer.println(formattedMessage);
writer.flush();
}
}
How does the Formatter work?
The Formatter turns level, timestamp, logger name, and message into a formatted line. It is pluggable independently of the appender.
package com.bank.logging;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
public class SimpleFormatter implements Formatter {
private static final DateTimeFormatter TS =
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
@Override
public String format(LogLevel level, String loggerName,
String message) {
return LocalDateTime.now().format(TS)
+ " [" + level + "] "
+ loggerName + " - " + message;
}
}
package com.bank.logging;
public interface Formatter {
String format(LogLevel level, String loggerName, String message);
}
2025-01-15 14:30:22 [INFO] TransferService - Transfer 50000 from RAJESH-001 to PRIYA-002
How does LoggerFactory work?
A LoggerFactory hands out loggers, typically cached per name as a Singleton.
package com.bank.logging;
import java.util.concurrent.ConcurrentHashMap;
import java.util.Map;
public class LoggerFactory {
private static final Map<String, Logger> LOGGERS =
new ConcurrentHashMap<>();
private static LogLevel defaultThreshold = LogLevel.INFO;
private static Formatter defaultFormatter = new SimpleFormatter();
public static Logger getLogger(String name) {
return LOGGERS.computeIfAbsent(name,
n -> new Logger(n, defaultThreshold, defaultFormatter));
}
public static void setDefaultThreshold(LogLevel level) {
defaultThreshold = level;
}
}
How do you handle thread safety?
Thread safety is non-negotiable because many threads log concurrently. Each appender's append() method is synchronised. For high-throughput systems, use an asynchronous appender backed by a ring buffer.
package com.bank.logging;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;
public class AsyncAppender implements Appender {
private final Appender delegate;
private final BlockingQueue<String> queue;
private final Thread worker;
public AsyncAppender(Appender delegate) {
this.delegate = delegate;
this.queue = new LinkedBlockingQueue<>(10000);
this.worker = new Thread(() -> {
while (!Thread.currentThread().isInterrupted()) {
try {
String msg = queue.take();
delegate.append(msg);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}, "async-log-worker");
worker.setDaemon(true);
worker.start();
}
@Override
public void append(String formattedMessage) {
queue.offer(formattedMessage); // non-blocking
}
}
What about extensibility?
Switching from plain text to structured JSON logs is a new Formatter class. Adding a Kafka appender is a new Appender class. Neither requires changing the Logger.
What should a senior engineer know?
- Logging must be both cheap when disabled (supplier lambdas) and safe under heavy concurrency (synchronised or async appenders).
- In production banking systems, an async appender backed by a ring buffer (like LMAX Disruptor) ensures the business thread never blocks on I/O.
- The MDC (Mapped Diagnostic Context) pattern adds per-thread context like transaction ID to every log line without passing it explicitly.
- Log rotation, compression, and retention policies are real production concerns.
What are common interview follow-up questions?
- How do you ensure the async appender does not lose logs on shutdown?
- How would you add structured JSON logging?
- How do you implement log rotation?
- How does the MDC pattern work for adding transaction IDs?
- What is the performance impact of synchronised vs async appenders?
Visual Overview
Logging Pipeline:
─────────────────
logger.info(() -> "message")
│
▼
Level check: INFO >= threshold?
│
├── no → discard (cheap, no string built)
│
└── yes → build string from supplier
│
▼
Formatter.format(level, name, message)
│
▼
┌─────┴──────────────┐
▼ ▼
ConsoleAppender FileAppender
(synchronized) (synchronized)
Logger
│
├── threshold: INFO
├── formatter: SimpleFormatter
└── appenders:
├── ConsoleAppender
├── FileAppender (/var/log/bank.log)
└── AsyncAppender → RemoteAppender (Kafka)
Key Takeaway
When you design a logging framework, separate four responsibilities so each can evolve independently. The Logger decides whether a message passes the configured level threshold. The Formatter decides how a line looks. The Appender decides where it goes. And a LoggerFactory hands out loggers that share configuration. This separation is what lets a framework support console in development and file plus remote in production without rewriting the core.
Levels are ordered from debug up to error, and the Logger holds a minimum threshold so anything below it is discarded cheaply. A key efficiency trick is accepting the message as a supplier lambda, so the string is only built when the level is actually enabled. In a hot transfer path at National Bank of India, building log strings that get thrown away is pure waste.
The Appender is an interface with implementations for console, file, and remote destinations, which is the Strategy pattern. Adding a new sink like a database or Kafka is a new class rather than a change to the Logger. The Formatter is pluggable the same way, so switching from plain text to structured JSON is a configuration choice.
Thread safety is non-negotiable because many transfer threads log at the same instant, and the log file must never get garbled. Synchronise appender writes for correctness, and in high-throughput services move to an asynchronous appender backed by a ring buffer so the business thread never blocks on I/O. The senior insight is that logging must be both cheap when disabled and safe under heavy concurrency, which is exactly why supplier lambdas and async appenders are essential.
Remember one sentence: a logging framework filters by level, formats through a pluggable formatter, fans out to strategy-based appenders, and must be both cheap when disabled and safe under concurrency.
---