Back

Modern C++ Design Patterns Beyond GoF


In 1994, the "Gang of Four" published Design Patterns: Elements of Reusable Object-Oriented Software. It quickly became one of the most influential books in software architecture. However, the GoF patterns were created in an era dominated by class hierarchies, inheritance-heavy designs, runtime polymorphism, raw pointers, and mostly single-threaded desktop applications.

C++ has changed dramatically since then.

With C++11, C++17, C++20, C++23, and the upcoming C++26, the language has moved toward value semantics, RAII-based lifetime management, compile-time validation, safer abstractions, and explicit ownership. Modern C++ often favors composition over inheritance, static polymorphism over virtual dispatch when possible, and clear resource ownership over raw pointer manipulation.

Modern C++ is also used in very different domains from those that shaped the original GoF catalog: high-frequency trading, game engines, embedded systems, real-time computer vision, AI inference pipelines, robotics, microservices, and distributed infrastructure. These domains require patterns that deal with concurrency, data locality, hardware resources, runtime plugins, resilience, and clean architectural boundaries.

This article revisits classic design patterns from a Modern C++ perspective, explains where the Service Locator fits, and presents a practical taxonomy of contemporary non-GoF patterns used in production systems.

Important clarification

Not everything discussed here is a "design pattern" in the strict GoF sense. Some are language idioms, some are architectural patterns, some are deployment patterns, and others are domain-specific production practices. The goal is not to force everything into the GoF taxonomy, but to understand which recurring solutions are useful in modern C++ systems.

1. Modern C++ Idioms That Replace Some GoF Patterns

Before looking at larger architectural structures, it is useful to see how Modern C++ language features can replace or simplify several classic object-oriented patterns.

Many GoF patterns were designed to solve limitations of older C++ and Java-like object models. In modern C++, some of those problems are better solved with value types, templates, lambdas, concepts, std::variant, RAII, and type erasure.

A. Modern Visitor: std::variant and std::visit

The classic Visitor pattern is often associated with invasive boilerplate: base classes, virtual functions, accept() methods, and a visitor interface with one overload per concrete type.

In modern C++, if the set of possible types is known at compile time, std::variant and std::visit often provide a cleaner alternative. This approach models a closed set of alternatives without requiring inheritance.

#include <iostream>
#include <variant>
#include <vector>

struct Circle {
    double radius;
};

struct Square {
    double side;
};

// Sum type: no base class, no virtual functions.
using Shape = std::variant<Circle, Square>;

// Helper for combining lambdas.
template<class... Ts>
struct overloaded : Ts... {
    using Ts::operator()...;
};

template<class... Ts>
overloaded(Ts...) -> overloaded<Ts...>;

int main() {
    std::vector<Shape> shapes{
        Circle{2.0},
        Square{3.0}
    };

    for (const auto& shape : shapes) {
        std::visit(overloaded{
            [](const Circle& c) {
                std::cout << "Circle area: "
                          << 3.14159 * c.radius * c.radius << "\n";
            },
            [](const Square& s) {
                std::cout << "Square area: "
                          << s.side * s.side << "\n";
            }
        }, shape);
    }
}

This approach avoids virtual dispatch, avoids pointer-based object graphs, keeps data types simple, and makes missing cases visible at compile time. However, it is best suited for closed type sets. If you need third-party plugins to add new types at runtime, classical polymorphism or type erasure may still be a better fit.

B. Modern Strategy: Type Erasure and Callables

The GoF Strategy pattern encapsulates interchangeable algorithms behind a common interface. Traditionally, this means defining an abstract base class and several concrete implementations.

In modern C++, many strategies can be represented directly as callables: lambdas, function objects, or type-erased wrappers such as std::function or C++23's std::move_only_function.

#include <algorithm>
#include <functional>
#include <iostream>
#include <vector>

// C++23.
// If unavailable, use std::function or a custom type-erased wrapper.
using SortingStrategy =
    std::move_only_function<void(std::vector<int>&) const>;

