Null Construction: Taming Deep Object Graphs

Seams solve the substitution problem: how to make a dependency replaceable at all. This post addresses what comes next — once every type in a graph is injectable, assembling test fixtures starts to accumulate. Add a new dependency to OrderProcessor and every test that constructs one breaks. The null construction pattern is a response to that maintenance burden.

The Shared Domain

This post builds on the domain from Seams — the same PaymentGateway, Mailer, Order, and OrderProcessor types — but adds a third dependency: AuditLog. The growing dependency count is the point. Two dependencies are manageable; three starts to hurt; the pattern becomes essential before you reach five.

from typing import Protocol
from dataclasses import dataclass

class PaymentGateway(Protocol):
    def charge(self, amount_cents: int, card_token: str) -> None: ...

class Mailer(Protocol):
    def send(self, to: str, subject: str, body: str) -> None: ...

class AuditLog(Protocol):
    def record(self, event: str) -> None: ...

@dataclass
class Order:
    id: str
    amount_cents: int
    card_token: str
    email: str

class OrderProcessor:
    def __init__(self,
                 gateway:   PaymentGateway,
                 mailer:    Mailer,
                 audit_log: AuditLog) -> None:
        self._gateway   = gateway
        self._mailer    = mailer
        self._audit_log = audit_log

    def process(self, order: Order) -> None:
        self._gateway.charge(order.amount_cents, order.card_token)
        self._mailer.send(order.email, "Order confirmed",
                          f"Order {order.id} received.")
        self._audit_log.record(f"processed:{order.id}")
pub trait PaymentGateway {
    fn charge(&self, amount_cents: u32, card_token: &str);
}

pub trait Mailer {
    fn send(&self, to: &str, subject: &str, body: &str);
}

pub trait AuditLog {
    fn record(&self, event: &str);
}

pub struct Order {
    pub id: String,
    pub amount_cents: u32,
    pub card_token: String,
    pub email: String,
}

pub struct OrderProcessor {
    gateway:   Box<dyn PaymentGateway>,
    mailer:    Box<dyn Mailer>,
    audit_log: Box<dyn AuditLog>,
}

impl OrderProcessor {
    pub fn new(
        gateway:   Box<dyn PaymentGateway>,
        mailer:    Box<dyn Mailer>,
        audit_log: Box<dyn AuditLog>,
    ) -> Self {
        Self { gateway, mailer, audit_log }
    }

    pub fn process(&self, order: &Order) {
        self.gateway.charge(order.amount_cents, &order.card_token);
        self.mailer.send(&order.email, "Order confirmed",
                         &format!("Order {} received.", order.id));
        self.audit_log.record(&format!("processed:{}", order.id));
    }
}
struct IPaymentGateway {
    virtual void charge(int amount_cents, const std::string& token) = 0;
    virtual ~IPaymentGateway() = default;
};

struct IMailer {
    virtual void send(const std::string& to, const std::string& subject,
                      const std::string& body) = 0;
    virtual ~IMailer() = default;
};

struct IAuditLog {
    virtual void record(const std::string& event) = 0;
    virtual ~IAuditLog() = default;
};

struct Order {
    std::string id;
    int         amount_cents;
    std::string card_token;
    std::string email;
};

class OrderProcessor {
    std::unique_ptr<IPaymentGateway> gateway_;
    std::unique_ptr<IMailer>         mailer_;
    std::unique_ptr<IAuditLog>       audit_log_;

public:
    OrderProcessor(std::unique_ptr<IPaymentGateway> g,
                   std::unique_ptr<IMailer> m,
                   std::unique_ptr<IAuditLog> a)
        : gateway_(std::move(g))
        , mailer_(std::move(m))
        , audit_log_(std::move(a)) {}

    void process(const Order& order) {
        gateway_->charge(order.amount_cents, order.card_token);
        mailer_->send(order.email, "Order confirmed",
                      "Order " + order.id + " received.");
        audit_log_->record("processed:" + order.id);
    }
};

The Fixture Problem

Say OrderProcessor has grown a third dependency — an AuditLog. Every test that creates an OrderProcessor now needs to supply one, even tests that have nothing to do with auditing.

