cpp26-reflection · part 14

Autowired dependency injection: no container, just reflection

· upd 2026-10-06· english· audience: working-cpp· AI-generated, reviewed by Filip Sajdak· discuss

Correction, 6 October 2026. Several code blocks in this post do not compile as published, and one sentence about GCC support is wrong. I compiled every block that is complete enough on g++-16 (Homebrew GCC 16.2.0, -std=c++26 -freflection -Wall -Wextra) and changed only what that run showed. First, parameters_of on a constructor works on GCC 16.2.0 with plain -freflection, so no separate flag is needed, and members_of(^^::) lists the current translation unit’s namespace (only that unit’s declarations). The sentence that namespace reflection is “still in flight” is replaced, and it cited the wrong paper: P2988 is std::optional<T&>, and the paper for parameter reflection is P3096, Function Parameter Reflection in Reflection for C++26. Second, construct_with_reflection failed with four errors. The splice in a template argument needs typename, the params array must be static constexpr, find_primary_ctor was never defined, and the parameter type Clock const& has to lose its reference and const before get<T> is instantiated, or GCC reports neither complete class type nor namespace. The text said the registry ignores reference qualifiers; only the typeid lookup does, and the instantiation of get<Clock const&> does not. The block now has the form that compiled and ran, and the output was Hello after a clock reading. Third, "circular dependency at " + typeid(T).name() adds two pointers, and GCC says invalid operands of types 'const char [24]' and 'const char*' to binary 'operator+'; it is now std::string{...} + .... With that fix the check does run at run time: two types that need each other throw circular dependency at 1A. In my test it also has a flaw: if resolving a type throws for another reason (an unregistered dependency), the type stays in resolving_, and a retry after registering the dependency reports a false cycle. The entry must be erased on every exit path. Fourth, the scan_and_register<translation_unit_reflection> call and the dep_graph skeleton named things that do not exist. The annotation scan and a one-root dep_graph are now written in the form that compiled, and the static_assert on {"Greeter", {"Clock", "I18n"}} held. Fifth, the table said misconfiguration is a static_assert error. The container in this post reports a missing registration and a cycle with exceptions at run time, and the constructor arguments are looked up at run time; only the parameter list is read at compile time. A compile-time graph with static_assert errors needs a separate design. The Compiler Explorer demo does not do what its old title said: it is a type-keyed registry with explicit factory lambdas and a std::any map. It never calls parameters_of or any reflection function, and its header says the autowired variant needs a separate flag, which is wrong for GCC 16.2.0. The Spring comparison and the interface and scope sketches (bind, add_transient) were not compiled and are sketches. The text below is otherwise left as published, so you can read it against this note.

In Spring (Java) or .NET:

@Service
class Greeter {
    Greeter(Clock clock, I18n i18n) { ... }
}

The framework sees the @Service annotation, inspects the constructor, and wires the parameters automatically from a registry of services. No new Greeter(new Clock(), new I18n()) boilerplate.

C++ has had Boost.DI. It is beautifully engineered template metaprogramming that does the same, but trips into SFINAE cliff-faces when things go wrong. Reflection makes the same container fit in 60 lines.

Target API

struct Clock   { auto now() const { return std::chrono::steady_clock::now(); } };
struct I18n    { std::string greet() const { return "Hello"; } };
struct Greeter {
    Greeter(Clock const& c, I18n const& i) : clock{c}, i18n{i} {}
    std::string greet() const {
        return std::format("[{}] {}", clock.now().time_since_epoch().count(), i18n.greet());
    }
    Clock const& clock;
    I18n const& i18n;
};

int main() {
    di::container c;
    c.add<Clock>();
    c.add<I18n>();
    c.add<Greeter>();

    Greeter& g = c.get<Greeter>();
    std::println("{}", g.greet());
}

c.add<Greeter>() does not say “Greeter depends on Clock and I18n.” The container figures that out by reflecting on Greeter’s constructor parameters.

The container

namespace di {

class container {
    std::unordered_map<std::type_index, std::any> instances_;

public:
    template <typename T>
    void add() {
        instances_[typeid(T)] = std::any{};   // placeholder; resolved lazily
    }

    template <typename T>
    T& get() {
        auto it = instances_.find(typeid(T));
        if (it == instances_.end()) throw std::runtime_error{"not registered"};
        if (!it->second.has_value()) {
            it->second = std::make_any<T>(construct<T>());
        }
        return std::any_cast<T&>(it->second);
    }

private:
    template <typename T>
    T construct() {
        return construct_with_reflection<T>(*this);
    }
};

}  // namespace di

Simple. Registration stores an empty slot; lookup lazily constructs. The interesting bit is construct_with_reflection.

Reflecting constructors

P3096 (function parameter reflection) lets us inspect a constructor’s parameter list:

// Tested on GCC 16.2.0: this block, plus the container above and a forward
// declaration of construct_with_reflection before the class, compiles and runs.
template <typename T>
consteval std::meta::info find_primary_ctor() {
    for (auto m : std::meta::members_of(^^T, std::meta::access_context::unchecked()))
        if (std::meta::is_constructor(m) && !std::meta::is_copy_constructor(m)
                                         && !std::meta::is_move_constructor(m))
            return m;
    return {};
}

template <typename T>
T construct_with_reflection(container& c) {
    // Find T's primary constructor
    constexpr auto ctor = find_primary_ctor<T>();
    // Reflect its parameters
    static constexpr auto params = std::define_static_array(
        std::meta::parameters_of(ctor));

    // Build a tuple of resolved arguments
    auto build_args = [&]<std::size_t... I>(std::index_sequence<I...>) {
        return std::make_tuple(
            c.get<std::remove_cvref_t<typename [: std::meta::type_of(params[I]) :]>>()...);
    };
    auto arg_tuple = build_args(std::make_index_sequence<params.size()>{});

    // Invoke the constructor with resolved args
    return std::apply([](auto&&... as) { return T{as...}; }, arg_tuple);
}