class Sequence {
public:
    Sequence(std::vector<int> data, SortingStrategy sorter)
        : data_(std::move(data)),
          sorter_(std::move(sorter)) {}

    void sort_data() {
        sorter_(data_);
    }

    void print() const {
        for (int value : data_) {
            std::cout << value << " ";
        }
        std::cout << "\n";
    }

private:
    std::vector<int> data_;
    SortingStrategy sorter_;
};

int main() {
    Sequence sequence(
        {4, 2, 5, 1, 3},
        [](std::vector<int>& values) {
            std::ranges::sort(values);
        }
    );

    sequence.sort_data();
    sequence.print();
}

std::move_only_function is a C++23 feature and may not be fully available on all toolchains. In C++17 or C++20 codebases, common alternatives include std::function, custom type erasure, template-based policies, or plain function objects.

C. Static Polymorphism: Concepts Instead of Runtime Interfaces

Classic object-oriented design often uses pure virtual interfaces to define behavior. This is still useful when runtime polymorphism is required. But when types are known at compile time, C++20 concepts provide a cleaner alternative. Concepts let you express requirements on template parameters directly.

#include <concepts>
#include <iostream>
#include <string_view>

template<typename T>
concept Logger = requires(T logger, std::string_view message) {
    { logger.log(message) } -> std::same_as<void>;
};

struct ConsoleLogger {
    void log(std::string_view message) {
        std::cout << message << "\n";
    }
};

void process(Logger auto& logger) {
    logger.log("Processing with compile-time checked logging.");
}

int main() {
    ConsoleLogger logger;
    process(logger);
}

Concepts make constraints explicit, improve compiler diagnostics, allow inlining, and avoid virtual dispatch when runtime polymorphism is unnecessary. This is especially valuable in performance-sensitive code such as inference engines, math libraries, embedded systems, and generic infrastructure.

2. Dependency Management: Service Locator vs Dependency Injection

How components obtain their dependencies is one of the most important architectural decisions in a codebase. This brings us to the Service Locator pattern.

Where Does Service Locator Belong?

Service Locator is not one of the original GoF 23 patterns. It is best classified as a dependency-access or object-lookup pattern. Some taxonomies place it near creational patterns because it helps obtain objects, but its real role is dependency management.

A Service Locator provides a central registry from which components can request services.

#include <iostream>
#include <string>

class Logger {
public:
    void log(const std::string& message) {
        std::cout << message << "\n";
    }
};

class ServiceLocator {
public:
    static void provide(Logger* logger) {
        logger_ = logger;
    }

    static Logger& get_logger() {
        return *logger_;
    }

private:
    static inline Logger* logger_ = nullptr;
};

A class can then retrieve the dependency directly:

class Tracker {
public:
    void run() {
        auto& logger = ServiceLocator::get_logger();
        logger.log("tracking");
    }
};

This is convenient, but it comes with serious trade-offs.

Why Service Locator Is Often Considered an Anti-Pattern

The main problem with Service Locator is that it hides dependencies. Looking at the public API of Tracker, nothing tells you that it requires a Logger. The dependency is invisible. This makes the class harder to understand, harder to test, and harder to reuse.

Tracker tracker;
tracker.run(); // Fails if ServiceLocator has not been initialized.

The class appears simple, but it secretly depends on global setup. This creates several problems:

  • hidden coupling;
  • order-of-initialization issues;
  • harder unit testing;
  • implicit global state;
  • unclear ownership;
  • harder reasoning in multithreaded systems.

Service Locator is not "bad" because it never works. It is problematic because it makes dependencies less visible and moves failures from compile time or construction time to runtime.

Modern Alternative: Explicit Dependency Injection

A more explicit alternative is Constructor Injection. Instead of allowing a class to pull dependencies from a global registry, dependencies are pushed into the object from the outside.

class Tracker {
public:
    explicit Tracker(Logger& logger)
        : logger_(logger) {}

    void run() {
        logger_.log("tracking");
    }

private:
    Logger& logger_;
};