Before:  OrderProcessor(gateway, mailer)
After:   OrderProcessor(gateway, mailer, audit_log)  ← every fixture breaks

With five tests, this is annoying. With fifty, it becomes a real cost. The null construction pattern solves it by making each type responsible for assembling its own inert version.

Null Objects

The foundation is a set of null implementations — types that satisfy the interface and do nothing. No network calls, no file I/O, no panics.

class NullGateway:
    def charge(self, amount_cents: int, card_token: str) -> None:
        pass  # does nothing

class NullMailer:
    def send(self, to: str, subject: str, body: str) -> None:
        pass  # does nothing

class NullAuditLog:
    def record(self, event: str) -> None:
        pass  # does nothing
pub struct NullGateway;
impl PaymentGateway for NullGateway {
    fn charge(&self, _: u32, _: &str) {}
}

pub struct NullMailer;
impl Mailer for NullMailer {
    fn send(&self, _: &str, _: &str, _: &str) {}
}

pub struct NullAuditLog;
impl AuditLog for NullAuditLog {
    fn record(&self, _: &str) {}
}
struct NullGateway : IPaymentGateway {
    void charge(int, const std::string&) override {}
};

struct NullMailer : IMailer {
    void send(const std::string&, const std::string&,
              const std::string&) override {}
};

struct NullAuditLog : IAuditLog {
    void record(const std::string&) override {}
};

Null objects are different from dummies and fakes. A dummy panics if called — it guards against unexpected interactions. A fake has real logic (an in-memory store, a state machine) that must be maintained alongside the production type. A null object sits between them: it absorbs calls silently, with zero logic to keep in sync. This lack of logic is what makes the pattern composable — when a graph is several layers deep, each layer can wire itself with nulls and the entire tree assembles in one call.

new_null

Each type that has injectable dependencies gets a new_null factory alongside its regular constructor. It wires itself with null objects, requiring no arguments from the caller.

class OrderProcessor:
    def __init__(self,
                 gateway:   PaymentGateway,
                 mailer:    Mailer,
                 audit_log: AuditLog) -> None:
        self._gateway   = gateway
        self._mailer    = mailer
        self._audit_log = audit_log

    @classmethod
    def new_null(cls) -> 'OrderProcessor':
        return cls(NullGateway(), NullMailer(), NullAuditLog())

    def process(self, order: Order) -> None:
        self._gateway.charge(order.amount_cents, order.card_token)
        self._mailer.send(order.email, "Order confirmed",
                          f"Order {order.id} received.")
        self._audit_log.record(f"processed:{order.id}")

# In a test that does not care about any of the deps:
sut = OrderProcessor.new_null()
sut.process(test_order)  # no fixture assembly needed
pub struct OrderProcessor {
    gateway:   Box<dyn PaymentGateway>,
    mailer:    Box<dyn Mailer>,
    audit_log: Box<dyn AuditLog>,
}

impl OrderProcessor {
    pub fn new(
        gateway:   Box<dyn PaymentGateway>,
        mailer:    Box<dyn Mailer>,
        audit_log: Box<dyn AuditLog>,
    ) -> Self {
        Self { gateway, mailer, audit_log }
    }

    pub fn new_null() -> Self {
        Self::new(
            Box::new(NullGateway),
            Box::new(NullMailer),
            Box::new(NullAuditLog),
        )
    }

    pub fn process(&self, order: &Order) {
        self.gateway.charge(order.amount_cents, &order.card_token);
        self.mailer.send(&order.email, "Order confirmed",
                         &format!("Order {} received.", order.id));
        self.audit_log.record(&format!("processed:{}", order.id));
    }
}

// In a test that does not care about any of the deps:
let sut = OrderProcessor::new_null();
sut.process(&test_order);
class OrderProcessor {
    std::unique_ptr<IPaymentGateway> gateway_;
    std::unique_ptr<IMailer>         mailer_;
    std::unique_ptr<IAuditLog>       audit_log_;

public:
    OrderProcessor(std::unique_ptr<IPaymentGateway> g,
                   std::unique_ptr<IMailer> m,
                   std::unique_ptr<IAuditLog> a)
        : gateway_(std::move(g))
        , mailer_(std::move(m))
        , audit_log_(std::move(a)) {}

