Singleton to Dependency Injection: A C++ Refactor
Notes from the v0.4.x refactor — why MantisBase is deleting its most convenient global.
Every codebase has a decision that felt obviously correct on day one and quietly turns into a debt you pay forever. For MantisBase, that decision was making the application object a singleton.
It made sense at the time. There is exactly one server, listening on exactly one port, owning one database pool, one router, and one logger. Why wouldn’t there be one global handle to reach it all? For most of v0.1 through v0.3, that instinct served us well. In v0.4.x we are pulling the cord, replacing MantisBase::instance() with plain old dependency injection. This post is about why, and it’s worth starting with what a singleton actually is before explaining why we’re purging ours.
A quick detour through design patterns
If you’ve written software for any length of time you’ve absorbed design patterns whether or not you’ve read the book Design Patterns: Elements of Reusable Object-Oriented Software (Gamma, Helm, Johnson & Vlissides, 1994). Patterns are not language features or libraries; they’re names for shapes that keep recurring. Having a shared vocabulary is genuinely useful: “wrap it in a decorator” communicates a whole structure in three words.
And then there’s Singleton.
The Singleton pattern “ensures a class has only one instance and provides a global point of access to it.” Two promises in one sentence, and the second one is the problem. It’s the only creational pattern in the book that most of its own authors would now tell you to avoid. In a 2009 interview, Erich Gamma — one of the four — said plainly:
“When discussing which patterns to drop, we found that we still love them all. (Not really, I’m in favor of dropping Singleton. Its use is almost always a design smell.)”
— Erich Gamma
The reason he gives is the reason this post exists.
How MantisBase became a singleton
MantisBase is an embeddable backend, a BaaS you link into a C++ process and drive. The MantisBase class is the composition root: it owns every subsystem and hands them out.
class MANTISBASE_API MantisBase
{
public:
static MantisBase& instance();
static MantisBase& create(const json& config = json::object());
[[nodiscard]] Database& db() const;
[[nodiscard]] Router& router() const;
[[nodiscard]] Logger& logs() const;
[[nodiscard]] RealtimeDB& rt() const;
// ...
private:
MantisBase(); // creation is private
static MantisBase& getInstanceImpl();
std::atomic<bool> m_isCreated = false;
};The mechanics are the textbook version. The constructor is private, `create()` builds the one instance, and `instance()` hands it back:
MantisBase &MantisBase::instance() {
auto &app_instance = getInstanceImpl();
if (!app_instance.isCreated())
throw std::runtime_error("MantisBase not created yet");
return app_instance;
}
MantisBase &MantisBase::create(const json &config) {
auto &app = getInstanceImpl();
if (app.isCreated())
throw std::runtime_error("MantisBase already created, "
"use MantisBase::instance() instead.");
// ...
}Even our own header documented the constraint as a feature:
MantisBase is a single-instance-per-process type (a managed singleton): the whole codebase reaches the active instance through instance(). Multiple concurrent instances in one process are not supported; create() throws if an instance already exists.
Once that door is open, the whole codebase walks through it. Reaching the database from a request handler, a model, an auth check, or a file path resolver—all of it collapses into one convenient line:
// src/core/api_keys.cpp
auto sql = MantisBase::instance().db().session();
// src/core/files.cpp
return fs::path(MantisBase::instance().dataDir()) / "files";
// src/core/sse_mgr.cpp
auto &router = MantisBase::instance().router();At last count, that pattern appeared across dozens of translation units, and that ubiquity is exactly the trap. Every one of those call sites is a dependency that doesn’t appear in any signature. Which brings us to why we’re ripping it out.
Why we’re ripping it out
1. A singleton is a global variable, dressed nicely
The case against globals is old and settled. Wulf and Shaw made it in 1973 in “Global Variable Considered Harmful”: non-local access makes it impossible to reason about a piece of code in isolation, because any part of the program can influence any other through the shared name. A singleton doesn’t repeal that argument; it just dresses it in a class with a `static` accessor. As Miško Hevery put it in his widely cited critique, Singletons are Pathological Liars: the method signature lies to you. When a `apiKeyLookup(id)` secretly calls `MantisBase::instance().db()`, its true dependency list is nowhere in its declaration. You only discover it by reading the body or by watching it explode at runtime.
This is the Dependency Inversion Principle (the “D” in Robert C. Martin’s SOLID) inverted the wrong way: our high-level model and request code depended directly on a concrete global-access mechanism instead of on abstractions handed to them.
2. The testing harness had to bend around it
This is where the abstraction stopped being free. Because there can only ever be one instance per process, our entire test suite is built around a single global application that must be stood up once and shared by everything. Look at what the test environment has to do:
// tests/common/test_environment.h
inline MbTestEnv::MbTestEnv() {
// ... assemble config ...
mb::MantisBase::create(args); // the one and only create()
}
inline void MbTestEnv::SetUp() {
serverThread = std::thread([this]() {
server_running.store(true);
auto res = mb::MantisBase::instance().run(); // reach back for it
});
// sleep, poll /health, hope it came up...
}Every test then reaches through the same global to get at subsystems:
// tests/unit/test_database.cpp
EXPECT_TRUE(mb::MantisBase::instance().db().isConnected());
// tests/unit/test_models.cpp
mb::EntitySchema base{mb::MantisBase::instance(), "test", "base"};The consequences are exactly what Michael Feathers warns about in Working Effectively with Legacy Code (2004). He defines a unit test as one that runs fast and in isolation, but singletons destroy isolation by construction. Global state persists between tests, so ordering matters, and you get to write defensive code like this just to survive a `create() > close() > create()` cycle within a single process:
// src/mantisbase.cpp
// Start from a clean slate: on a create() -> close() -> create()
// cycle (e.g. across test runs in one process) the arg vector would
// otherwise accumulate the previous run’s arguments.
app.m_cmdArgs.clear();
app.m_cmdArgs.emplace_back("mantisbase");That comment is a remnant record of the pain, a state that should have died with an object instead outlives it, because the object is a `static` that never really goes away. Freeman and Pryce, in Growing Object-Oriented Software, Guided by Tests (2009), argue that hard-to-test code is usually a signal of a design problem, not a testing problem. The singleton was our signal, printed in every `instance()` call the tests had to make.
3. We now genuinely need more than one instance per process
The convenience argument for a singleton, “there’s only ever one”, turned out to be false as the product grew. We want to run multiple independent MantisBase instances in a single process: think isolated tenants, an embedding host driving several databases, or simply a test that spins up two servers and asserts they don’t leak into each other. We already have the test that describes the goal:
// tests/integration/test_integration_multi_instance.cpp
TEST_F(MultiInstanceTest, IndependentEntitiesDontInterfere) {
// create schema multi_inst_a and multi_inst_b, insert into each,
// and assert each entity sees exactly its own row.
EXPECT_EQ(body_a["data"]["items_count"], 1);
EXPECT_EQ(body_b["data"]["items_count"], 1);
}You cannot express “two independent apps that don’t interfere” when `instance()` is hardwired to return the one true app and `create()` throws the second time you call it. The singleton isn’t just inconvenient here; it makes the requirement unrepresentable.
What we’re replacing it with
The fix is the boring, correct one that Martin Fowler catalogued back in 2004 in Inversion of Control Containers and the Dependency Injection pattern constructor injection. Instead of reaching out to a global, each object is handed the app it belongs to, at construction, as its first parameter.
A locked-in design decision for v0.4.x: the app is the first constructor parameter, so nobody can forget to bind it.
// include/mantisbase/core/http.h
class MantisRequest {
/// Owning application, injected by the Router when the request is
/// wrapped. Request-path code reaches shared services (db, router,
/// realtime, config) via app() instead of the global singleton.
const MantisBase &m_app;
public:
explicit MantisRequest(const MantisBase& mb, const drogon::HttpRequestPtr& req);
const MantisBase& mApp() const { return m_app; }
};// include/mantisbase/core/models/entity.h
Entity(const MantisBase &app, const nlohmann::json &schema);
Entity(const MantisBase &app, const std::string &name, const std::string &type);`Router`, `Database`, `Entity`, `EntitySchema`, `SSEMgr`, `WSMgr` each now takes its owning `MantisBase&` up front and stores it. The dependency that used to be invisible is now right there in the signature, allowing the type to tell the truth about what it needs.
The refactor isn’t finished. A handful of `instance()` calls in infrastructure code—the logger, file utilities, date helpers, the SSE manager, and one validator—are deliberately still standing, development in progress (maybe done by the time you are reading this!).
The lesson from v0.4.x isn’t that singletons are bad. It’s that convenience at the composition root has a way of becoming a constraint at the leaves. `MantisBase::instance()` saved us a constructor parameter thousands of times and charged interest on every one. Dependency injection just makes you pay the cost up front, in the open, where the type system can see it, and that turns out to be the cheaper deal.
This work is part of the ongoing MantisBase v0.4.x line. If you want to follow along, check out the v0.4.x branch and watch out for exciting features coming soon!


