Low Level Design: Core Classes in a Logging Library
Logging is a fundamental part of any backend or distributed system. A well-designed logging library makes debugging, monitoring, and tracing much easier. Here, we break down the core classes, their responsibilities, and show how you might implement them in Java.
1. Logger
Role: Main API for logging. Central entry point used by application code. Delegates to formatter, buffer, and writer.
Key Methods:
log(level, message, meta)
info(message, meta)
debug(message, meta)
error(message, meta)
Java Example:
public class Logger {
private final LogFormatter formatter;
private final LogWriter writer;
private final LogBuffer buffer;
private final LogConfig config;
public Logger(LogFormatter formatter, LogWriter writer, LogBuffer buffer, LogConfig config) {
this.formatter = formatter;
this.writer = writer;
this.buffer = buffer;
this.config = config;
}
public void info(String message, Map<String, Object> meta) {
log("INFO", message, meta);
}
public void debug(String message, Map<String, Object> meta) {
log("DEBUG", message, meta);
}
public void error(String message, Map<String, Object> meta) {
log("ERROR", message, meta);
}
public void log(String level, String message, Map<String, Object> meta) {
String formatted = formatter.format(level, message, meta);
buffer.add(formatted);
if (buffer.shouldFlush()) {
writer.write(buffer.flush());
}
}
}
2. LogFormatter
Role: Converts logs to structured format (e.g., JSON, plain text). Cleanly separates formatting, easy to support multiple formats.
Key Method:
format(level, message, meta) → String
Java Example:
public interface LogFormatter {
String format(String level, String message, Map<String, Object> meta);
}
public class JsonLogFormatter implements LogFormatter {
@Override
public String format(String level, String message, Map<String, Object> meta) {
// Use your favorite JSON library here
return String.format("{\"level\":\"%s\",\"msg\":\"%s\",\"meta\":%s}",
level, message, meta.toString());
}
}
3. LogBuffer
Role: Holds logs temporarily for async flush. Reduces write overhead and supports batching.
Key Methods:
add(log)
flush()
shouldFlush()
Java Example:
public class LogBuffer {
private final List<String> buffer = new ArrayList<>();
private final int flushInterval;
public LogBuffer(int flushInterval) {
this.flushInterval = flushInterval;
}
public void add(String log) {
buffer.add(log);
}
public boolean shouldFlush() {
return buffer.size() >= flushInterval;
}
public List<String> flush() {
List<String> logs = new ArrayList<>(buffer);
buffer.clear();
return logs;
}
}
4. LogWriter
Role: Writes logs to destination. Decouples destination (file, API, DB, etc.). Swappable.
Key Method:
write(logs)
Java Example:
public interface LogWriter {
void write(List<String> logs);
}
public class FileLogWriter implements LogWriter {
private final String filePath;
public FileLogWriter(String filePath) {
this.filePath = filePath;
}
@Override
public void write(List<String> logs) {
// Write logs to file (append mode)
try (FileWriter fw = new FileWriter(filePath, true)) {
for (String log : logs) {
fw.write(log + "\n");
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
5. TraceContext
Role: Injects traceId/requestId into logs. Enables log tracing across services.
Key Methods:
getContext()
setContext(traceId)
Java Example:
public class TraceContext {
private static final ThreadLocal<String> traceId = new ThreadLocal<>();
public static void setContext(String id) {
traceId.set(id);
}
public static String getContext() {
return traceId.get();
}
}
6. LogConfig
Role: Holds all config for the logger. Centralizes behavior, easy to tweak.
Key Fields:
level
outputMode
(file / console / http)format
(json / text)flushInterval
Java Example:
public class LogConfig {
public String level = "INFO";
public String outputMode = "file";
public String format = "json";
public int flushInterval = 5;
}
Class Flow Overview
Application Code → Logger → LogFormatter (format) → LogBuffer (buffer → flush) → LogWriter (write to file/API)
Example Usage:
LogConfig config = new LogConfig();
LogFormatter formatter = new JsonLogFormatter();
LogWriter writer = new FileLogWriter("app.log");
LogBuffer buffer = new LogBuffer(config.flushInterval);
Logger logger = new Logger(formatter, writer, buffer, config);
TraceContext.setContext("req-1234");
logger.info("User created", Map.of("userId", 42, "traceId", TraceContext.getContext()));
Best Practices for Logging Libraries
- Always include traceId/requestId for distributed tracing
- Support multiple output formats (JSON, text)
- Make log destination swappable (file, API, DB, etc.)
- Use buffering to reduce I/O overhead
- Allow dynamic config changes (log level, flush interval)
- Handle errors in logging gracefully (never crash the app)
- Document the API for easy integration
Summary
A robust logging library is modular, extensible, and easy to integrate. By separating concerns (formatting, buffering, writing, context), you enable flexibility and maintainability. The above design and Java examples provide a solid foundation for building your own logging solution or understanding how popular libraries (like Log4j, SLF4J) are structured.