    static OrderProcessor new_null() {
        return OrderProcessor(
            std::make_unique<NullGateway>(),
            std::make_unique<NullMailer>(),
            std::make_unique<NullAuditLog>()
        );
    }

    void process(const Order& order) {
        gateway_->charge(order.amount_cents, order.card_token);
        mailer_->send(order.email, "Order confirmed",
                      "Order " + order.id + " received.");
        audit_log_->record("processed:" + order.id);
    }
};

// In a test that does not care about any of the deps:
auto sut = OrderProcessor::new_null();
sut.process(test_order);

When a new dependency is added to OrderProcessor, only new_null needs updating — not every test that constructs one.

Bubbling Up

The pattern composes. A higher-level service that depends on OrderProcessor can implement new_null by calling OrderProcessor::new_null() internally.

class InventoryService:
    def __init__(self, repository: StockRepository) -> None:
        self._repository = repository

    @classmethod
    def new_null(cls) -> 'InventoryService':
        return cls(NullStockRepository())

class OrderService:
    def __init__(self,
                 processor: OrderProcessor,
                 inventory: InventoryService) -> None:
        self._processor = processor
        self._inventory = inventory

    @classmethod
    def new_null(cls) -> 'OrderService':
        # Each layer assembles itself — this call reaches all the way down.
        return cls(OrderProcessor.new_null(), InventoryService.new_null())

    def place_order(self, order: Order) -> None:
        self._inventory.reserve(order)
        self._processor.process(order)

# In a test for OrderService — the whole graph is wired with one call:
sut = OrderService.new_null()
sut.place_order(test_order)
pub struct InventoryService {
    repository: Box<dyn StockRepository>,
}

impl InventoryService {
    pub fn new(repository: Box<dyn StockRepository>) -> Self {
        Self { repository }
    }
    pub fn new_null() -> Self {
        Self::new(Box::new(NullStockRepository))
    }
}

pub struct OrderService {
    processor: OrderProcessor,
    inventory: InventoryService,
}

impl OrderService {
    pub fn new(processor: OrderProcessor, inventory: InventoryService) -> Self {
        Self { processor, inventory }
    }

    pub fn new_null() -> Self {
        // Each layer assembles itself — this call reaches all the way down.
        Self::new(OrderProcessor::new_null(), InventoryService::new_null())
    }

    pub fn place_order(&self, order: &Order) {
        self.inventory.reserve(order);
        self.processor.process(order);
    }
}

// In a test for OrderService — the whole graph is wired with one call:
let sut = OrderService::new_null();
sut.place_order(&test_order);
class InventoryService {
    std::unique_ptr<IStockRepository> repository_;
public:
    explicit InventoryService(std::unique_ptr<IStockRepository> r)
        : repository_(std::move(r)) {}

    static InventoryService new_null() {
        return InventoryService(std::make_unique<NullStockRepository>());
    }
};

class OrderService {
    OrderProcessor  processor_;
    InventoryService inventory_;
public:
    OrderService(OrderProcessor p, InventoryService i)
        : processor_(std::move(p)), inventory_(std::move(i)) {}

    static OrderService new_null() {
        // Each layer assembles itself — this call reaches all the way down.
        return OrderService(OrderProcessor::new_null(), InventoryService::new_null());
    }

    void place_order(const Order& order) {
        inventory_.reserve(order);
        processor_.process(order);
    }
};

// In a test for OrderService — the whole graph is wired with one call:
auto sut = OrderService::new_null();
sut.place_order(test_order);

Adding a new service to OrderService means updating OrderService::new_null(), and nothing else. Tests for OrderService are unaffected.

Selective Override

Most tests do care about one dependency. The pattern handles this by combining new_null with the regular constructor: use null objects for everything uninteresting and supply a real double only for the thing being observed.

def test_sends_confirmation_email():
    spy = SpyMailer()
    sut = OrderProcessor(
        gateway=NullGateway(),
        mailer=spy,
        audit_log=NullAuditLog(),
    )

    sut.process(test_order)

    assert spy.last_recipient == test_order.email
