| 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 |
|
||||||
| transition | slide-left | ||||||
| title | Incremental C++ Modernization | ||||||
| mdc | true | ||||||
| layout | image | ||||||
| image | /title-card.png | ||||||
| vite |
|
layout: center class: text-center background: https://source.unsplash.com/collection/94734566/1920x1080
Refactoring Legacy C++ OOP Application Code with Functional Programming and Type Erasure Concepts
Jessica Ding, Software Engineer @ Bloomberg Tradebook
- 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!
- 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
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
- 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
- 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
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
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
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
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
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"
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::
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
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!
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
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!
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::
[[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
// 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?
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
- 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
std::expected
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
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
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?
// Define error cases clearly
enum class OrderError {
InvalidSymbol, // "ZZZZ" is not valid
PriceUnavailable, // Market data feed down
};// 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
}
// .transform(f): f cannot fail, wraps result in expected automatically
// .and_then(f): f can fail, returns its own expectedOption 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);// 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)
<style> .slidev-vclick-prior { opacity: 1 !important; } </style>
Free short-circuiting
- If
getMarketPricefails → 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
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
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
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
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 —
dbmust come from elsewhere
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
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
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
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 sitestd::functionrequires copyable callables. What if you want to capture a move-onlyunique_ptror a file handle?
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 siteunique_ptris move-only —std::functionwon't compile
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 siteunique_ptris move-only —std::functionwon't compilemove_only_functionrequires only movability; handler has sole ownership
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 siteunique_ptris move-only —std::functionwon't compilemove_only_functionrequires only movability; handler has sole ownership- Same call site —
onFilled(order)unchanged
<style> .slidev-vclick-prior { opacity: 1 !important; } </style>
- 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
-
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 callableTwo methods, one object. std::function can't express that — we need a richer abstraction.
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);
OrderProcessorrequires two virtual methods:canProcessandprocess
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);
}
};OrderProcessorrequires two virtual methods:canProcessandprocessRiskCheckneedscanProcess— skips internal orders
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 patternOrderProcessorrequires two virtual methods:canProcessandprocessRiskCheckneedscanProcess— skips internal ordersEnrichmentProcessornever skips — but the base class demandscanProcessfrom everyone
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 patternOrderProcessorrequires two virtual methods:canProcessandprocessRiskCheckneedscanProcess— skips internal ordersEnrichmentProcessornever skips — but the base class demandscanProcessfrom everyone- same for
FillsProcessor
// 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
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));
}
};OrderProcessoris a concept- Defines what a processor must be able to do
- Only
processis required —canProcessis optional
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));
}
};OrderProcessoris a concept- Defines what a processor must be able to do
- Only
processis required —canProcessis optional
RiskCheckprovidescanProcess— the wrapper uses it when present
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));
}
};OrderProcessoris a concept- Defines what a processor must be able to do
- Only
processis required —canProcessis optional
RiskCheckprovidescanProcess— the wrapper uses it when presentEnrichmentProcessoronly needsprocess. No boilerplate!
// 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 hereAnyOrderProcessoris a type-erased wrapper- concrete class that satisfies the
OrderProcessorconcept - The concept defines the contract; the wrapper performs the type erasure
- concrete class that satisfies the
-
ComplianceChecksatisfies the concept— No forwarding wrapper needed!
- Concept checked at compile
-
If
ComplianceCheckdoesn't fit, you get a clear error atemplace_back!
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)
};
}
};AnyOrderProcessorusesif constexprandrequires- detects
canProcessat compile time and defaults totrueif absent ⚠️ typo risk: misspelledcanProcesssilently passes all orders through
- detects
- Guard option: make
canProcessrequired in the concept; provide anAlwaysProcessmixin for processors that never skip (inheritance dependency)
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
AnyOrderProcessortype-erases both methods
// 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
// 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 |
- 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 —
ComplianceCheckneeded zero changes to participate - Optional methods —
canProcesscan default viaif constexpr.
- Virtual dispatch still happens —
AnyOrderProcessorhides aConceptBase*internally.vector<AnyOrderProcessor>works because each wrapper heap-allocates its concrete type
- Boilerplate —
AnyOrderProcessorwrapper 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
std::variant
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!// 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
// 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
OrderStatuscan be exactly one of these at a time
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
handleStockOrdercan usestd::visit- Exhaustively handle all
OrderStatusvariants - Forget a case and compiler will error
- Exhaustively handle all
// 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::visitin every pipeline step fails to compile until handled - The compiler finds every place for you — no grep needed!
// 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
<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.
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
We can still use
.and_then. Just automate the chaining!
// 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)
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::functionerasing any callable with the right signature. No base class needed
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::functionerasing any callable with the right signature. No base class neededsteps— pipeline as data. Add, remove, reorder with no structural changes
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::functionerasing any callable with the right signature. No base class neededsteps— pipeline as data. Add, remove, reorder with no structural changesexecute— automates.and_thenchaining. Step fails → rest skip automatically
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);<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 |
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};
}// 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// 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);
}Incentivizes finishing migration quickly!
As more code migrates, adapter usage decreases.
-
Extract pure functions and write tests
- Start with one method, validate results match
-
Write modern types (
OrderV2with variant)- Link alongside legacy, don't replace yet
-
Build adapters (
toModern,toLegacy)- Test round-trip is lossless!
-
Let legacy call modern pipeline
- Convert to modern, process, convert back
-
Gradually move callsites from legacy to modern
- One function at a time, measure progress
-
Deprecate legacy types
- When adapter usage hits zero, delete!
// 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.
// 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.
// 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!
}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
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
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
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
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
C++17 — The foundation
std::variant,std::optional,std::any— sum types and nullable valuesif constexpr— compile-time branching (used instd::visitpattern matching)- Structured bindings,
std::string_view, fold expressions
C++20 — Concepts and constraints
- Concepts and
requires— compile-time contracts (ourOrderProcessorconcept) std::ranges— composable, lazy pipelines over data- Modules — better encapsulation, faster builds
C++23 — Monadic composition
std::expectedwith.and_then,.transform,.or_else— railway patternstd::move_only_function— type erasure with unique ownership semantics- Monadic operations for
std::optional(.transform,.and_then)
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 forstd::visit, possibly C++29+
