Phase 5 · Professional C++ EngineeringModule 28~60 min read

Architecture, Design Patterns & API Design

Create maintainable C++ systems with stable boundaries, deliberate polymorphism, dependency direction, patterns, and compatible APIs.

What you'll learn

Architecture is the deliberate placement of responsibilities and dependencies so change stays local. Patterns are names for recurring tradeoffs, not goals by themselves. You will design boundaries around values, ownership, errors, polymorphism, compilation, and compatibility.

By the end, you'll be able to:

  • Evaluate cohesion, coupling, dependency direction, and stable boundaries
  • Choose value, object, generic, and runtime-polymorphic designs deliberately
  • Apply selected patterns without hiding ownership or control flow
  • Publish APIs with explicit lifetime, failure, concurrency, and compatibility contracts

Cohesion and dependency direction

Cohesion keeps related data and behavior together. Coupling measures how many details one component must know about another. A stable boundary presents domain concepts while hiding volatile technology details. Source dependencies should point toward stable policy, with file systems, networks, and frameworks adapted at the edges.

dependency_map.txt
CLI / GUI / HTTP adapter
          |
          v
Application use cases  --->  abstract ports (Clock, Store, Logger)
          |                              ^
          v                              | implements
Domain values and rules        filesystem / database / network adapters

Key idea

A boundary is useful when it protects a reason to change. Splitting every class into its own library adds ceremony without necessarily reducing coupling.

Value, object, and generic design

Value-oriented design models independent data with equality and copy/move semantics. Object-oriented design protects state behind behavior and can vary implementation at runtime. Generic design expresses algorithms over compile-time capabilities. A system can use all three, selecting the simplest mechanism that preserves the contract.

StyleStrong fitPrimary cost
ValueDomain facts, messages, results, configurationCopying or large representation
Encapsulated objectStateful invariant and cohesive behaviorIdentity and mutation reasoning
Virtual interfaceOpen runtime implementationsIndirection, ownership, ABI
Template/conceptOpen compile-time operationsBuild cost and exposed definitions
VariantClosed alternatives with value semanticsVisitation changes when the set grows

SOLID in Modern C++

SOLID principles are prompts for evaluating change, not laws requiring inheritance. A class should have one cohesive reason to change; substitutable implementations must honor their base contract; focused interfaces reduce forced dependencies; high-level policy should not depend directly on volatile detail.

  • Single responsibility: group one cohesive policy, not necessarily one method
  • Open/closed: add behavior through data, composition, variants, templates, or interfaces as appropriate
  • Liskov substitution: derived implementations preserve preconditions, postconditions, and invariants
  • Interface segregation: consumers depend only on operations they need
  • Dependency inversion: policy owns an abstraction that external detail implements

Watch out

An interface for every concrete class increases indirection and mocking without creating a useful variation point. Abstract where independently changing implementations or tests need a stable seam.

Patterns as tradeoffs

Strategy supplies interchangeable behavior, factory centralizes validated construction, observer publishes events, adapter translates an external interface, command packages an action, and state delegates behavior by lifecycle phase. Each pattern adds objects and indirection; begin with direct code and introduce the pattern when the variation becomes real.

PatternVariation it contains
StrategyHow an operation is performed
FactoryWhich valid concrete value is constructed
ObserverWhich subscribers react to an event
AdapterHow an external contract maps to an internal one
CommandWhich action is stored, queued, logged, or undone
StateWhich behavior applies in the current lifecycle state
strategy.cpp
#include <functional>
#include <iostream>
#include <vector>

using Pricing = std::function<double(double)>;

class Checkout {
public:
    explicit Checkout(Pricing pricing) : pricing_{std::move(pricing)} {}
    double total(const std::vector<double>& prices) const {
        double result{};
        for (double price : prices) result += pricing_(price);
        return result;
    }
private:
    Pricing pricing_;
};

int main() {
    Checkout sale{[](double price) { return price * 0.9; }};
    std::cout << sale.total({10.0, 20.0}) << '\n';
}

Injection, Pimpl, and type erasure

Constructor injection makes required collaborators visible and testable. Pimpl stores a pointer to a hidden implementation, reducing header dependencies and helping ABI stability at the cost of allocation and indirection. Type erasure stores heterogeneous implementations behind one value-like runtime interface without requiring them to inherit a shared base.

client.h
#pragma once
#include <memory>
#include <string>

class Client {
public:
    explicit Client(std::string endpoint);
    ~Client();

    Client(Client&&) noexcept;
    Client& operator=(Client&&) noexcept;
    Client(const Client&) = delete;
    Client& operator=(const Client&) = delete;

    std::string fetch();

private:
    class Impl;
    std::unique_ptr<Impl> impl_;
};
The destructor is defined in the source file where Client::Impl is complete.

Note

Pimpl is not free encapsulation. Use it when compile-time isolation or binary compatibility matters enough to justify heap allocation, indirection, and explicit special members.

API contracts and compatibility

A C++ API must communicate value ranges, ownership transfer, borrowed lifetimes, invalidation, thread safety, blocking, error channels, complexity, and version compatibility. Prefer domain types and spans to parallel raw parameters, return values to output mutation, and named operations to Boolean flag combinations.

api_shape.cpp
// Weak: unclear ownership, units, bounds, and error signaling.
int send(void* data, int length, int timeout, bool async);

// Stronger: types state borrowing, byte count, duration, and typed failure.
#include <chrono>
#include <expected>
#include <span>

enum class SendError { disconnected, timed_out, rejected };
std::expected<std::size_t, SendError>
send(std::span<const std::byte> payload, std::chrono::milliseconds timeout);
std::expected requires C++23; a project can use another result type under C++20.
  • Add new behavior compatibly before removing old behavior
  • Deprecate with migration guidance and a documented timeline
  • Keep ownership and error policy consistent across one API family
  • Use characterization tests before refactoring legacy behavior
  • Move unsafe mechanisms behind small adapters, then improve inward

Tip

The easiest API to evolve is small. Expose stable concepts, keep implementation detail private, and avoid returning concrete containers when a range or domain result is the real contract.

Recap & quick check

Key takeaways

  • Architecture keeps volatile details behind stable domain-oriented boundaries.
  • Values, objects, templates, variants, and virtual interfaces solve different variation problems.
  • SOLID principles guide contract and dependency review; they do not mandate class hierarchies.
  • Patterns should name real variation and justify their added indirection.
  • C++ APIs must state ownership, lifetime, errors, invalidation, concurrency, complexity, and compatibility.

Quick check

1. What should source dependencies generally point toward?

2. Which technique models a closed set of alternatives?

3. When is Pimpl justified most clearly?

4. What should a borrowed-view API document?

Next: Module 29 — Performance, Portability & Secure C++, where design claims are tested against measurements, platforms, and hostile inputs.