#[test]
fn sends_confirmation_email() {
    let spy = SpyMailer::new();
    let sut = OrderProcessor::new(
        Box::new(NullGateway),
        Box::new(spy.clone()),
        Box::new(NullAuditLog),
    );

    sut.process(&test_order);

    assert_eq!(spy.last_recipient(), test_order.email);
}
TEST(OrderProcessorTest, SendsConfirmationEmail) {
    auto spy = std::make_shared<SpyMailer>();
    OrderProcessor sut{
        std::make_unique<NullGateway>(),
        spy,
        std::make_unique<NullAuditLog>(),
    };

    sut.process(test_order);

    EXPECT_EQ(spy->last_recipient(), test_order.email);
}

The test is explicit about what it observes and silent about everything else. When a new dependency is added to OrderProcessor, this test still compiles — the null default absorbs the change inside new_null, and this test does not use new_null at all, so it continues to state exactly which dependencies it uses.

Builder

When a type has many dependencies and a test only cares about one, the explicit constructor call in the selective override example becomes noisy. A builder keeps the intent clear: name the dependency you care about, let everything else default to null.

class OrderProcessorBuilder:
    def __init__(self) -> None:
        self._gateway   = NullGateway()
        self._mailer    = NullMailer()
        self._audit_log = NullAuditLog()

    def with_gateway(self, g: PaymentGateway) -> 'OrderProcessorBuilder':
        self._gateway = g
        return self

    def with_mailer(self, m: Mailer) -> 'OrderProcessorBuilder':
        self._mailer = m
        return self

    def with_audit_log(self, a: AuditLog) -> 'OrderProcessorBuilder':
        self._audit_log = a
        return self

    def build(self) -> OrderProcessor:
        return OrderProcessor(self._gateway, self._mailer, self._audit_log)

# In a test — state only what the test cares about:
sut = (OrderProcessorBuilder()
       .with_mailer(SpyMailer())
       .build())
#[derive(Default)]
pub struct OrderProcessorBuilder {
    gateway:   Option<Box<dyn PaymentGateway>>,
    mailer:    Option<Box<dyn Mailer>>,
    audit_log: Option<Box<dyn AuditLog>>,
}

impl OrderProcessorBuilder {
    pub fn gateway(mut self, g: impl PaymentGateway + 'static) -> Self {
        self.gateway = Some(Box::new(g));
        self
    }
    pub fn mailer(mut self, m: impl Mailer + 'static) -> Self {
        self.mailer = Some(Box::new(m));
        self
    }
    pub fn audit_log(mut self, a: impl AuditLog + 'static) -> Self {
        self.audit_log = Some(Box::new(a));
        self
    }
    pub fn build(self) -> OrderProcessor {
        OrderProcessor::new(
            self.gateway.unwrap_or_else(|| Box::new(NullGateway)),
            self.mailer.unwrap_or_else(|| Box::new(NullMailer)),
            self.audit_log.unwrap_or_else(|| Box::new(NullAuditLog)),
        )
    }
}

// In a test — state only what the test cares about:
let sut = OrderProcessorBuilder::default()
    .mailer(SpyMailer::new())
    .build();
class OrderProcessorBuilder {
    std::unique_ptr<IPaymentGateway> gateway_   = std::make_unique<NullGateway>();
    std::unique_ptr<IMailer>         mailer_    = std::make_unique<NullMailer>();
    std::unique_ptr<IAuditLog>       audit_log_ = std::make_unique<NullAuditLog>();

public:
    OrderProcessorBuilder& with_gateway(std::unique_ptr<IPaymentGateway> g) {
        gateway_ = std::move(g); return *this;
    }
    OrderProcessorBuilder& with_mailer(std::unique_ptr<IMailer> m) {
        mailer_ = std::move(m); return *this;
    }
    OrderProcessorBuilder& with_audit_log(std::unique_ptr<IAuditLog> a) {
        audit_log_ = std::move(a); return *this;
    }
    OrderProcessor build() {
        return OrderProcessor(
            std::move(gateway_),
            std::move(mailer_),
            std::move(audit_log_)
        );
    }
};

// In a test — state only what the test cares about:
auto sut = OrderProcessorBuilder{}
    .with_mailer(std::make_unique<SpyMailer>())
    .build();