Now the dependency is visible in the constructor. This makes the design easier to test:

Logger logger;
Tracker tracker{logger};
tracker.run();

In production systems, dependencies are usually wired together in one place at application startup. This place is called the Composition Root.

int main() {
    Logger logger;
    Tracker tracker{logger};

    tracker.run();
}

The Composition Root keeps object graph construction centralized and prevents dependency-resolution logic from spreading throughout the codebase.

When Is Service Locator Still Acceptable?

Context Why it may be acceptable
Legacy migration Allows gradual replacement of hardcoded globals.
Plugin systems Helps discover runtime-registered services.
Game engines Avoids passing large context objects through thousands of entities.
Embedded systems Sometimes used for constrained global infrastructure.
Cross-cutting diagnostics Logging, metrics, tracing, or profiling hooks.
Test harnesses Can simplify controlled replacement of infrastructure.

The rule of thumb is simple: use explicit dependency injection for core business dependencies. Use Service Locator only when the dependency is truly infrastructural, cross-cutting, or difficult to pass explicitly without damaging the design.

3. Taxonomy of Contemporary Non-GoF Patterns

Modern C++ systems use many recurring design solutions that do not belong to the original GoF catalog. A useful way to organize them is by scope.

A. Dependency and Composition Patterns

These patterns control how components are wired together.

Pattern What it solves C++ relevance
Dependency Injection Pass dependencies from outside instead of constructing them internally. Standard for testable C++ services.
Composition Root Centralize application wiring in one place. Keeps construction out of business logic.
Factory Registry Select implementations dynamically by name or type. Useful for inference backends, codecs, and plugins.
Plugin Architecture Load or register implementations at runtime. Common in engines, SDKs, and ML runtimes.
Service Locator Centralized service lookup. Useful selectively, but dangerous if overused.

Factory Registry Example

A Factory Registry is especially useful in C++ systems that support multiple backends, such as TensorRT, ONNX Runtime, LibTorch, OpenVINO, Vulkan, CUDA, or CPU fallback implementations.

#include <functional>
#include <memory>
#include <stdexcept>
#include <string>
#include <string_view>
#include <unordered_map>

class IBackend {
public:
    virtual ~IBackend() = default;
    virtual void infer() = 0;
};

class BackendRegistry {
public:
    using Factory = std::function<std::unique_ptr<IBackend>()>;

    void register_backend(std::string name, Factory factory) {
        factories_.emplace(std::move(name), std::move(factory));
    }

    std::unique_ptr<IBackend> create(std::string_view name) const {
        auto it = factories_.find(std::string{name});

        if (it == factories_.end()) {
            throw std::runtime_error("Unknown backend");
        }

        return it->second();
    }

private:
    std::unordered_map<std::string, Factory> factories_;
};

This avoids scattering large if, else, or switch blocks across the codebase. Instead of hardcoding every backend decision at every call site, you centralize backend creation in a registry.

B. Data, ML, and Real-Time Inference Patterns

High-performance systems often care more about memory layout, latency, cache behavior, and ownership than about class hierarchies. In AI inference, robotics, video processing, and real-time audio, the most important patterns are often data-centric.

Pipeline and Producer-Consumer

Instead of running one large sequential loop, a system can be split into stages connected by queues.

[Camera Capture]
       |
       v
[Frame Queue]
       |
       v
[Inference Engine]
       |
       v
[Postprocessing]
       |
       v
[Output / Tracking / Alerts]

A camera thread produces frames. An inference thread consumes frames and produces detections. A postprocessing stage consumes detections and produces application-level results. This is a natural fit for real-time systems because it improves CPU utilization, separates responsibilities, supports backpressure management, simplifies profiling, and isolates slow stages from fast ones.

Zero-Copy Pipeline

In high-throughput systems, copying large buffers is expensive. A zero-copy pipeline avoids copying raw frame data between stages. Instead, it passes ownership or references.

using FramePtr = std::unique_ptr<Frame>;

