Why centralise object creation

When a subsystem creates its dependencies directly, it’s coupled to their concrete types. Changing the implementation means changing the subsystem code:

1// Sensor task creates its own dependencies — coupled to Bmp280 and UartLogger
2class SensorTask {
3    Bmp280     sensor_;   // concrete type
4    UartLogger logger_;   // concrete type
5public:
6    SensorTask() : sensor_(hi2c1), logger_(huart2) {}
7};

Testing this in isolation means having real hardware available. Swapping Bmp280 for a mock requires editing SensorTask. The factory pattern moves the creation decision outside the class that uses the object.


Factory method

A factory method is a function that constructs and returns an object, hiding the concrete type behind an interface:

 1// Interface
 2class ISensor {
 3public:
 4    virtual float read() = 0;
 5    virtual ~ISensor() = default;
 6};
 7
 8// Concrete sensors
 9class Bmp280Sensor : public ISensor { /* ... */ };
10class MockSensor   : public ISensor {
11    float value_;
12public:
13    explicit MockSensor(float v) : value_(v) {}
14    float read() override { return value_; }
15};
16
17// Factory function — caller doesn't know which concrete type it gets
18std::unique_ptr<ISensor> createSensor(SensorType type) {
19    switch (type) {
20        case SensorType::Bmp280: return std::make_unique<Bmp280Sensor>(hi2c1);
21        case SensorType::Mock:   return std::make_unique<MockSensor>(25.0f);
22        default:                 return nullptr;
23    }
24}
25
26auto sensor = createSensor(SensorType::Bmp280);
27float temp = sensor->read();

The switch lives in one place. SensorTask receives ISensor* — it never sees Bmp280Sensor.

Static factory method on the class

For single-type creation with multiple construction paths, put the factory on the class itself:

 1class FrameParser {
 2public:
 3    static FrameParser fromUart(UART_HandleTypeDef* huart) {
 4        return FrameParser(huart, FrameFormat::Uart);
 5    }
 6    static FrameParser fromSpi(SPI_HandleTypeDef* hspi) {
 7        return FrameParser(hspi, FrameFormat::Spi);
 8    }
 9
10private:
11    FrameParser(void* handle, FrameFormat fmt) : handle_(handle), format_(fmt) {}
12    void*       handle_;
13    FrameFormat format_;
14};
15
16auto parser = FrameParser::fromUart(&huart1);

Named constructors (fromUart, fromSpi) communicate intent that the parameter lists alone can’t — especially when multiple overloads take the same types.


Abstract factory

An abstract factory groups related factories behind an interface. The caller picks a factory family; the factory creates consistent objects within that family:

 1// Abstract factory interface
 2class IPeripheralFactory {
 3public:
 4    virtual std::unique_ptr<ISensor>  createSensor()  = 0;
 5    virtual std::unique_ptr<IDisplay> createDisplay() = 0;
 6    virtual std::unique_ptr<IStorage> createStorage() = 0;
 7    virtual ~IPeripheralFactory() = default;
 8};
 9
10// Production hardware factory
11class Stm32Factory : public IPeripheralFactory {
12public:
13    std::unique_ptr<ISensor>  createSensor()  override { return std::make_unique<Bmp280Sensor>(hi2c1); }
14    std::unique_ptr<IDisplay> createDisplay() override { return std::make_unique<Ssd1306Display>(hi2c1); }
15    std::unique_ptr<IStorage> createStorage() override { return std::make_unique<FlashStorage>(); }
16};
17
18// Test factory — all mocks, no hardware
19class MockFactory : public IPeripheralFactory {
20public:
21    std::unique_ptr<ISensor>  createSensor()  override { return std::make_unique<MockSensor>(23.5f); }
22    std::unique_ptr<IDisplay> createDisplay() override { return std::make_unique<NullDisplay>(); }
23    std::unique_ptr<IStorage> createStorage() override { return std::make_unique<RamStorage>(4096); }
24};
25
26// Application — takes any factory
27class Application {
28    std::unique_ptr<ISensor>  sensor_;
29    std::unique_ptr<IDisplay> display_;
30    std::unique_ptr<IStorage> storage_;
31public:
32    explicit Application(IPeripheralFactory& factory)
33        : sensor_ (factory.createSensor())
34        , display_(factory.createDisplay())
35        , storage_(factory.createStorage())
36    {}
37};
38
39// main.cpp — pick the factory once
40#ifdef UNIT_TEST
41MockFactory factory;
42#else
43Stm32Factory factory;
44#endif
45Application app(factory);

Switching from hardware to mocks means changing one line. All the mock wiring is in MockFactory, not scattered across Application.


Builder pattern

