Logging Framework: Pluggable Sinks via Strategy
A logging library reduces to one filtering gate in front of a set of interchangeable output sinks, but the reference solution below stops at that gate — it stops short of the single global access point and the thread safety a logger actually needs once real threads start calling it.
5.1Problem Statement
Design a reusable logging library for application developers to embed in their own code — think a lightweight log4j. Call sites just say "log this," and everything about where the message ends up and whether it survives is configured once, elsewhere.
Functional requirements:
- Log levels with a strict ordering:
DEBUG < INFO < WARN < ERROR. Any message below the configured threshold is dropped, silently. - Pluggable output sinks — console, file, and eventually network — switchable without touching a single call site.
- A single global access point for the logger, configured once at startup: level, sinks, format.
- Thread-safe writes — multiple threads logging concurrently must never interleave the bytes of a single message.
Non-functional constraints, which name the patterns outright:
- The logger is a Singleton — one shared instance, reached the same way from anywhere in the process.
- Sinks are a Strategy (an
Appenderinterface) so output targets swap freely without changing the logger. - Level filtering can be modeled as a Chain of Responsibility of level handlers, each answering "is this loud enough for me?"
- A
Formatterabstraction controls the rendered layout of a message — a second, smaller Strategy, orthogonal to the sink.
The expected public interface, given up front so the design effort goes into behavior, not signatures:
enum class LogLevel { DEBUG, INFO, WARN, ERROR };
struct LogMessage { LogLevel level; std::string text; /* timestamp, thread id ... */ };
class Appender { // Strategy
public:
virtual void append(const LogMessage&) = 0; // ConsoleAppender, FileAppender
virtual ~Appender() = default;
};
class Logger {
public:
static Logger& getInstance(); // Singleton
void log(LogLevel level, const std::string& msg);
void setLevel(LogLevel level);
void addAppender(std::unique_ptr<Appender>);
private:
Logger() = default; // non-copyable, non-movable
};
What the interviewer is really testing:
- Three patterns cooperating in one small surface — Singleton, Strategy, and Chain of Responsibility — without any of them entangling the others.
- That you mention thread safety explicitly: a mutex around the append path, or a producer-consumer queue feeding a background writer.
- A
Formatterabstraction, separate from the sink, earns bonus points — it proves you keep "how it's rendered" and "where it goes" as two different axes of change.
Follow-up questions to expect once the base design stands:
- Async logging — hand messages to a background flush thread over a producer-consumer queue, so a slow sink doesn't block every caller.
- Log rotation by file size or date.
- Per-module loggers with independent levels and a parent/child fallback hierarchy, mirroring
log4j.
5.2How to Approach
The Strategy Half: an Appender Interface
Start from the requirement that reads loudest: sinks must be "switchable without touching call sites." That sentence is the Strategy tell — one interface, N interchangeable implementations, and callers who never know which one is plugged in. The reference solution below gets exactly this piece right. LogAppender declares a single method, append(const LogMessage&); ConsoleAppender writes to cout, FileAppender writes to an ofstream opened in append mode, and Logger holds a vector<shared_ptr<LogAppender>> it iterates blindly. Adding a NetworkAppender later is one new subclass; nothing about Logger::log changes. That's the Strategy payoff, delivered.
Level Filtering, the Plain Version
The severity gate is a single guard clause: if (!isEnabled(level)) return;, where isEnabled compares the message's level against a stored minLevel using static_cast<int>(level) >= static_cast<int>(minLevel). This is the cheapest possible implementation of "messages below threshold are dropped" — no handler objects, no chain to walk, just one comparison before the fan-out to sinks. It works, and for a single flat threshold it's arguably the right amount of machinery. It just isn't the Chain of Responsibility the problem doc asks for, which matters the moment you want per-level routing (say, ERROR also pages someone) without touching log() again.
Singleton — asked for, not built. The expected interface names static Logger& getInstance() with a private constructor. The actual Logger is a plain, publicly-constructible class instantiated directly in main() — Logger logger("MyApp");. Nothing stops a second instance from existing, so "single global access point" isn't enforced by the type, only by convention. Bolting it on is small and mechanical: make the constructor private, delete copy/move, and add a Meyers singleton accessor —
static Logger& getInstance() { static Logger instance("app"); return instance; }. The C++11 standard guarantees that function-local static initializes exactly once, even under concurrent first calls, so this is the fix and not a redesign.
Chain of Responsibility — asked for, not built. Level filtering here is one if inside Logger::log, not a chain of handler objects each owning a threshold and a next pointer. For a single flat cutoff the two are behaviorally identical. The gap shows up the moment a requirement wants per-level behavior — e.g. ERROR notifications on top of the normal sinks. A real LevelHandler node (threshold + next, handle() dispatches if the message clears its bar, else forwards down the chain) turns that into inserting one new link, rather than adding a second if to log().
Thread safety — asked for, not built. There is no std::mutex anywhere in this file, and none of the containers (vector<shared_ptr<LogAppender>>, the ofstream inside FileAppender) are safe for concurrent writers. Two threads calling log() at once can genuinely interleave the bytes of two messages inside ostream::operator<<. The fix is one std::mutex guarding the fan-out loop in log() (a std::lock_guard taken right before the for (appender : appenders) pass), so "render + dispatch to every sink" for one message becomes atomic. Nothing else in the class needs to change.
Bonus finding, not a scope cut. Compiling and running this file turns up a real bug, not a missing feature: enum class LogLevel { TRACE, ERROR, WARN, INFO, DEBUG } assigns the underlying ints in declaration order — TRACE=0, ERROR=1, WARN=2, INFO=3, DEBUG=4 — which is not the DEBUG < INFO < WARN < ERROR severity order the problem statement asks for. Since isEnabled compares those raw ints against a default threshold of INFO (3), the actual run drops WARN (2 < 3) and ERROR (1 < 3) — the two most severe levels — while letting DEBUG (4 >= 3) through despite the driver's own comment claiming it should be "filtered out, below INFO." Running the program confirms it: the console shows the DEBUG line and the INFO line, but "Low disk space" and "An error occurred" never print at all. The one-line fix is declaring the enum in actual severity order — enum class LogLevel { DEBUG, INFO, WARN, ERROR }, exactly as the problem doc's expected interface specifies.
None of this changes the shape of the design — it's the same three axes (who do I call, is it loud enough, where does it go) the reasoning always pointed at. What's missing is the layer of ceremony around that core: enforcing single-instance-ness at the type level, generalizing one if into a chain that scales to per-level routing, and a lock around the one place where concurrent callers actually touch shared state.
5.3Whole Solution
The reference solution below is the working first pass described above: a LogAppender Strategy with console and file sinks, plus flat level-threshold filtering. It compiles and runs as shown — no getInstance(), no handler chain, no mutex, and the enum-ordering bug called out above is present exactly as written.
#include <bits/stdc++.h>
using namespace std;
enum class LogLevel {
TRACE,
ERROR,
WARN,
INFO,
DEBUG
};
string toString(LogLevel level){
switch(level){
case LogLevel :: TRACE : return "TRACE";
case LogLevel :: ERROR : return "ERROR";
case LogLevel :: WARN : return "WARN";
case LogLevel :: INFO : return "INFO";
case LogLevel :: DEBUG : return "DEBUG";
default: return "UNKNOWN";
}
}
class LogMessage{
private:
LogLevel level;
string message;
string source;
string timestamp;
public:
LogMessage(LogLevel level, const string& message, const string& source){
this->level = level;
this->message = message;
this->source = source;
time_t now = time(nullptr);
ostringstream oss;
oss << put_time(localtime(&now), "%Y-%-m%-d %H:%M:%S");
this->timestamp = oss.str();
}
string getFormattedMessage() const{
return "[" + timestamp + "] [" + toString(level) + "] [" + source + "] " + message;
}
};
class LogAppender{
public:
virtual ~LogAppender(){}
virtual void append(const LogMessage& message) = 0;
};
class ConsoleAppender : public LogAppender{
public:
void append(const LogMessage& message){
cout << message.getFormattedMessage() << endl;
}
};
class FileAppender : public LogAppender{
private:
ofstream file;
public:
FileAppender(const string& filename){
file.open(filename, ios::app);
}
~ FileAppender(){
if(file.is_open()){
file.close();
}
}
void append(const LogMessage& message){
if(file.is_open()){
file << message.getFormattedMessage() << endl;
}
}
};
class Logger{
private:
string name;
LogLevel minLevel;
vector<shared_ptr<LogAppender>> appenders;
bool isEnabled(LogLevel level){
return static_cast<int>(level) >= static_cast<int>(minLevel) ;
}
public:
Logger(const string& name, LogLevel minLevel = LogLevel::INFO){
this->name = name;
this->minLevel = minLevel;
}
void addAppender(shared_ptr<LogAppender> appender){
appenders.push_back(appender);
}
void setMinLevel(LogLevel level){
minLevel = level;
}
void log(LogLevel level, const string& message){
if(!isEnabled(level)){
return;
}
LogMessage record(level, message, name);
for(const auto& appender: appenders){
appender->append(record);
}
}
void trace(const string& message) { log(LogLevel::TRACE, message); }
void debug(const string& message) { log(LogLevel::DEBUG, message); }
void info (const string& message) { log(LogLevel::INFO, message); }
void warn (const string& message) { log(LogLevel::WARN, message); }
void error(const string& message) { log(LogLevel::ERROR, message); }
};
int main(){
Logger logger("MyApp");
logger.addAppender(make_shared<ConsoleAppender>());
logger.addAppender(make_shared<FileAppender>("app.log"));
logger.debug("filtered out, below INFO");
logger.info ("Application started");
logger.warn ("Low disk space");
logger.error("An error occurred");
logger.setMinLevel(LogLevel::DEBUG);
logger.debug("Now visible");
return 0;
}
How would you make this logger thread-safe without touching any call site?
Add one std::mutex as a member of Logger and take a std::lock_guard at the top of log(), covering the level check, the LogMessage construction, and the whole fan-out loop over appenders. That makes "render this message and hand it to every sink" atomic per call, so two threads logging at once can never interleave the bytes of a single line. addAppender and setMinLevel should take the same lock, since a thread reconfiguring the logger while another is mid-log is exactly the same race. None of ConsoleAppender, FileAppender, or any call site needs to change — the fix is entirely inside Logger.
How would you add a network sink without touching Logger at all?
Write a new NetworkAppender : public LogAppender that implements append(const LogMessage&) by shipping message.getFormattedMessage() over a socket or HTTP call, then wire it in at the call site with logger.addAppender(make_shared<NetworkAppender>(host, port)). That's the entire change. Logger::log only ever calls appender->append(record) through the base interface, so it has no idea whether it's talking to a console, a file, or a socket — which is exactly the "switchable without touching call sites" requirement the Strategy pattern exists to satisfy.
This code has a level-filtering bug. Where is it, and how do you fix it?
The enum LogLevel { TRACE, ERROR, WARN, INFO, DEBUG } assigns underlying ints in declaration order — TRACE=0, ERROR=1, WARN=2, INFO=3, DEBUG=4 — which does not match the intended severity order DEBUG < INFO < WARN < ERROR. Since isEnabled filters by comparing those raw ints against a default threshold of INFO, running the program actually drops WARN and ERROR (the two levels that should never be silently dropped at an INFO threshold) while letting DEBUG through. The fix is purely in the enum declaration: list the levels in true ascending severity, enum class LogLevel { DEBUG, INFO, WARN, ERROR }, exactly as the problem statement's expected interface specifies. No other line of the class needs to change, because isEnabled's comparison logic was correct all along — it was only ever as good as the ordering it was handed.