Dependency Injection with C++20 Concepts and the Service Provider Pattern

Linux /g++ 11.1.0

In this article I explore a system of using C++20 Concepts for Dependency Injection. A working example can be found here

For each service you will need:

  • an interface Concept
  • a provider Concept
  • concrete implementation

Optionally you could also create a mock implementation using the testing framework of your choice.

End Game

In the sample code I have several functions (all, two, onlyLogger, onlyPersist, onlyCalculator) that have different injection requirements. I have covered requirements for all, a sample/mix (two), and a single service. All functions take a provider object as their only parameter.

int main() {
    auto provider = ServiceProviderImpl{};

    all(provider);
    two(provider);
    onlyLogger(provider);
    onlyPersist(provider);
    onlyCalculator(provider);

    return 0;
}
usage

Here are the functions and their requirements:

  • all (logger, persistence, calculator)
  • two (logger, persistence)
  • onlyLogger (logger)
  • onlyPersist (persistence)
  • onlyCalculator (calculator)

void all(ServiceProviderInterface auto provider) {
    provider.logger.info("Hello Logger DI: all");
    std::cout << "add (all) = " << provider.calculator.add(1,2) << std::endl;
}


template<typename T>
concept TwoProvider = 
	LoggerServiceProvider<T> && 
    DataPersistenceProvider<T>;
void two(TwoProvider auto provider) {
    provider.logger.info("Hello Logger DI: two");
}

void onlyLogger(LoggerServiceProvider auto provider) {
    provider.logger.info("Hello Logger DI: onlyLogger");
}

void onlyPersist(DataPersistenceProvider auto provider) {
    provider.persistence.write();
}

void onlyCalculator(CalculatorProvider auto provider) {
    std::cout << "add (onlyCalculator) = " << provider.calculator.add(3,4) << std::endl;
}

All requirements can use the ServiceProviderInterface directly, single requirements can use the individual provider directly. The more useful case is a mix of services which requires a new concept to be used. Luckily the mixed concepts are easy to write by using && with the services you require.

template<typename T>
concept TwoProvider = 
	LoggerServiceProvider<T> && 
    DataPersistenceProvider<T>;
specifying which services you require

Service Provider

template<typename T>
concept ServiceProviderInterface =
        LoggerServiceProvider<T> &&
        DataPersistenceProvider<T> &&
        CalculatorProvider<T>;
        
        
struct ServiceProviderImpl{
    LoggerServiceImpl logger;
    DataPersistenceImpl persistence;
    CalculatorImpl calculator;
};

auto provider = ServiceProviderImpl{};
master service provider concept and concrete

Creating the Services

This is the Calculator implementation. It's simply a struct with 2 functions.

struct CalculatorImpl {
    int add(int a, int b){ return a + b; }
    int subtract(int a, int b){ return a - b; }
};
concrete implementation

We can then create a Concept for Calculator that enforces the interface at compile time.

template<typename T>
concept CalculatorInterface = requires(T calculator, int value) {
    {calculator.add(value, value)} -> std::same_as<int>;
    {calculator.subtract(value, value)} -> std::same_as<int>;
};

interface

The provider allows us to simplify the Service Provide Concept and enforce the variable name of this service (in this case calculator).

template<typename T>
concept CalculatorProvider = requires(T provider){
    {provider.calculator} -> CalculatorInterface;
};

provider wrapper