The builder and new_null are complementary: new_null is for tests that genuinely do not care about any dependency, and the builder is for tests that care about exactly one. Both ensure that adding a new dependency to the production type requires updating one place — the builder defaults, or the new_null body — and not the individual tests.

Full Solution

Everything above in one consolidated reference — interfaces, null objects, OrderProcessor with new_null, the builder, and example tests showing all three usage patterns.

↗ Run
from typing import Protocol
from dataclasses import dataclass

# --- Interfaces ---

class PaymentGateway(Protocol):
    def charge(self, amount_cents: int, card_token: str) -> None: ...

class Mailer(Protocol):
    def send(self, to: str, subject: str, body: str) -> None: ...

class AuditLog(Protocol):
    def record(self, event: str) -> None: ...

@dataclass
class Order:
    id: str
    amount_cents: int
    card_token: str
    email: str

# --- Null Objects ---

class NullGateway:
    def charge(self, amount_cents: int, card_token: str) -> None:
        pass

class NullMailer:
    def send(self, to: str, subject: str, body: str) -> None:
        pass

class NullAuditLog:
    def record(self, event: str) -> None:
        pass

# --- Spy ---

class SpyMailer:
    def __init__(self) -> None:
        self.last_recipient: str | None = None

    def send(self, to: str, subject: str, body: str) -> None:
        self.last_recipient = to

# --- OrderProcessor with new_null ---

class OrderProcessor:
    def __init__(self,
                 gateway:   PaymentGateway,
                 mailer:    Mailer,
                 audit_log: AuditLog) -> None:
        self._gateway   = gateway
        self._mailer    = mailer
        self._audit_log = audit_log

    @classmethod
    def new_null(cls) -> 'OrderProcessor':
        return cls(NullGateway(), NullMailer(), NullAuditLog())

    def process(self, order: Order) -> None:
        self._gateway.charge(order.amount_cents, order.card_token)
        self._mailer.send(order.email, "Order confirmed",
                          f"Order {order.id} received.")
        self._audit_log.record(f"processed:{order.id}")

def test_new_null():
    sut = OrderProcessor.new_null()

    sut.process(Order("1", 500, "tok_123", "a@b.com"))

def test_selective_override():
    spy = SpyMailer()
    sut = OrderProcessor(
        gateway=NullGateway(),
        mailer=spy,
        audit_log=NullAuditLog(),
    )

    sut.process(Order("1", 500, "tok_123", "a@b.com"))

    assert spy.last_recipient == "a@b.com"

# --- Builder ---

class OrderProcessorBuilder:
    def __init__(self) -> None:
        self._gateway   = NullGateway()
        self._mailer    = NullMailer()
        self._audit_log = NullAuditLog()

    def with_gateway(self, g: PaymentGateway) -> 'OrderProcessorBuilder':
        self._gateway = g
        return self

    def with_mailer(self, m: Mailer) -> 'OrderProcessorBuilder':
        self._mailer = m
        return self

    def with_audit_log(self, a: AuditLog) -> 'OrderProcessorBuilder':
        self._audit_log = a
        return self

    def build(self) -> OrderProcessor:
        return OrderProcessor(self._gateway, self._mailer, self._audit_log)

def test_builder():
    spy = SpyMailer()
    sut = (OrderProcessorBuilder()
           .with_mailer(spy)
           .build())

    sut.process(Order("1", 500, "tok_123", "a@b.com"))

    assert spy.last_recipient == "a@b.com"

# --- Run all tests ---

test_new_null();           print("new_null .............. passed")
test_selective_override(); print("selective_override .... passed")
test_builder();            print("builder ............... passed")
#![cfg(test)]

use std::cell::RefCell;
use std::rc::Rc;

// --- Interfaces ---

pub trait PaymentGateway {
    fn charge(&self, amount_cents: u32, card_token: &str);
}

pub trait Mailer {
    fn send(&self, to: &str, subject: &str, body: &str);
}

pub trait AuditLog {
    fn record(&self, event: &str);
}

pub struct Order {
    pub id: String,
    pub amount_cents: u32,
    pub card_token: String,
    pub email: String,
}

// --- Null Objects ---