ThreadSafeQueue<FramePtr> frame_queue;

A producer can move the frame into the queue, and a consumer receives ownership without copying the underlying frame buffer.

frame_queue.push(std::move(frame));

auto frame = frame_queue.pop();

This is especially important for camera frames, GPU buffers, audio blocks, network packets, and tensor batches.

Object Pool

Repeated heap allocation in a hot path can cause latency spikes, memory fragmentation, and unpredictable performance. An Object Pool preallocates reusable resources and recycles them.

#include <functional>
#include <memory>
#include <mutex>
#include <vector>

class Buffer {
public:
    Buffer() {
        // Heavy allocation, for example GPU memory or pinned host memory.
    }

    void reset() {
        // Clear metadata before reuse.
    }
};

class BufferPool {
public:
    using PoolPtr = std::unique_ptr<Buffer, std::function<void(Buffer*)>>;

    PoolPtr acquire() {
        std::lock_guard<std::mutex> lock(mutex_);

        if (pool_.empty()) {
            return PoolPtr(
                new Buffer(),
                [this](Buffer* buffer) {
                    recycle(buffer);
                }
            );
        }

        auto buffer = std::move(pool_.back());
        pool_.pop_back();

        return PoolPtr(
            buffer.release(),
            [this](Buffer* buffer) {
                recycle(buffer);
            }
        );
    }

private:
    void recycle(Buffer* buffer) {
        buffer->reset();

        std::lock_guard<std::mutex> lock(mutex_);
        pool_.push_back(std::unique_ptr<Buffer>(buffer));
    }

    std::vector<std::unique_ptr<Buffer>> pool_;
    std::mutex mutex_;
};

This example is useful pedagogically, but the deleter captures this. That means the BufferPool must outlive every PoolPtr returned by acquire(). If a buffer handle survives longer than the pool, the deleter will call a destroyed object. In production code, enforce pool lifetime carefully or store the pool state in a std::shared_ptr-managed control block.

Double Buffering

Double buffering allows one part of the system to write into one buffer while another part reads from another buffer.

Writer -> Buffer A
Reader -> Buffer B

Swap

Writer -> Buffer B
Reader -> Buffer A

This reduces blocking and is commonly used in rendering, camera acquisition, real-time audio, and shared sensor state.

C. Boundary and Migration Patterns

Modern C++ systems often integrate with old C APIs, vendor SDKs, drivers, external services, and legacy codebases. Good architecture keeps those messy details at the boundary.

Anti-Corruption Layer

An Anti-Corruption Layer, often abbreviated as ACL, protects your clean domain model from external APIs. This is especially useful when dealing with vendor camera SDKs, legacy C libraries, hardware drivers, third-party inference runtimes, external network protocols, and old internal codebases.

#include <cstdint>
#include <expected>
#include <stdexcept>
#include <string>
#include <string_view>
#include <vector>

// Legacy unsafe Vendor C API.
extern "C" {
    struct LegacyCamera {
        int handle;
    };

    int init_cam_sdk(LegacyCamera* camera, const char* config);
    int read_raw_frame(LegacyCamera* camera, void* buffer, int* size);
}

class ICamera {
public:
    virtual ~ICamera() = default;

    virtual std::expected<std::vector<std::uint8_t>, std::string>
    capture_frame() = 0;
};

class VendorCameraACL final : public ICamera {
public:
    explicit VendorCameraACL(std::string_view config) {
        if (init_cam_sdk(&camera_, config.data()) != 0) {
            throw std::runtime_error("Failed to initialize camera SDK");
        }
    }

    std::expected<std::vector<std::uint8_t>, std::string>
    capture_frame() override {
        std::vector<std::uint8_t> buffer(1920 * 1080 * 3);

        int bytes_read = 0;

        if (read_raw_frame(&camera_, buffer.data(), &bytes_read) != 0) {
            return std::unexpected("Hardware read failure");
        }

        buffer.resize(static_cast<std::size_t>(bytes_read));
        return buffer;
    }

private:
    LegacyCamera camera_{};
};