The builder pattern constructs complex objects step by step. It’s useful when:

  • Object construction requires many optional parameters
  • Parameters have complex validation or ordering constraints
  • You want a fluent interface (method chaining)

Fluent builder

 1class UartConfig {
 2public:
 3    uint32_t baud     = 115200;
 4    uint8_t  wordLen  = 8;
 5    uint8_t  stopBits = 1;
 6    bool     parity   = false;
 7    bool     flowCtrl = false;
 8};
 9
10class UartBuilder {
11    UartConfig cfg_;
12public:
13    UartBuilder& baud(uint32_t b)       { cfg_.baud = b;       return *this; }
14    UartBuilder& wordLength(uint8_t w)  { cfg_.wordLen = w;    return *this; }
15    UartBuilder& stopBits(uint8_t s)    { cfg_.stopBits = s;   return *this; }
16    UartBuilder& parityEven()           { cfg_.parity = true;  return *this; }
17    UartBuilder& hardwareFlowControl()  { cfg_.flowCtrl = true;return *this; }
18
19    UartConfig build() const { return cfg_; }
20};
21
22// Usage — readable, no positional argument confusion
23auto cfg = UartBuilder{}
24    .baud(9600)
25    .wordLength(8)
26    .stopBits(2)
27    .parityEven()
28    .build();

Compare to the constructor alternative:

1UartConfig cfg(9600, 8, 2, true, false);  // what does each bool mean?

The builder makes each argument self-documenting.

Builder with validation

 1class UartBuilder {
 2    UartConfig cfg_;
 3    bool baudrateSet_ = false;
 4
 5public:
 6    UartBuilder& baud(uint32_t b) {
 7        if (b != 9600 && b != 115200 && b != 921600)
 8            throw std::invalid_argument("unsupported baud rate");
 9        cfg_.baud = b;
10        baudrateSet_ = true;
11        return *this;
12    }
13
14    UartConfig build() const {
15        if (!baudrateSet_)
16            throw std::logic_error("baud rate not set");
17        return cfg_;
18    }
19};

Validation at build time catches misconfigured objects before they reach hardware.

Builder for test fixtures

Builders are especially useful in tests where you need many similar objects with slight variations:

 1class SensorBuilder {
 2    float    baseTemp_   = 20.0f;
 3    float    noiseLevel_ = 0.0f;
 4    bool     faulted_    = false;
 5public:
 6    SensorBuilder& temperature(float t) { baseTemp_ = t;    return *this; }
 7    SensorBuilder& noise(float n)       { noiseLevel_ = n;  return *this; }
 8    SensorBuilder& faulted()            { faulted_ = true;  return *this; }
 9
10    std::unique_ptr<ISensor> build() const {
11        if (faulted_) return std::make_unique<FaultingSensor>();
12        return std::make_unique<MockSensor>(baseTemp_, noiseLevel_);
13    }
14};
15
16// Test cases
17auto coldSensor = SensorBuilder{}.temperature(-10.0f).build();
18auto noisySensor = SensorBuilder{}.temperature(25.0f).noise(2.0f).build();
19auto brokenSensor = SensorBuilder{}.faulted().build();

Each test case reads clearly without repetitive constructor argument lists.


Factory registry

For plugin-style extensibility, register factories in a map:

 1class SensorRegistry {
 2    using Factory = std::function<std::unique_ptr<ISensor>()>;
 3    std::map<std::string, Factory> factories_;
 4
 5public:
 6    void registerSensor(std::string name, Factory f) {
 7        factories_[std::move(name)] = std::move(f);
 8    }
 9
10    std::unique_ptr<ISensor> create(const std::string& name) const {
11        auto it = factories_.find(name);
12        if (it == factories_.end()) return nullptr;
13        return it->second();
14    }
15};
16
17SensorRegistry registry;
18registry.registerSensor("bmp280",  [] { return std::make_unique<Bmp280Sensor>(hi2c1); });
19registry.registerSensor("ntc10k",  [] { return std::make_unique<NtcSensor>(hadc1); });
20registry.registerSensor("mock",    [] { return std::make_unique<MockSensor>(22.0f); });
21
22// Config-driven sensor creation
23auto sensor = registry.create(config.get("sensor_type"));

New sensor types are added by registration — no switch statement changes, no existing code touched. This is the Open/Closed principle applied to creation.


Summary

  • Factory method: one function creates one family of objects — caller sees the interface, not the type
  • Named constructor: static factory on the class — communicates intent for multiple construction paths
  • Abstract factory: interface for creating families of related objects — swap all implementations (hardware → mock) at once
  • Builder: step-by-step construction with method chaining — readable configuration, validation at build time
  • Factory registry: map of factory functions — plugin-style extension without switch statements

What’s next