Skip to content

Latest commit

 

History

History
3896 lines (2830 loc) · 101 KB

slides.md

File metadata and controls

3896 lines (2830 loc) · 101 KB
theme default
class text-center
highlighter shiki
lineNumbers true
info ## Incremental Modernization Refactoring Legacy C++ OOP Application Code with Functional Programming and Type Erasure Concepts
drawings
persist
transition slide-left
title Incremental C++ Modernization
mdc true
layout image
image /title-card.png
vite
server
host allowedHosts
0.0.0.0
all

layout: center class: text-center background: https://source.unsplash.com/collection/94734566/1920x1080

Incremental Modernization

Refactoring Legacy C++ OOP Application Code with Functional Programming and Type Erasure Concepts

Jessica Ding, Software Engineer @ Bloomberg Tradebook


layout: default

Hi! I'm Jess!

  • Software Engineer @ Bloomberg Tradebook
  • We provide trading and analytics in a single system that uses quantitative data models to intelligently route orders to a suite of destinations
  • For futures and options (my team!), Tradebook supports simple, complex, and algorithmic strategies across global exchanges, with flexible access and connectivity
  • Tradebook is 30 years old and constantly building and pushing
  • We care very much about safe and incremental migration!

layout: default

The challenge of modernization

  • Modernization is risky — even partial changes can have catastrophic consequences
  • Knight Capital (2012): $440M loss in 45 minutes
    • Deprecated a feature ("Power Peg") but never removed its code
    • New code reused Power Peg's flag, but deploy failed on 1 of 8 servers, so Power Peg was activated
    • Power Peg's order completion logic had been removed → infinite orders
    • Faulty rollback didn't help
  • Dead code, stale flags, inconsistent deploys — these are modernization problems
Modernize safely, incrementally, and completely.

layout: default