std::expected is a C++23 feature. Before C++23, similar behavior can be achieved with tl::expected, boost::outcome, std::variant, or project-specific Result<T, E> types. The key idea is not the specific type. The key idea is to keep unsafe external APIs outside the clean core of your system.

Strangler Fig

The Strangler Fig pattern is used to gradually migrate legacy systems. Instead of rewriting everything at once, you place a new interface around the old system and replace functionality piece by piece.

Old System
    |
    v
Compatibility Interface
    |
    v
New Modern C++ Implementation

Over time, the new implementation grows until the old system is no longer needed. This is safer than big-bang rewrites, especially in production systems where correctness and uptime matter.

Adapter at the Architecture Boundary

The Adapter pattern still matters in modern C++, but it is often most valuable at architectural boundaries. Instead of adapting every object internally, you adapt external APIs before they enter your domain model.

Vendor SDK Model  --->  Adapter  --->  Internal Domain Model

This keeps the rest of the codebase independent from vendor-specific data structures.

D. Resilience and Cloud-Native Patterns

C++ is increasingly used in distributed systems, backend services, robotics fleets, edge devices, and cloud-native infrastructure. In those environments, failures are normal. Networks fail. Services time out. GPUs run out of memory. Queues grow. Downstream systems become overloaded.

Circuit Breaker

A Circuit Breaker wraps fragile operations, such as remote calls, database queries, or external service requests. If too many failures occur, the circuit opens and future calls fail fast for a period of time.

Closed    -> calls allowed
Open      -> calls fail fast
Half-open -> limited trial calls allowed

This prevents the system from wasting threads and resources waiting on operations that are likely to fail.

Bulkhead

The Bulkhead pattern isolates resources so that one overloaded subsystem does not take down the whole application.

Thread Pool A -> video processing
Thread Pool B -> REST API
Thread Pool C -> telemetry

If the REST API is overloaded, the video processing pipeline can continue running. This is especially useful in systems that combine real-time processing with management APIs, dashboards, telemetry, or background tasks.

Retry with Exponential Backoff

Retries are useful for transient failures, but retrying too aggressively can make the situation worse. Exponential backoff increases the delay between attempts:

100 ms -> 200 ms -> 400 ms -> 800 ms -> ...

In distributed systems, this is often combined with jitter to prevent many clients from retrying at exactly the same time.

E. Enterprise, Domain, and Event-Driven Patterns

Some non-GoF patterns are especially common in enterprise systems, event-driven systems, and domain-driven design. They are not specific to C++, but they can be useful when C++ is used in backend or infrastructure services.

CQRS

CQRS stands for Command Query Responsibility Segregation. The idea is to separate write operations from read operations.

Commands -> modify state
Queries  -> read state

This can simplify scaling and performance tuning. For example, a write path may enforce domain invariants and transactions, while a read path may use optimized projections or cache-friendly views.

Repository

The Repository pattern hides persistence details behind a collection-like interface. Instead of scattering SQL, file access, or remote database calls throughout the business logic, the domain code talks to a repository.

class UserRepository {
public:
    virtual ~UserRepository() = default;

    virtual std::optional<User> find_by_id(UserId id) = 0;
    virtual void save(const User& user) = 0;
};

This is useful when the business logic should not depend directly on the database implementation.

Unit of Work

Unit of Work groups multiple changes into a single transactional operation.

begin transaction
    modify object A
    modify object B
    modify object C
commit

This is common in systems that need consistency across multiple repository operations.

Sidecar and Backend-for-Frontend

Sidecar and BFF are not C++ design patterns in the traditional sense. They are architectural and deployment patterns. A Sidecar is a helper process or container deployed alongside the main application. It may handle telemetry, logging, TLS, service discovery, or proxying.

[C++ Service] <-> [Sidecar Proxy]

A Backend-for-Frontend, or BFF, is a dedicated backend tailored to a specific frontend or client type. These patterns are common in cloud-native systems where infrastructure concerns are separated from core application logic.