pub struct NullGateway;
impl PaymentGateway for NullGateway {
    fn charge(&self, _: u32, _: &str) {}
}

pub struct NullMailer;
impl Mailer for NullMailer {
    fn send(&self, _: &str, _: &str, _: &str) {}
}

pub struct NullAuditLog;
impl AuditLog for NullAuditLog {
    fn record(&self, _: &str) {}
}

// --- Spy ---

#[derive(Clone)]
pub struct SpyMailer {
    last_recipient: Rc<RefCell<String>>,
}

impl SpyMailer {
    pub fn new() -> Self {
        Self { last_recipient: Rc::new(RefCell::new(String::new())) }
    }
    pub fn last_recipient(&self) -> String {
        self.last_recipient.borrow().clone()
    }
}

impl Mailer for SpyMailer {
    fn send(&self, to: &str, _subject: &str, _body: &str) {
        *self.last_recipient.borrow_mut() = to.to_string();
    }
}

// --- OrderProcessor with new_null ---

pub struct OrderProcessor {
    gateway:   Box<dyn PaymentGateway>,
    mailer:    Box<dyn Mailer>,
    audit_log: Box<dyn AuditLog>,
}

impl OrderProcessor {
    pub fn new(
        gateway:   Box<dyn PaymentGateway>,
        mailer:    Box<dyn Mailer>,
        audit_log: Box<dyn AuditLog>,
    ) -> Self {
        Self { gateway, mailer, audit_log }
    }

    pub fn new_null() -> Self {
        Self::new(
            Box::new(NullGateway),
            Box::new(NullMailer),
            Box::new(NullAuditLog),
        )
    }

    pub fn process(&self, order: &Order) {
        self.gateway.charge(order.amount_cents, &order.card_token);
        self.mailer.send(&order.email, "Order confirmed",
                         &format!("Order {} received.", order.id));
        self.audit_log.record(&format!("processed:{}", order.id));
    }
}

fn test_order() -> Order {
    Order { id: "1".into(), amount_cents: 500,
            card_token: "tok_123".into(), email: "a@b.com".into() }
}

#[test]
fn new_null_no_dependencies() {
    let sut = OrderProcessor::new_null();

    sut.process(&test_order());
}

#[test]
fn selective_override() {
    let spy = SpyMailer::new();
    let sut = OrderProcessor::new(
        Box::new(NullGateway),
        Box::new(spy.clone()),
        Box::new(NullAuditLog),
    );

    sut.process(&test_order());

    assert_eq!(spy.last_recipient(), "a@b.com");
}

// --- Builder ---

#[derive(Default)]
pub struct OrderProcessorBuilder {
    gateway:   Option<Box<dyn PaymentGateway>>,
    mailer:    Option<Box<dyn Mailer>>,
    audit_log: Option<Box<dyn AuditLog>>,
}

impl OrderProcessorBuilder {
    pub fn gateway(mut self, g: impl PaymentGateway + 'static) -> Self {
        self.gateway = Some(Box::new(g));
        self
    }
    pub fn mailer(mut self, m: impl Mailer + 'static) -> Self {
        self.mailer = Some(Box::new(m));
        self
    }
    pub fn audit_log(mut self, a: impl AuditLog + 'static) -> Self {
        self.audit_log = Some(Box::new(a));
        self
    }
    pub fn build(self) -> OrderProcessor {
        OrderProcessor::new(
            self.gateway.unwrap_or_else(|| Box::new(NullGateway)),
            self.mailer.unwrap_or_else(|| Box::new(NullMailer)),
            self.audit_log.unwrap_or_else(|| Box::new(NullAuditLog)),
        )
    }
}

#[test]
fn builder_usage() {
    let spy = SpyMailer::new();
    let sut = OrderProcessorBuilder::default()
        .mailer(spy.clone())
        .build();

    sut.process(&test_order());

    assert_eq!(spy.last_recipient(), "a@b.com");
}

fn main() {}
#include <memory>
#include <string>
#include <cassert>
#include <iostream>

// --- Interfaces ---

struct IPaymentGateway {
    virtual void charge(int amount_cents, const std::string& token) = 0;
    virtual ~IPaymentGateway() = default;
};

