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.
Table of Contents
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.