4. Practical Rule of Thumb

Here is a practical decision matrix for modern C++ systems.

Situation Prefer Avoid
Core dependency required by a class Constructor Injection Service Locator or hidden globals
Wiring many objects at startup Composition Root Scattered initialization
Selecting implementation at runtime Factory Registry / Plugin Pattern Hardcoded conditional branches everywhere
Integrating with C APIs or vendor SDKs Anti-Corruption Layer Spreading raw SDK calls across business logic
Optional cross-cutting infrastructure Carefully controlled Service Locator Raw global variables
High-throughput frame processing Pipeline + Producer-Consumer One massive sequential loop
Passing large buffers Move ownership or references Repeated deep copies
High-frequency allocations RAII + Object Pool Repeated hot-path heap allocation
Runtime failure on hot path expected / result types Error codes spread everywhere
Remote service instability Circuit Breaker + Backoff Infinite retries
Resource overload Bulkhead isolation Shared thread pool for everything

5. The Modern C++ Developer's Shortlist

If you work on modern C++ systems, especially in backend, AI inference, robotics, computer vision, or high-performance computing, the most useful patterns and idioms to master are the following.

RAII and Rule of Zero

RAII is the foundation of reliable C++. Resources should be acquired in constructors and released in destructors. Whenever possible, ownership should be expressed using standard library types such as std::vector, std::unique_ptr, std::shared_ptr, std::lock_guard, and std::jthread.

The Rule of Zero says that most classes should not manually define destructors, copy constructors, move constructors, or assignment operators. Let the members manage themselves.

Dependency Injection and Composition Root

Make dependencies explicit. A class should clearly state what it needs to function. Constructor injection makes code easier to test, easier to understand, and easier to maintain. The Composition Root keeps dependency wiring in one place.

Type Erasure and Policy-Based Design

Use type erasure when you need runtime flexibility without exposing inheritance-heavy APIs. Use policy-based design when behavior can be selected at compile time. Both are powerful alternatives to traditional object-oriented hierarchies.

Concepts and Static Polymorphism

Use concepts to express compile-time requirements clearly. They are especially useful in generic libraries, performance-sensitive code, and template-heavy systems.

Pipeline and Producer-Consumer

Use pipelines to split real-time processing into independent stages. This is one of the most important patterns for video processing, AI inference, robotics, streaming systems, and data acquisition.

Object Pool and Zero-Copy Ownership

Avoid repeated allocation and copying in hot paths. Use object pools, move semantics, preallocated buffers, and ownership-passing queues to reduce latency and improve predictability.

Anti-Corruption Layer

Keep unsafe, ugly, or unstable external APIs at the boundary. Do not let vendor SDK types, C APIs, or legacy models spread throughout your internal codebase.

Circuit Breaker and Bulkhead

In distributed or service-oriented systems, design for failure. Use circuit breakers to fail fast when dependencies are unhealthy. Use bulkheads to isolate critical resources.

Conclusion

The original GoF patterns are still historically important, but modern C++ requires a broader vocabulary.

Today, good C++ architecture is not only about class diagrams. It is about ownership, lifetimes, data movement, concurrency, compile-time constraints, hardware resources, dependency boundaries, and operational resilience.

Some classic patterns remain useful. Others are replaced by language features. Many new recurring solutions come from domains the GoF book did not address: real-time systems, cloud infrastructure, AI inference, GPU programming, and distributed services.

The modern C++ mindset is this:

  • Prefer explicit ownership.
  • Prefer explicit dependencies.
  • Prefer value semantics when possible.
  • Prefer compile-time validation when practical.
  • Protect your domain from messy boundaries.
  • Design hot paths around data movement, not object hierarchies.
  • Design distributed systems assuming failure will happen.

By moving from inheritance-heavy designs toward RAII, composition, concepts, pipelines, type erasure, and clean architectural boundaries, modern C++ systems become faster, safer, easier to test, and better prepared for production workloads.