struct IMailer {
    virtual void send(const std::string& to, const std::string& subject,
                      const std::string& body) = 0;
    virtual ~IMailer() = default;
};

struct IAuditLog {
    virtual void record(const std::string& event) = 0;
    virtual ~IAuditLog() = default;
};

struct Order {
    std::string id;
    int         amount_cents;
    std::string card_token;
    std::string email;
};

// --- Null Objects ---

struct NullGateway : IPaymentGateway {
    void charge(int, const std::string&) override {}
};

struct NullMailer : IMailer {
    void send(const std::string&, const std::string&,
              const std::string&) override {}
};

struct NullAuditLog : IAuditLog {
    void record(const std::string&) override {}
};

// --- Spy ---

struct SpyMailer : IMailer {
    std::string last_recipient_;

    void send(const std::string& to, const std::string&,
              const std::string&) override {
        last_recipient_ = to;
    }

    const std::string& last_recipient() const { return last_recipient_; }
};

// --- OrderProcessor with new_null ---

class OrderProcessor {
    std::unique_ptr<IPaymentGateway> gateway_;
    std::unique_ptr<IMailer>         mailer_;
    std::unique_ptr<IAuditLog>       audit_log_;

public:
    OrderProcessor(std::unique_ptr<IPaymentGateway> g,
                   std::unique_ptr<IMailer> m,
                   std::unique_ptr<IAuditLog> a)
        : gateway_(std::move(g))
        , mailer_(std::move(m))
        , audit_log_(std::move(a)) {}

    static OrderProcessor new_null() {
        return OrderProcessor(
            std::make_unique<NullGateway>(),
            std::make_unique<NullMailer>(),
            std::make_unique<NullAuditLog>()
        );
    }

    void process(const Order& order) {
        gateway_->charge(order.amount_cents, order.card_token);
        mailer_->send(order.email, "Order confirmed",
                      "Order " + order.id + " received.");
        audit_log_->record("processed:" + order.id);
    }
};

void test_new_null() {
    auto sut = OrderProcessor::new_null();
    Order order{"1", 500, "tok_123", "a@b.com"};

    sut.process(order);
}

void test_selective_override() {
    auto spy = std::make_unique<SpyMailer>();
    auto* spy_ptr = spy.get();
    Order order{"1", 500, "tok_123", "a@b.com"};
    OrderProcessor sut{
        std::make_unique<NullGateway>(),
        std::move(spy),
        std::make_unique<NullAuditLog>(),
    };

    sut.process(order);

    assert(spy_ptr->last_recipient() == "a@b.com");
}

// --- Builder ---

class OrderProcessorBuilder {
    std::unique_ptr<IPaymentGateway> gateway_   = std::make_unique<NullGateway>();
    std::unique_ptr<IMailer>         mailer_    = std::make_unique<NullMailer>();
    std::unique_ptr<IAuditLog>       audit_log_ = std::make_unique<NullAuditLog>();

public:
    OrderProcessorBuilder& with_gateway(std::unique_ptr<IPaymentGateway> g) {
        gateway_ = std::move(g); return *this;
    }
    OrderProcessorBuilder& with_mailer(std::unique_ptr<IMailer> m) {
        mailer_ = std::move(m); return *this;
    }
    OrderProcessorBuilder& with_audit_log(std::unique_ptr<IAuditLog> a) {
        audit_log_ = std::move(a); return *this;
    }
    OrderProcessor build() {
        return OrderProcessor(
            std::move(gateway_),
            std::move(mailer_),
            std::move(audit_log_)
        );
    }
};

void test_builder() {
    auto spy = std::make_unique<SpyMailer>();
    auto* spy_ptr = spy.get();
    Order order{"1", 500, "tok_123", "a@b.com"};
    auto sut = OrderProcessorBuilder{}
        .with_mailer(std::move(spy))
        .build();

    sut.process(order);

    assert(spy_ptr->last_recipient() == "a@b.com");
}

int main() {
    test_new_null();           std::cout << "new_null .............. passed\n";
    test_selective_override(); std::cout << "selective_override .... passed\n";
    test_builder();            std::cout << "builder ............... passed\n";
    return 0;
}