Hidden risks in legacy code

  • Edge cases buried in old logic
  • Clients build workflows around quirks "The bug is the feature" (Hyrum's Law)
  • Compilation dependencies, cascading changes
xkcd 1172

layout: default

What do we actually need?

  • Testable in isolation — without spinning up databases, complex mocks, etc.
  • Safe to change — catch mistakes at compile time, not in production
  • Decoupled — change one piece without recompiling everything
  • Incremental — migrate one function at a time, not big-bang rewrites

Traditional OOP refactoring doesn't give us the whole solution.

  • Changing a base class cascades everywhere
  • Adding a new virtual method means touching every derived class
  • OOP has real strengths! Modularity, concurrent development, organizational alignment (Conway's Law)
  • Modern C++ enables functional and generic thinking without abandoning OOP

layout: default

Incremental, low-risk modernization

Using modern C++ features that align with where the language is heading
  • Pure functions → testability, parallelization
  • std::expected → type-safe error handling
  • Type erasure → flexible APIs, stable ABI
  • std::variant → compile-time state safety
  • Declarative pipelines → composable, monadic transformations

layout: two-cols layoutClass: gap-16 transition: none

Running example

enum OrderStatus { NEW, FILLED, CANCELLED, ... };

class Order {
public:
    int orderId;
    string symbol;
    int shares;
    double pricePerShare;
    OrderStatus status;
};

::right::

#  

Domain: Financial stock orders (buying/selling shares)

1. Start simple

  • Represent a stock order with id, stock ticker symbol, number of shares, and price per share

layout: two-cols layoutClass: gap-16 transition: none

Running example

enum OrderStatus { NEW, FILLED, CANCELLED, ... };

class Order {
public:
    int orderId;
    string symbol;
    int shares;
    double pricePerShare;
    OrderStatus status;

    void enrichWithMarketData(const MarketDataContext& ctx);
};

::right::

#  

Domain: Financial stock orders (buying/selling shares)

1. Start simple

2. "Can we show real-time stock prices from the market?"

  • Added enrichWithMarketData() method

layout: two-cols layoutClass: gap-16 transition: none

Running example

enum OrderStatus { NEW, FILLED, CANCELLED, ... };

class Order {
public:
    int orderId;
    string symbol;
    int shares;
    double pricePerShare;
    OrderStatus status;

    void enrichWithMarketData(const MarketDataContext& ctx);

    string toDisplayString();
};

::right::

#  

Domain: Financial stock orders (buying/selling shares)

1. Start simple

2. "Can we show real-time stock prices from the market?"

3. "We need display formatting for different trading desks"

  • Added toDisplayString() method

layout: two-cols layoutClass: gap-16 transition: none

Running example

enum OrderStatus { NEW, FILLED, CANCELLED, WORKING, ... };

class Order {
public:
    int orderId;
    string symbol;
    int shares;
    double pricePerShare;
    OrderStatus status;
    vector<Fill> fills;
    string cancelReason;

    void enrichWithMarketData(const MarketDataContext& ctx);

    string toDisplayString();

    void applyFill();
};

::right::

#  

Domain: Financial stock orders (buying/selling shares)

1. Start simple

2. "Can we show real-time stock prices from the market?"

3. "We need display formatting for different trading desks"

4. "Track cancels and partial fills"

  • Added applyFill(), fills vector, WORKING, cancelReason

layout: two-cols layoutClass: gap-16 transition: none

Running example

enum OrderStatus { NEW, FILLED, CANCELLED, WORKING, ... };

class Order {
public:
    int orderId;
    string symbol;
    int shares;
    double pricePerShare;
    OrderStatus status;
    vector<Fill> fills;
    string cancelReason;

    void enrichWithMarketData(const MarketDataContext& ctx);

    string toDisplayString();

    void applyFill();

    // ... 20+ scattered mutation methods
};

::right::

#  

Domain: Financial stock orders (buying/selling shares)

1. Start simple

2. "Can we show real-time stock prices from the market?"

3. "We need display formatting for different trading desks"

4. "Track cancels and partial fills"

Each addition made sense in isolation...
...but we never stepped back to redesign!

layout: two-cols layoutClass: gap-16 transition: none

Pain points?

enum OrderStatus { NEW, FILLED, CANCELLED, WORKING, ... };

class Order {
public:
    int orderId;
    string symbol;
    int shares;
    double pricePerShare;
    OrderStatus status;
    vector<Fill> fills;
    string cancelReason;

    void enrichWithMarketData(const MarketDataContext& ctx);

    string toDisplayString();

    void applyFill();

    // ... 20+ scattered mutation methods
};

::right::


layout: two-cols layoutClass: gap-16 transition: none

Pain points?

enum OrderStatus { NEW, FILLED, CANCELLED, WORKING, ... };

class Order {
public:
    int orderId;
    string symbol;
    int shares;
    double pricePerShare;
    OrderStatus status;
    vector<Fill> fills;
    string cancelReason;

    void enrichWithMarketData(const MarketDataContext& ctx);

    string toDisplayString();

    void applyFill();

    // ... 20+ scattered mutation methods
};

::right::

#  

1. Inefficient state and data holding (God object)

  • Every order carries fields for ALL states
  • 1M active orders (90% NEW) = ~200MB wasted

layout: two-cols layoutClass: gap-16 transition: none

Pain points?

enum OrderStatus { NEW, FILLED, CANCELLED, WORKING, ... };

class Order {
public:
    int orderId;
    string symbol;
    int shares;
    double pricePerShare;
    OrderStatus status;
    vector<Fill> fills;
    string cancelReason;

    void enrichWithMarketData(const MarketDataContext& ctx);

    string toDisplayString();

    void applyFill();

    // ... 20+ scattered mutation methods
};

::right::

#  

1. Inefficient state and data holding (God object)

2. Tight coupling

  • Good luck testing this without a real market data service!
  • Good luck writing a reasonable mock for a non-trivial test!

layout: two-cols layoutClass: gap-16 transition: none

Pain points?

enum OrderStatus { NEW, FILLED, CANCELLED, WORKING, ... };

class Order {
public:
    int orderId;
    string symbol;
    int shares;
    double pricePerShare;
    OrderStatus status;
    vector<Fill> fills;
    string cancelReason;

    void enrichWithMarketData(const MarketDataContext& ctx);

    string toDisplayString();

    void applyFill();

    // ... 20+ scattered mutation methods
};

::right::

#  

1. Inefficient state and data holding (God object)

2. Tight coupling

3. Scattered transformation logic

  • 20+ mutation methods scattered across many files
  • Can't expect LLMs to make changes elegantly either

layout: two-cols layoutClass: gap-16

Pain points?

enum OrderStatus { NEW, FILLED, CANCELLED, WORKING, ... };

class Order {
public:
    int orderId;
    string symbol;
    int shares;
    double pricePerShare;
    OrderStatus status;
    vector<Fill> fills;
    string cancelReason;

    void enrichWithMarketData(const MarketDataContext& ctx);

    string toDisplayString();

    void applyFill();

    // ... 20+ scattered mutation methods
};

::right::

#  

1. Inefficient state and data holding (God object)

2. Tight coupling

3. Scattered transformation logic

4. Global mutable state

  • Good luck reasoning about order state with any concurrency!

layout: center class: text-center

Smallest possible change?

Extract pure functions

layout: two-cols layoutClass: gap-16

Pure functions

BEFORE: Impure

void Order::enrichWithMarketData(
    const MarketDataContext& ctx
) {
    this->price =
        ctx.lookupPrice(this->symbol);
    // Mutation + coupling!
}

Problems:

  • Mutates this->price
  • Couples to MarketDataContext
  • Hard to test (need real service)
  • Can't parallelize safely

::right::

AFTER: Pure

[[nodiscard]]
Order enrichWithStockPrice(
    Order order,
    double currentPrice
) {
    order.price = currentPrice;
    return order;
}

Benefits:

  • [[nodiscard]] signals intent, prevents ignoring result
  • No mutation
  • No side effects
  • Easy to test (no mocks!)
  • Parallelizable

layout: default

Pure function usage

// Step by step
auto enriched = enrichWithStockPrice(order, 150.25);

// Or compose: output of one → input to next
auto result = validateOrder(enrichWithStockPrice(order, 150.25));

Referential transparency:

  • enrichWithStockPrice(order, 150.25) can be replaced by its return value anywhere!
  • Same result every time

But what happens when we have 5+ steps? And what if a step fails?


layout: default hide: true

Wait... pass by value?

Concern: Isn't copying expensive?

Answer: Modern C++ optimizes this!

Passing lvalue (existing variable):

Order myOrder = /* ... */;
auto result = enrichWithStockPrice(
    myOrder, 150.25
);
  • 1 copy: myOrder → parameter
  • NRVO eliminates return copy

Passing rvalue (temporary):

auto result = enrichWithStockPrice(
    Order{/* ... */}, 150.25
);
  • 0 copies: temporary moved
  • NRVO for return
See Appendix A for detailed copy elision mechanics

layout: default

Pure functions: Trade-offs

Benefits

  • Easier testing - no complex mocks
  • Parallelization - no shared state
  • Predictable - same input → same output
  • Easy to implement - works with legacy types!
  • Can start TODAY - extract from existing code

Drawbacks

  • Less obvious input remains unchanged (no const&)
    • Counter: [[nodiscard]] signals intent
  • Might accidentally modify parameter
    • Counter: short functions + code review
  • __attribute__((pure)) is an optimization hint, not a guarantee
    • Counter: code review
But what about errors? What if the price feed is down?

layout: center class: text-center

Breaking purity with exceptions defeats the purpose

Type-safe error handling with std::expected

layout: default transition: none

Does your error handling look like this?

Approach 1: Exceptions

double getMarketPrice(const string& symbol, const MarketDataService& svc) {
    auto price = svc.lookup(symbol);
    if (!price) throw MarketDataException("Price unavailable for " + symbol);
    return *price;
}

// call site
try {
    auto price = getMarketPrice(order.symbol, svc);
    try {
        auto enriched = enrichWithStockPrice(order, price);
        try {
            submit(enriched);
        } catch (const SubmitException& e) { /* ... */ }
    } catch (const ValidationException& e) { /* ... */ }
} catch (const MarketDataException& e) { /* ... */ }
  • Breaks purity
  • Non-local control flow
  • Usage: try/catch nightmare

layout: default transition: none

Does your error handling look like this?

Approach 2: Error codes

bool getMarketPrice(const string& symbol, const MarketDataService& svc,
                    double* out) {
    auto price = svc.lookup(symbol);
    if (!price) return false;
    *out = *price;
    return true;
}
  • Not composable
  • Loses error info
  • Forces output parameters

layout: default

Does your error handling look like this?

Approach 3: std::optional

optional<double> getMarketPrice(const string& symbol,
                                const MarketDataService& svc) {
    return svc.lookup(symbol);  // returns nullopt if unavailable
}
  • No error context
  • Can't tell why it failed — feed down? Invalid symbol? Market closed?

layout: default

Railway-oriented programming

Input
→
Validate
→
Enrich
→
Submit
→
Success
↓
↓
↓
Error Result (short-circuits, skips remaining steps)
Handle success and failure tracks in clear, composable ways

layout: default transition: none

std::expected for type-safe errors

// Define error cases clearly
enum class OrderError {
    InvalidSymbol,           // "ZZZZ" is not valid
    PriceUnavailable,        // Market data feed down
};

layout: default transition: none

std::expected for type-safe errors

// Define error cases clearly
enum class OrderError {
    InvalidSymbol,           // "ZZZZ" is not valid
    PriceUnavailable,        // Market data feed down
};

// Returns price OR error — not both, not neither
expected<double, OrderError> getMarketPrice(
    const string& symbol, const MarketDataService& svc
) {
    if (!svc.hasSymbol(symbol))
        return unexpected(OrderError::InvalidSymbol);
    auto price = svc.lookup(symbol);
    if (!price)
        return unexpected(OrderError::PriceUnavailable);
    return *price;  // Success — no wrapping needed
}

layout: default transition: none

Monadic composition with std::expected

// .transform(f): f cannot fail, wraps result in expected automatically
// .and_then(f):  f can fail, returns its own expected

Option 1: std::bind_front (readable)

auto result = getMarketPrice(order.symbol, svc)
    .transform(bind_front(enrichWithStockPrice, order))
    .and_then(validateOrder);

Option 2: Lambda (most flexible)

auto result = getMarketPrice(order.symbol, svc)
    .transform([&](double p) { return enrichWithStockPrice(order, p); })
    .and_then(validateOrder);

What about explicit steps? (no automatic short-circuiting!)

auto price = getMarketPrice(order.symbol, svc);    // expected<double, OrderError>
if (!price) return unexpected(price.error());       // manual error propagation!
auto enriched = enrichWithStockPrice(order, *price);
auto result = validateOrder(enriched);

layout: default

Using std::expected results

// Chain everything, handle the result once at the end
auto result = getMarketPrice(order.symbol, svc)
    .transform(bind_front(enrichWithStockPrice, order))
    .and_then(validateOrder)
    .and_then(checkBuyingPower);

// One check — not nested if/else for every step
if (result) {
    submit(*result);
} else {
    logError(result.error());  // know exactly which step failed and why
}
std::expected = "this operation might fail" (error handling)
std::variant = "this value can be one of several types" (state modeling)

layout: default class: tight-lines

Railway pattern benefits

<style> .slidev-vclick-prior { opacity: 1 !important; } </style>

Free short-circuiting

  • If getMarketPrice fails → the rest never run
  • Error propagates automatically

Type-safe

  • Can't forget to check errors (compiler forces you)
  • Know exactly what error occurred

Composable

  • Chain operations with .and_then()
  • No nested try/catch or if statements

Pure

  • Errors are values, not exceptions
  • Still referentially transparent

layout: center class: text-center

Legacy OOP code uses abstract base classes everywhere

Type erasure for extensible APIs

layout: default transition: none

Type erasure: Event handling

BEFORE: Abstract base class

class OrderListener {
public:
    virtual void onOrderFilled(
        const Order&) = 0;
};

class DatabaseLogger
    : public OrderListener {
    Database& db;
public:
    void onOrderFilled(
        const Order& o) override {
        db.log(o.id, o.symbol);
    }
};

Problems:

  • Invasive: Must inherit
  • Recompilation cascade from adding event

AFTER: std::function


layout: default transition: none

Type erasure: Event handling

BEFORE: Abstract base class

class OrderListener {
public:
    virtual void onOrderFilled(
        const Order&) = 0;
};

class DatabaseLogger
    : public OrderListener {
    Database& db;
public:
    void onOrderFilled(
        const Order& o) override {
        db.log(o.id, o.symbol);
    }
};

Problems:

  • Invasive: Must inherit
  • Recompilation cascade from adding event

AFTER: std::function

ANY callable works! Lambda:

// No OrderListener needed!
function<void(const Order&)> onFilled =
    [db](const Order& o) {
        db.log(o.id, o.symbol);
    };

onFilled(order);
  • Zero boilerplate, captures local state inline
  • Best for one-off handlers

layout: default transition: none

Type erasure: Event handling

BEFORE: Abstract base class

class OrderListener {
public:
    virtual void onOrderFilled(
        const Order&) = 0;
};

class DatabaseLogger
    : public OrderListener {
    Database& db;
public:
    void onOrderFilled(
        const Order& o) override {
        db.log(o.id, o.symbol);
    }
};

Problems:

  • Invasive: Must inherit
  • Recompilation cascade from adding event

AFTER: std::function

ANY callable works! Free function:

// No OrderListener needed!
void logToDatabase(const Order& o) {
    db.log(o.id, o.symbol);
}

function<void(const Order&)> onFilled =
    logToDatabase;
onFilled(order); // same call site
  • Reusable, easy to unit test in isolation
  • Can't capture state — db must come from elsewhere

layout: default transition: none

Type erasure: Event handling

BEFORE: Abstract base class

class OrderListener {
public:
    virtual void onOrderFilled(
        const Order&) = 0;
};

class DatabaseLogger
    : public OrderListener {
    Database& db;
public:
    void onOrderFilled(
        const Order& o) override {
        db.log(o.id, o.symbol);
    }
};

Problems:

  • Invasive: Must inherit
  • Recompilation cascade from adding event

AFTER: std::function

ANY callable works! operator() struct:

// No OrderListener needed!
struct DatabaseLogger {
    Database db;
    void operator()(const Order& o) {
        db.log(o.id, o.symbol);
    }
};

function<void(const Order&)> onFilled =
    DatabaseLogger{db};
onFilled(order); // same call site
  • Reusable AND stateful — closest to what you already know
  • More boilerplate, but no base class required!
  • Class that acts like a function

layout: default

Type erasure: Event handling

BEFORE: Abstract base class

class OrderListener {
public:
    virtual void onOrderFilled(
        const Order&) = 0;
};

class DatabaseLogger
    : public OrderListener {
    Database& db;
public:
    void onOrderFilled(
        const Order& o) override {
        db.log(o.id, o.symbol);
    }
};

Problems:

  • Invasive: Must inherit
  • Recompilation cascade from adding event

AFTER: std::function

ANY callable works! operator() struct:

// No OrderListener needed!
struct DatabaseLogger {
    Database db;
    void operator()(const Order& o) {
        db.log(o.id, o.symbol);
    }
};

function<void(const Order&)> onFilled =
    DatabaseLogger{db};
onFilled(order); // same call site
  • std::function requires copyable callables. What if you want to capture a move-only unique_ptr or a file handle?

layout: default transition: none

std::move_only_function (C++23)

What if the handler owns a unique resource?

auto socket = make_unique<AuditSocket>();

// std::function won't compile —
// unique_ptr isn't copyable!
// function<void(const Order&)> onFilled =
//   [sock=move(socket)](...){}; // ❌

// move_only_function: movable only
move_only_function<void(const Order&)>
  onFilled =
  [sock=move(socket)](const Order& o) {
      sock->send(o.id, o.symbol); // ✓
  };

onFilled(order); // same call site
  • unique_ptr is move-only — std::function won't compile

layout: default transition: none

std::move_only_function (C++23)

What if the handler owns a unique resource?

auto socket = make_unique<AuditSocket>();

// std::function won't compile —
// unique_ptr isn't copyable!
// function<void(const Order&)> onFilled =
//   [sock=move(socket)](...){}; // ❌

// move_only_function: movable only
move_only_function<void(const Order&)>
  onFilled =
  [sock=move(socket)](const Order& o) {
      sock->send(o.id, o.symbol); // ✓
  };

onFilled(order); // same call site
  • unique_ptr is move-only — std::function won't compile
  • move_only_function requires only movability; handler has sole ownership

layout: default transition: none

std::move_only_function (C++23)

What if the handler owns a unique resource?

auto socket = make_unique<AuditSocket>();

// std::function won't compile —
// unique_ptr isn't copyable!
// function<void(const Order&)> onFilled =
//   [sock=move(socket)](...){}; // ❌

// move_only_function: movable only
move_only_function<void(const Order&)>
  onFilled =
  [sock=move(socket)](const Order& o) {
      sock->send(o.id, o.symbol); // ✓
  };

onFilled(order); // same call site
  • unique_ptr is move-only — std::function won't compile
  • move_only_function requires only movability; handler has sole ownership
  • Same call site — onFilled(order) unchanged

layout: default

Type-erased callables: Trade-offs

<style> .slidev-vclick-prior { opacity: 1 !important; } </style>

Benefits

  • No inheritance - lambda, free function, or operator() struct all work
  • Uniform call site - onFilled(order) regardless of what's inside
  • Open for extension, closed for modification - add a new handler without touching existing code or recompiling

What remains the same

  • Virtual call cost - dispatch still goes through a hidden vtable pointer
  • No compiler optimization - no inlining or constant folding across the erased boundary

Drawbacks

  • Heap allocation - large captures exceed small buffer optimization
A single signature is enough for event handlers — but what about complex pipelines?

layout: default

Not every order needs every step

  • Risk checks - "Can we afford this trade, and are we allowed to make it?"

    • High-value orders — buying millions of dollars of stock must go through risk. No exceptions
    • Test orders — should never touch real $. We can skip risk checks

The pipeline needs to ask each step: does this apply to this order?

for (auto& processor : pipeline) {
    if (processor.canProcess(order))  // ← gate: does this apply?
        order = processor.process(order);  // ← transform
}
// two methods, one object — more than a single callable

Two methods, one object. std::function can't express that — we need a richer abstraction.


layout: center class: text-center

What about pluggable processing pipelines?

Concept-based type erasure

layout: default transition: none

Order pipeline: The OOP approach

class OrderProcessor {
public:
    virtual bool canProcess(
        const Order&) const = 0;
    virtual Order process(
        const Order&) = 0;
    virtual ~OrderProcessor() = default;
};

vector<unique_ptr<OrderProcessor>>
    pipeline_;
for (auto& p : pipeline_)
    if (p->canProcess(order))
        order = p->process(order);
  • OrderProcessor requires two virtual methods: canProcess and process

layout: default transition: none

Order pipeline: The OOP approach

class OrderProcessor {
public:
    virtual bool canProcess(
        const Order&) const = 0;
    virtual Order process(
        const Order&) = 0;
    virtual ~OrderProcessor() = default;
};

class RiskCheck
    : public OrderProcessor {
public:
    bool canProcess(
        const Order& o) const override {
        return !o.isInternal();
    }
    Order process(
        const Order& o) override {
        return checkRisk(o);
    }
};
  • OrderProcessor requires two virtual methods: canProcess and process
  • RiskCheck needs canProcess — skips internal orders

layout: default transition: none

Order pipeline: The OOP approach

class OrderProcessor {
public:
    virtual bool canProcess(
        const Order&) const = 0;
    virtual Order process(
        const Order&) = 0;
    virtual ~OrderProcessor() = default;
};

class EnrichmentProcessor
    : public OrderProcessor {
    MarketDataService& svc_;
public:
    bool canProcess(  // always enrich
        const Order&) const override {
        return true;  // ← forced boilerplate
    }
    Order process(
        const Order& o) override {
        return enrichWithStockPrice(
            o, svc_.lookup(o.symbol));
    }
};
// FillProcessor... same pattern
  • OrderProcessor requires two virtual methods: canProcess and process
  • RiskCheck needs canProcess — skips internal orders
  • EnrichmentProcessor never skips — but the base class demands canProcess from everyone

layout: default transition: none

Order pipeline: The OOP approach

class OrderProcessor {
public:
    virtual bool canProcess(
        const Order&) const = 0;
    virtual Order process(
        const Order&) = 0;
    virtual ~OrderProcessor() = default;
};

class EnrichmentProcessor
    : public OrderProcessor {
    MarketDataService& svc_;
public:
    bool canProcess(  // always enrich
        const Order&) const override {
        return true;  // ← forced boilerplate
    }
    Order process(
        const Order& o) override {
        return enrichWithStockPrice(
            o, svc_.lookup(o.symbol));
    }
};
// FillProcessor... same pattern
  • OrderProcessor requires two virtual methods: canProcess and process
  • RiskCheck needs canProcess — skips internal orders
  • EnrichmentProcessor never skips — but the base class demands canProcess from everyone
  • same for FillsProcessor

layout: default

Order pipeline: The OOP approach

// Compliance team's code — can't modify
class ComplianceCheck {
public:
    bool canProcess(
        const Order& o) const {
        return o.value() > threshold_;
    }
    Order process(
        const Order& o) const {
        return runComplianceRules(o);
    }
};

// Our pipeline
vector<unique_ptr<OrderProcessor>>
    pipeline_;
for (auto& p : pipeline_)
    if (p->canProcess(order))
        order = p->process(order);

pipeline_.emplace_back(
    ComplianceCheck{});  // ❌ won't compile
  • How to use external types, like compliance team's checker?
  • Right methods, right signatures — but doesn't inherit from OrderProcessor
  • Must write a forwarding wrapper

layout: default transition: none

Concept-based type erasure

template<typename Processor>
concept OrderProcessor =
    requires(Processor p, const Order& o) {
        { p.process(o) } -> same_as<Order>;
    };

struct RiskCheck {
    bool canProcess(  // optional — used if present
        const Order& o) const {
        return !o.isInternal();
    }
    Order process(
        const Order& o) const {
        return checkRisk(o);
    }
};

struct EnrichmentProcessor {
    MarketDataService& svc_;
    // no canProcess needed
    Order process(const Order& o) const {
        return enrichWithStockPrice(
            o, svc_.lookup(o.symbol));
    }
};
  • OrderProcessor is a concept
    • Defines what a processor must be able to do
    • Only process is required — canProcess is optional

layout: default transition: none

Concept-based type erasure

template<typename Processor>
concept OrderProcessor =
    requires(Processor p, const Order& o) {
        { p.process(o) } -> same_as<Order>;
    };

struct RiskCheck {
    bool canProcess(  // optional — used if present
        const Order& o) const {
        return !o.isInternal();
    }
    Order process(
        const Order& o) const {
        return checkRisk(o);
    }
};

struct EnrichmentProcessor {
    MarketDataService& svc_;
    // no canProcess needed
    Order process(const Order& o) const {
        return enrichWithStockPrice(
            o, svc_.lookup(o.symbol));
    }
};
  • OrderProcessor is a concept
    • Defines what a processor must be able to do
    • Only process is required — canProcess is optional
  • RiskCheck provides canProcess — the wrapper uses it when present

layout: default transition: none

Concept-based type erasure

template<typename Processor>
concept OrderProcessor =
    requires(Processor p, const Order& o) {
        { p.process(o) } -> same_as<Order>;
    };

struct RiskCheck {
    bool canProcess(  // optional — used if present
        const Order& o) const {
        return !o.isInternal();
    }
    Order process(
        const Order& o) const {
        return checkRisk(o);
    }
};

struct EnrichmentProcessor {
    MarketDataService& svc_;
    // no canProcess needed
    Order process(const Order& o) const {
        return enrichWithStockPrice(
            o, svc_.lookup(o.symbol));
    }
};
  • OrderProcessor is a concept
    • Defines what a processor must be able to do
    • Only process is required — canProcess is optional
  • RiskCheck provides canProcess — the wrapper uses it when present
  • EnrichmentProcessor only needs process. No boilerplate!

layout: default transition: none

Concept-based type erasure

// AnyOrderProcessor's constructor:
template<OrderProcessor Processor>
    // ^ concept checked here
AnyOrderProcessor(Processor proc);

// Compliance team's code — unmodified
class ComplianceCheck {
    /* canProcess + process */
};

pipeline.emplace_back(
    ComplianceCheck{});
    // check: concept satisfied?
    // wrong method? error right here
  • AnyOrderProcessor is a type-erased wrapper
    • concrete class that satisfies the OrderProcessor concept
    • The concept defines the contract; the wrapper performs the type erasure
  • ComplianceCheck satisfies the concept

    — No forwarding wrapper needed!

    • Concept checked at compile
  • If ComplianceCheck doesn't fit, you get a clear error at emplace_back!


layout: default transition: none

Concept-based type erasure

class AnyOrderProcessor {
    template<OrderProcessor Processor>
    AnyOrderProcessor(Processor proc) {
        // compile-time check at construction
        if constexpr (requires(const Order& o) {
            proc.canProcess(o);
        })
            canProcess_ = [proc](const Order& o) {
                return proc.canProcess(o); // yes (RiskCheck)
            };
        else
            canProcess_ = [](const Order&) {
                return true;              // no (EnrichmentProcessor)
            };
    }
};
  • AnyOrderProcessor uses if constexpr and requires
    • detects canProcess at compile time and defaults to true if absent
    • ⚠️ typo risk: misspelled canProcess silently passes all orders through
  • Guard option: make canProcess required in the concept; provide an AlwaysProcess mixin for processors that never skip (inheritance dependency)

layout: default

Concept-based type erasure

vector<AnyOrderProcessor> pipeline;
pipeline.emplace_back(RiskCheck{});
pipeline.emplace_back(
    EnrichmentProcessor{svc_});
pipeline.emplace_back(ComplianceCheck{});

for (auto& processor : pipeline)
    if (processor.canProcess(order))
        order = processor.process(order);
  • Same pipeline loop
    • AnyOrderProcessor type-erases both methods

layout: default

How this type erasure example works

// When you write this:
pipeline.emplace_back(LegacyRiskCheck{});

// The wrapper does this:
template<OrderProcessor T>
AnyOrderProcessor(T rc)
    : ptr_(make_unique<Model<T>>(rc)) {} // Stores as unique_ptr<ConceptBase> (type erasure!)
// Runtime dispatch flow:
step.process(order);  // User calls this
  ↓
AnyOrderProcessor::process(order) { return ptr_->process(order); }
  ↓  (virtual call through ConceptBase*)
Model<LegacyRiskCheck>::process(order) { return impl_.process(order); }
  ↓  (direct call to wrapped object)
LegacyRiskCheck::process(order) { /* risk logic */ }
  • Pointer dereference + virtual dispatch + inlineable wrapper call = usually same cost as virtual dispatch through an abstract base class

Want zero heap allocation? Hand-roll the vtable instead


layout: default

Why not the visitor pattern?

// Visitor Pattern (traditional OOP):
class OrderProcessor {
public:
    virtual void visit(NewOrder&) = 0;
    virtual void visit(Filled&) = 0;
    virtual void visit(Cancelled&) = 0;
    // Adding new state type → change base class → recompile ALL visitors!
};
Visitor Type Erasure
Add new processor Easy — implement the interface Easy — implement the concept
Add new state type Hard — change base class, recompile all visitors Easy — nothing else recompiles
External types Must inherit or wrap Just satisfy the concept

layout: default

Type erasure with concepts: Trade-offs

Benefits

  • Compile-time contract — wrong signature fails early, not at runtime
  • No shared base class in your code — adding a new processor doesn't touch anything already compiled
  • Fit external types — ComplianceCheck needed zero changes to participate
  • Optional methods — canProcess can default via if constexpr.

What remains the same

  • Virtual dispatch still happens — AnyOrderProcessor hides a ConceptBase* internally. vector<AnyOrderProcessor> works because each wrapper heap-allocates its concrete type

Drawbacks

  • Boilerplate — AnyOrderProcessor wrapper must be written once
  • Harder debugging — stacktraces go through the wrapper's indirection
  • Type erasure is irreversible — can't recover the concrete type from the pipeline without custom RTTI
We've improved dispatch and error handling. But how do we enforce state-data relationships?

layout: center class: text-center

Our OrderStatus enum doesn't enforce state-data relationships

Type-safe state with std::variant

layout: default

Enum + switch: What goes wrong

enum OrderStatus { NEW, FILLED, CANCELLED, WORKING };

class Order {  // pure functions improved how we operate on this — but the type is still unsafe
    OrderStatus status;
    vector<Fill> fills;     // Only valid for FILLED/WORKING
    string cancelReason;    // Only valid for CANCELLED
    int shares;             // Only valid for NEW
};

void handleStockOrder(const Order& order) {
    switch (order.status) {
        case NEW:
            // Send order to exchange
            break;
        case FILLED:
            // Settle: transfer shares to account
            break;
        // Forgot CANCELLED? Compiler: maybe warning (not error!)
        // Code compiles, ships to production, bug at runtime
    }
}

// Add PARTIALLY_FILLED? Still compiles with just a warning!
// Runtime bug!

layout: default transition: none

Variant + visit: Type-safe

// Each state carries only its own data
struct NewOrder { int shares; };
struct Working { int shares; vector<Fill> fills; };
struct Filled { int shares; vector<Fill> fills; };
struct Cancelled { string reason; };

using OrderStatus = variant<
    NewOrder, Working, Filled, Cancelled>;
  • Each state is a distinct type — no more god object

layout: default transition: none

Variant + visit: Type-safe

// Each state carries only its own data
struct NewOrder { int shares; };
struct Working { int shares; vector<Fill> fills; };
struct Filled { int shares; vector<Fill> fills; };
struct Cancelled { string reason; };

using OrderStatus = variant<
    NewOrder, Working, Filled, Cancelled>;
  • Each state is a distinct type — no more god object
  • OrderStatus can be exactly one of these at a time

layout: default transition: none

Variant + visit: Type-safe

struct OrderV2 { int id;
                 string symbol;
                 OrderStatus status; };

using Result = expected<OrderV2, OrderError>;

Result handleStockOrder(const OrderV2& order) {
    return visit([](auto&& state) -> Result {
        using T = decay_t<decltype(state)>;
        if constexpr (is_same_v<T, NewOrder>)
            return sendToExchange(state.shares);
        else if constexpr (is_same_v<T, Working>)
            return settleFills(state.fills);
        else if constexpr (is_same_v<T, Filled>)
            return settle(state);
        // Forgot Cancelled! Compiler ERROR!
    }, order.status);
}
  • Now handleStockOrder can use std::visit
    • Exhaustively handle all OrderStatus variants
    • Forget a case and compiler will error

layout: default transition: none

Variant: Scales safely

// Add a state — compiler forces you to handle it
// EVERYWHERE, including all pipeline steps
struct Pending { int shares; string venue; };
using OrderStatus = variant<
    NewOrder, Working, Filled, Cancelled, Pending>;
  • Add a state: every std::visit in every pipeline step fails to compile until handled
  • The compiler finds every place for you — no grep needed!

layout: default

Variant: Scales safely

// Type-safe access: must pattern match
if (auto* filled = get_if<Filled>(&order.status)) {
    auto shares = filled->fills[0].shares;
    // Compiler KNOWS this is Filled, fills exist
}

// Access fills on a generic order? Won't compile:
order.status.fills;
// ERROR: variant has no member 'fills'
  • Must pattern match to access state-specific data

    — compiler prevents wrong field access


layout: default

std::variant key takeaways

<style> .slidev-vclick-prior { opacity: 1 !important; } </style>

Exhaustiveness — std::visit forces handling ALL cases. Add a state, compiler finds everything that needs updating.

Type-safe access — each state carries only its own data. Compiler prevents wrong field access.

Memory — variant only as large as its largest alternative. No wasted fields.

Immutability — state transitions return a new variant, consistent with our pass-by-value pure functions. Thread-safe by default, easier to reason about.


layout: center class: text-center

How do we tie all these patterns together?

Declarative pipelines

layout: default

Composition, revisited

We already solved chaining with .and_then:

getMarketPrice(order.symbol, ctx)
    .transform(bind_front(enrichWithStockPrice, order))
    .and_then(validateOrder)
    .and_then(applyFills);

But this chain is hardcoded — steps are code structure, not data:

  • Add or reorder a step → restructure the chain
  • Different steps for limit vs market orders → no clean way
What if steps were a configurable list instead?
We can still use .and_then. Just automate the chaining!

layout: default transition: none

Declarative pipeline architecture

// all infrastructure in one mockable object
struct OrderContext {
    optional<double> marketPrice(
        const string& symbol) const;
    bool hasValidSymbol(
        const string& symbol) const;
};
  • OrderContext — explicit read-only dependencies, not globals (Reader monad pattern)

layout: default transition: none

Declarative pipeline architecture

namespace order_pipeline {
    using Transform = function<
        expected<OrderV2, OrderError>(
            const OrderV2&, const OrderContext&)>;

    static const vector<Transform> steps = {
        enrichWithMarketData,
        validateOrder,
        applyFills,
    };

    expected<OrderV2, OrderError> execute(
        const OrderV2& order,
        const OrderContext& ctx
    ) {
        return accumulate(steps.begin(), steps.end(),
            expected<OrderV2, OrderError>(order),
            [&](auto acc, const Transform& step) {
                return acc
                    .and_then([&](const OrderV2& o) {
                        return step(o, ctx);
                });
            });
    }
}
  • Transform — std::function erasing any callable with the right signature. No base class needed

layout: default transition: none

Declarative pipeline architecture

namespace order_pipeline {
    using Transform = function<
        expected<OrderV2, OrderError>(
            const OrderV2&, const OrderContext&)>;

    static const vector<Transform> steps = {
        enrichWithMarketData,
        validateOrder,
        applyFills,
    };

    expected<OrderV2, OrderError> execute(
        const OrderV2& order,
        const OrderContext& ctx
    ) {
        return accumulate(steps.begin(), steps.end(),
            expected<OrderV2, OrderError>(order),
            [&](auto acc, const Transform& step) {
                return acc
                    .and_then([&](const OrderV2& o) {
                        return step(o, ctx);
                });
            });
    }
}
  • Transform — std::function erasing any callable with the right signature. No base class needed
  • steps — pipeline as data. Add, remove, reorder with no structural changes

layout: default

Declarative pipeline architecture

namespace order_pipeline {
    using Transform = function<
        expected<OrderV2, OrderError>(
            const OrderV2&, const OrderContext&)>;

    static const vector<Transform> steps = {
        enrichWithMarketData,
        validateOrder,
        applyFills,
    };

    expected<OrderV2, OrderError> execute(
        const OrderV2& order,
        const OrderContext& ctx
    ) {
        return accumulate(steps.begin(), steps.end(),
            expected<OrderV2, OrderError>(order),
            [&](auto acc, const Transform& step) {
                return acc
                    .and_then([&](const OrderV2& o) {
                        return step(o, ctx);
                });
            });
    }
}
  • Transform — std::function erasing any callable with the right signature. No base class needed
  • steps — pipeline as data. Add, remove, reorder with no structural changes
  • execute — automates .and_then chaining. Step fails → rest skip automatically

layout: default

Runtime-configurable pipelines

vector<Transform> buildPipeline(OrderType type) {
    vector<Transform> steps = {
        enrichWithMarketData,
        checkMarketHours,   // both types need this
    };

    if (type == LIMIT_ORDER)
        steps.push_back(validateLimitPrice);  // don't fill above limit price

    steps.push_back(validateOrder);
    steps.push_back(applyFills);
    return steps;
}

auto limitPipeline  = buildPipeline(LIMIT_ORDER);
auto marketPipeline = buildPipeline(MARKET_ORDER);
Pipelines become first-class values.

layout: default

Pain points: Solved

<style> .slidev-vclick-prior { opacity: 1 !important; } </style>
Pain point Solution
God object — one type carrying all state data std::variant — each state carries only its own fields
Tight coupling — hard to test without real infrastructure Pure functions + OrderContext — explicit, mockable dependencies
Inheritance cascade — changing a base class recompiles everything Type-erased callables + concept-based type erasure — no shared base class
Scattered mutation — logic spread across many files Declarative pipeline — all transforms in one configurable list
Global mutable state — unsafe under concurrency std::expected — errors as values, no mutation

layout: center class: text-center

How do we roll out our new and improved code?

Migration strategy

layout: default

Adapters: Legacy to modern

namespace adapters {

// Convert legacy Order to modern OrderV2
OrderV2 toModern(const Order& legacy) {
    OrderStatus status = [&]() -> OrderStatus {
        switch (legacy.status) {
            case NEW:       return NewOrder{legacy.shares};
            case WORKING:   return Working{legacy.shares, legacy.fills};
            case FILLED:    return Filled{legacy.shares, legacy.fills};
            case CANCELLED: return Cancelled{legacy.cancelReason};
        }
        return Cancelled{"Unknown state"};
    }();
    return OrderV2{legacy.id, legacy.symbol, status};
}

layout: default

Adapters: Modern to legacy

// Convert modern OrderV2 back to legacy Order
Order toLegacy(const OrderV2& modern) {
    Order legacy;
    legacy.id = modern.id;
    legacy.symbol = modern.symbol;

    visit([&](auto&& state) {
        using T = decay_t<decltype(state)>;
        if constexpr (is_same_v<T, NewOrder>) {
            legacy.status = NEW;
            legacy.shares = state.shares;
        } else if constexpr (is_same_v<T, Working>) {
            legacy.status = WORKING;
            legacy.shares = state.shares; legacy.fills = state.fills;
        } else if constexpr (is_same_v<T, Filled>) {
            legacy.status = FILLED;
            legacy.shares = state.shares; legacy.fills = state.fills;
        } else if constexpr (is_same_v<T, Cancelled>) {
            legacy.status = CANCELLED;
            legacy.cancelReason = state.reason;
        }
    }, modern.status);
    return legacy;
}
}  // namespace adapters

layout: default

Using adapters in production

// Gradual migration: legacy calls modern code
void legacyProcessOrder(Order* order) {
    auto modernOrder = adapters::toModern(*order);
    auto processed = order_pipeline::execute(modernOrder, context);
    *order = adapters::toLegacy(*processed);  // Write back
}

// Modern code calls modern code directly
void modernProcessOrder(const OrderV2& order) {
    auto processed = order_pipeline::execute(order, context);
    submit(*processed);
}
Adapters have runtime cost - temporarily!
Incentivizes finishing migration quickly!
As more code migrates, adapter usage decreases.

layout: default

Migration strategy: 6 Steps

  1. Extract pure functions and write tests

    • Start with one method, validate results match
  2. Write modern types (OrderV2 with variant)

    • Link alongside legacy, don't replace yet
  3. Build adapters (toModern, toLegacy)

    • Test round-trip is lossless!
  1. Let legacy call modern pipeline

    • Convert to modern, process, convert back
  2. Gradually move callsites from legacy to modern

    • One function at a time, measure progress
  3. Deprecate legacy types

    • When adapter usage hits zero, delete!

layout: center class: text-center

How do we validate our migration?

Testing strategy

layout: default

Property: Fills are commutative

// Property: Fill order doesn't matter
TEST_CASE("Stock order fills are commutative") {
    auto order = OrderV2{1, "AAPL", 100, NewOrder{100}, nullopt};

    auto fill1 = Fill{30};  // 30 shares
    auto fill2 = Fill{70};  // 70 shares

    // Two execution paths to same result:
    auto path1 = order.withFill(fill1).withFill(fill2);
    auto path2 = order.withFill(fill2).withFill(fill1);

    REQUIRE(get<Filled>(path1.status).totalShares ==
            get<Filled>(path2.status).totalShares);
}

Test invariants, not specific examples. Much more powerful for distributed systems.


layout: default

Property: Enrichment is idempotent

// Stock price updates might be replayed due to network retries or failover
TEST_CASE("Stock price enrichment is idempotent") {
    auto order = OrderV2{1, "AAPL", 100, NewOrder{100}, nullopt};
    double applePrice = 150.25;

    auto once = order.withStockPrice(applePrice);
    auto twice = once.withStockPrice(applePrice);  // Replay scenario

    REQUIRE(once.pricePerShare == twice.pricePerShare);  // Replays are safe!
}

Property: idempotency ensures replays don't corrupt state.


layout: default

Adapter validation: Critical!

// Characterization test: round-trip must be lossless
TEST_CASE("Adapter preserves legacy stock order behavior") {
    // Critical: During migration, adapters must be lossless!
    Order legacy;
    legacy.id = 1;
    legacy.symbol = "AAPL";
    legacy.shares = 100;
    legacy.pricePerShare = 150.25;
    legacy.status = NEW;

    auto modern = adapters::toModern(legacy);
    auto backToLegacy = adapters::toLegacy(modern);

    // Verify round-trip is lossless
    REQUIRE(legacy.id == backToLegacy.id);
    REQUIRE(legacy.symbol == backToLegacy.symbol);
    REQUIRE(legacy.shares == backToLegacy.shares);
    REQUIRE(legacy.pricePerShare == backToLegacy.pricePerShare);
    REQUIRE(legacy.status == backToLegacy.status);

    // This gives confidence to run new code alongside old!
}

layout: center class: text-center

Next steps for YOUR codebase

What you can do Monday morning!

layout: default

Week 1: Extract pure functions

Pick ONE mutable method

  • Example: Order::enrichWithMarketData

Extract as pure function

Order enrichWithMarketData(Order order, const Context& ctx)

Test in parallel with old code

  • Validate results match
  • Measure: same output?

Start migrating callsites

  • One at a time
  • Low risk!

Payoff: Easier testing, parallelization TODAY


layout: default

Month 1: std::expected for errors

Find error-prone functions

  • Those with try/catch
  • Those with bool return codes

Wrap in std::expected

expected<Order, OrderError> validate(const Order& o)

Chain with .and_then()

validateOrder(order)
    .and_then([&](auto o) { return enrichWithStockPrice(o, ctx.lookup(o.symbol)); })
    .and_then([&](auto o) { return checkFunds(o, acctCtx); });

Track: How many try/catch nests eliminated?

Payoff: Clearer error handling TODAY


layout: default

Month 2: Type erasure at ONE boundary

Pick ONE plugin/listener system

  • Currently using abstract base classes

Add std::function alternative

  • Dual-publish pattern (old + new)

Migrate listeners one by one

  • Rewrite as lambdas
  • Subscribe to new system

Remove virtual base class

  • Once all migrated

Payoff: Faster builds, less coupling TODAY


layout: default

Month 3+: Variant for state machines

Find ONE god-object class

  • State enum + union-like fields

Model as std::variant

using OrderStatus = variant<NewOrder, Filled, Cancelled>;

Build adapters to/from legacy

  • Test round-trip!

Gradually migrate callsites

  • Measure adapter usage
  • Delete when zero

Payoff: Compile-time safety TODAY


layout: default

Each pattern pays for itself immediately

Pure functions → Easier testing, parallelization TODAY

std::expected → Clearer error handling TODAY

Type erasure → Faster builds, less coupling TODAY

std::variant → Compile-time safety TODAY

Declarative pipelines → Composability, flexibility TODAY

You don't need to wait for full migration to see benefits!

layout: default transition: none

The language is moving this direction

C++17 — The foundation

  • std::variant, std::optional, std::any — sum types and nullable values
  • if constexpr — compile-time branching (used in std::visit pattern matching)
  • Structured bindings, std::string_view, fold expressions

C++20 — Concepts and constraints

  • Concepts and requires — compile-time contracts (our OrderProcessor concept)
  • std::ranges — composable, lazy pipelines over data
  • Modules — better encapsulation, faster builds

C++23 — Monadic composition

  • std::expected with .and_then, .transform, .or_else — railway pattern
  • std::move_only_function — type erasure with unique ownership semantics
  • Monadic operations for std::optional (.transform, .and_then)

layout: default

The language is moving this direction

C++26 — Reflection and more

  • Static reflection — may auto-generate Model/Concept wrappers
  • std::execution (senders/receivers) — structured async pipelines

Beyond C++26 — Working papers

  • Pattern matching via match (P2688) — cleaner syntax for std::visit, possibly C++29+
But the safety is here today!

layout: center class: text-center

Maybe the real type erasure was the OOP we functionally replaced along the way.

layout: center class: text-center

Thank you! Questions?

layout: center class: text-center

Appendix


src: ./appendix.md