Two reflection moves:

  1. parameters_of(ctor) returns std::vector<info>, one per parameter.
  2. [: type_of(params[I]) :] splices each parameter’s type. remove_cvref_t turns Clock const& into Clock before c.get<Clock>() recursively resolves it; without it get<Clock const&> tries to construct a reference type and GCC 16.2.0 rejects it.

The result: Greeter{c.get<Clock>(), c.get<I18n>()}. The parameter list is read at compile time; the lookups in the registry happen at run time.

Circular dependencies

What if A needs B and B needs A? The naive container recurses until stack overflow. Detection is easy (a fragment, not a complete program; tested inside the container above):

thread_local std::unordered_set<std::type_index> resolving_;

template <typename T>
T& get() {
    if (resolving_.contains(typeid(T)))
        throw std::runtime_error{std::string{"circular dependency at "} + typeid(T).name()};
    resolving_.insert(typeid(T));
    /* ... construct + cache ... */
    resolving_.erase(typeid(T));
    return /* ... */;
}

Production containers do this with lifetime-aware graph analysis; our 60-line version just throws.

Scopes and lifetimes

Three common lifetimes in DI frameworks:

  1. Singleton. One instance for the container’s lifetime. Our default.
  2. Transient. A new instance per get<T>(), via container::add_transient<T>().
  3. Scoped. Per “logical request” (web-framework terminology), which needs a scope stack.

The singleton → transient switch is a one-line change in get<T>. Scoped is more involved. A scope is itself a container with an inheritance chain.

Interface-based resolution

c.get<Greeter>() asks for a concrete type. Real DI often wants to bind Clock (an interface) to a specific implementation (SteadyClock):

c.bind<Clock, SteadyClock>();
// When something needs a Clock&, construct SteadyClock once.

struct TestClock : Clock { /* ... */ };
c.bind<Clock, TestClock>();   // swap for tests

Implementation: a second registry mapping interface → concrete type, consulted during get<Clock&> before falling back to direct lookup. Reflection checks the parameter type in construct_with_reflection and routes through the bind-map.

Auto-registration via annotations

Instead of explicit c.add<T>(), annotate:

struct [[=di::service{}]] Greeter { /* ... */ };
struct [[=di::service{}]] Clock  { /* ... */ };

// Somewhere at startup (sketch: scan_and_register does not exist):
di::scan_and_register<^^::>(container);

scan_and_register is a name for a function you would write; it is not in a library. On GCC 16.2.0 the building blocks work: members_of(^^::, access_context::current()) lists the namespace as the current translation unit declares it (a second translation unit sees its own list, not this one’s), and annotations_of_with_type(m, ^^di::service) filters it. This loop found Greeter, Clock and I18n and skipped an unannotated type:

consteval std::vector<std::meta::info> services_in_namespace() {
    std::vector<std::meta::info> out;
    for (auto m : std::meta::members_of(^^::, std::meta::access_context::current()))
        if (std::meta::is_type(m) && !std::meta::annotations_of_with_type(m, ^^di::service).empty())
            out.push_back(m);
    return out;
}

Registering them is then a loop over define_static_array(services_in_namespace()) with template for. Parameter reflection is P3096, Function Parameter Reflection in Reflection for C++26.

Compile-time dependency graph

A fun debug hook: ask the container for its dependency graph at compile time:

// Tested on GCC 16.2.0 (one root; the static_assert holds).
consteval auto dep_graph(std::meta::info root) {
    std::vector<std::pair<std::string, std::vector<std::string>>> edges;
    std::vector<std::meta::info> todo{root};
    for (std::size_t i = 0; i < todo.size(); ++i) {
        auto t = todo[i];
        std::vector<std::string> deps;
        for (auto m : std::meta::members_of(t, std::meta::access_context::unchecked()))
            if (std::meta::is_constructor(m) && !std::meta::is_copy_constructor(m)
                                             && !std::meta::is_move_constructor(m))
                for (auto p : std::meta::parameters_of(m)) {
                    auto d = std::meta::dealias(std::meta::remove_cvref(std::meta::type_of(p)));
                    deps.push_back(std::string{std::meta::identifier_of(d)});
                    todo.push_back(d);
                }
        edges.push_back({std::string{std::meta::identifier_of(t)}, deps});
    }
    return edges;
}

static_assert(dep_graph(^^Greeter) ==
    std::vector<std::pair<std::string, std::vector<std::string>>>{
        {"Greeter", {"Clock", "I18n"}}, {"Clock", {}}, {"I18n", {}}});

The graph is knowable at compile time because every constructor’s parameter types are reflectable. Tools can emit DOT diagrams, detect cycles, and check for unreachable services, all without running the program.

Spring vs. our version

Spring di:: (C++26)
Registration @Service, @Component add<T>() or @di::service
Resolution Reflection at runtime Reflection at compile time
Cost per get() Hash-map + constructor reflection Hash-map + fully-inlined constructor
Cycle detection Runtime Runtime or compile-time
Error at misconfiguration BeanCreationException at startup exception at run time in this post’s container; a compile-time graph can give static_assert errors
Lines of framework code ~600k (Spring Core) ~60 (demo), ~600 (production-adequate)

Spring has 20 years of production polish; our 60-line demo doesn’t. But the architectural core (inspect constructors, resolve parameters from a registry) is exactly the same, and C++ gets it at compile time.

Next: mocks and type synthesis