C++ 领域驱动设计(DDD):在复杂业务架构中利用 C++ 强类型系统表达业务不变式与生命周期规则

各位好!欢迎来到“代码地狱与天堂”的交界处。

今天我们不聊那些虚头巴脑的架构图,也不谈那些花里胡哨的前端动画。今天我们要聊的是一种古老、强大,甚至有点“脾气暴躁”,但绝对能让你的业务逻辑坚如磐石的武器——C++ 强类型系统

在领域驱动设计(DDD)里,我们经常听到“不变式”、“生命周期”、“充血模型”这些词。在那些“鸭子类型”泛滥的动态语言世界里,这些概念往往变成了运行时的笑话。但在 C++ 里,这些概念是写在编译器里的铁律。

想象一下,你的业务规则是什么?是“不能卖负数的钱”,是“订单只有在确认后才能发货”,是“用户不能在未登录状态下下单”。在 Java 或 Python 里,这些是 if (money < 0) throw Error,或者是 if (user.isLoggedIn)。但在 C++ 里,我们把这些规则编译进类型系统里。如果有人试图违反规则,编译器会直接把代码变成废铁,而不是等到上线那一刻才让数据库崩溃。

今天,我们就来聊聊怎么用 C++ 的嘴脸,去管理那些复杂的业务逻辑。

一、 值对象:不可变宇宙的基石

在 DDD 中,值对象 是最基础的砖块。什么是值对象?它没有唯一标识,但有一堆属性。比如“金额”、“日期”、“地址”。

在 C++ 里,如果你想让值对象“神圣不可侵犯”,你必须利用 constprivate 成员变量,以及构造函数的约束

1. 不仅仅是数据,是契约

很多新手写 C++,喜欢把结构体敞开,然后到处赋值。但在 DDD 里,一旦对象被创建,它的业务状态就锁死了。

看看这个例子。我们要表示“钱”。

#include <string>
#include <stdexcept>
#include <fmt/format.h> // 这里用 fmt 只是方便演示,实际可用 std::format

// 这是一个值对象
struct Money {
    int cents; // 我们用分为单位,避免浮点数精度问题
    std::string currency;

private:
    // 私有构造函数,防止外部瞎搞
    Money(int c, std::string cur) : cents(c), currency(std::move(cur)) {}

public:
    // 默认构造函数,通常用于数据库反序列化或默认值
    // 注意:这里给了一个默认值,但在业务逻辑中,你可能希望禁止 0 元的存在
    Money() : cents(0), currency("USD") {}

    // 静态工厂方法:这是创建值对象的标准姿势
    static Money fromDollars(double dollars, const std::string& cur) {
        if (dollars < 0) {
            throw std::invalid_argument("Money cannot be negative!");
        }
        // 将浮点数转换为整数,避免精度丢失
        int cents = static_cast<int>(dollars * 100);
        return Money(cents, cur);
    }

    // 不可变操作:加法应该返回一个新的对象,而不是修改自己
    // 这就是 DDD 里的“不可变对象”模式
    Money add(const Money& other) const {
        if (this->currency != other.currency) {
            throw std::runtime_error("Cannot add different currencies!");
        }
        return Money(this->cents + other.cents, this->currency);
    }

    // 打印一下,看看我们有多严谨
    std::string toString() const {
        double dollars = static_cast<double>(cents) / 100.0;
        return fmt::format("${:.2f} {}", dollars, currency);
    }
};

看到这个 add 函数了吗?它返回了一个新的 Money 对象。为什么?因为在业务上,金钱是不能被篡改的。你不能修改一张旧钞票的面值,你也应该不能修改一个对象的状态。所有的状态变更都通过“创建新对象”来表达。

如果你试图这样做:

Money a = Money::fromDollars(10.0);
a.cents = -100; // 哪怕你加了 private,如果你不小心写了 friend 或者用了非 const 引用,也能改
// 但在 DDD 里,我们要的是:一旦对象生成,它就是永恒的。

在真正的 DDD C++ 实践中,我们通常更进一步,使用 constexpr 和模板来确保这些操作在编译期就能被检查。

二、 生命周期与状态机:std::variant 的魔法

业务对象是有“脾气”的。订单有状态:未确认、已确认、已发货、已送达。用户有状态:未激活、已激活、被封禁。

以前我们怎么写?用 int state 或者 enum class,然后到处都是 if (state == 1) ... else if (state == 2)。这叫“面条代码”,是软件维护者的噩梦。

在 C++17 以后,我们有了神器——std::variant。它本质上是一个类型安全的联合体。它允许一个变量同时持有多种类型,但只能持有其中一种

1. 把“状态”变成“类型”

让我们用 C++ 来表达一个“订单”的生命周期。

#include <variant>
#include <optional>
#include <iostream>

// 定义订单状态
enum class OrderStatus {
    Created,    // 已创建
    Confirmed,  // 已确认
    Shipped,    // 已发货
    Delivered,  // 已送达
    Cancelled   // 已取消
};

// 以前的做法:混乱
// Order o; o.status = OrderStatus::Created; 
// if (o.status == OrderStatus::Created) { ... }

// DDD 做法:类型即状态
class Order {
public:
    // 这是一个类型安全的联合体,它要么是 Created,要么是 Confirmed...
    using State = std::variant<
        std::monostate, // 用于默认构造或重置
        struct Created,
        struct Confirmed,
        struct Shipped,
        struct Delivered,
        struct Cancelled
    >;

private:
    State state;

public:
    Order() : state(Created{}) {}

    // 核心逻辑:状态转换
    // 只有在特定状态下才能调用特定方法
    void confirm() {
        // std::visit 是 C++17 的模式匹配神器
        // 它会根据 state 的实际类型,执行对应的 lambda
        std::visit([](auto& arg) {
            // 使用 C++20 的 requires 约束,或者在这里写死逻辑
            using T = std::decay_t<decltype(arg)>;

            if constexpr (std::is_same_v<T, Created>) {
                // 只有 Created 状态才能确认
                std::cout << "Order confirmed!n";
                state = Confirmed{};
            } else {
                // 如果不是 Created,那就是非法操作
                std::cout << "Error: Cannot confirm an already confirmed order!n";
                // 在这里,我们可以抛出异常,或者直接编译报错(如果用模板元编程)
            }
        }, state);
    }

    void ship() {
        std::visit([](auto& arg) {
            using T = std::decay_t<decltype(arg)>;

            if constexpr (std::is_same_v<T, Confirmed>) {
                std::cout << "Order shipped!n";
                state = Shipped{};
            } else {
                std::cout << "Error: Cannot ship an unconfirmed order!n";
            }
        }, state);
    }

    // 查看当前状态
    void printStatus() const {
        std::visit([](auto&& arg) {
            using T = std::decay_t<decltype(arg)>;
            if constexpr (std::is_same_v<T, std::monostate>) {
                std::cout << "Status: Unknownn";
            } else if constexpr (std::is_same_v<T, Created>) {
                std::cout << "Status: Createdn";
            } else if constexpr (std::is_same_v<T, Confirmed>) {
                std::cout << "Status: Confirmedn";
            }
            // ... 其他状态省略
        }, state);
    }
};

看懂了吗?

在这里,state 变量不仅仅是一个整数。它是 CreatedConfirmed 等结构体的容器。
当你调用 confirm() 时,std::visit 会检查当前容器里装的是不是 Created。如果是,它就替换成 Confirmed
如果不是,它就报错。

这就是编译时检查的雏形。虽然 std::visit 是运行时执行的,但它强制你处理了所有的分支。你没法在 Created 状态下调用 ship(),因为编译器不会让你通过(或者说,你的逻辑代码会阻止它)。这完美地表达了业务的生命周期规则:状态是连续的,不可逆跳变。

三、 约束与不变式:requiresstatic_assert

C++20 引入了 concept(概念)。这玩意儿简直是 DDD 的福音。

在 DDD 中,我们经常遇到“可处理”的对象。比如,一个“支付网关”只能处理“有效”的支付请求。以前,我们可能写一个 bool isValid(PaymentRequest req) 函数。但如果这个函数被漏掉了怎么办?或者传错了参数怎么办?

现在,我们用 concept 把这个检查变成类型系统的约束。

#include <concepts>

// 定义一个概念:必须是一个 Money 对象,并且金额大于 0
template<typename T>
concept PositiveMoney = requires(T money) {
    { money.cents } -> std::convertible_to<int>; // 必须有 cents 属性
    { money.cents } > 0;                         // 必须大于 0
};

// 定义一个概念:必须是一个 Order,并且必须是 Created 状态
template<typename T>
concept CreatedOrder = requires(T order) {
    // 我们可以假设 Order 有一个 isCreated() 方法,或者通过 std::visit 检查
    // 这里为了演示,假设有方法
    { order.isCreated() } -> std::same_as<bool>;
};

// 业务服务:只有正数的钱才能被处理
template<PositiveMoney T>
void processPayment(T amount) {
    std::cout << "Processing " << amount.toString() << "n";
}

// 业务服务:只有 Created 状态的订单才能开始结算
template<CreatedOrder T>
void startSettlement(T order) {
    std::cout << "Starting settlement for order...n";
}

// 测试代码
int main() {
    Money validMoney = Money::fromDollars(50.0);
    Money invalidMoney; // 默认是 0

    // 这行代码会编译通过
    processPayment(validMoney);

    // 这行代码会编译报错!
    // processPayment(invalidMoney); 

    Order o;
    o.confirm(); // 变成 Confirmed
    // o.ship();  // 这行代码本身没问题,但 startSettlement 需要的是 CreatedOrder
    // startSettlement(o); // 编译错误!因为 o 不是 Created 状态
}

看懂了吗? 这就是强类型的威力。processPayment 函数的签名直接锁死了参数类型。如果你传了一个 int,或者一个 std::string,编译器直接报错。你根本不需要在函数体里写 if (amount < 0),因为 PositiveMoney 这个概念已经帮你过滤掉了所有非法类型。

这就是业务不变式的编译时保障。不变式一旦违反,程序根本编译不过去。

四、 所有权与生命周期:智能指针的契约

DDD 强调“充血模型”,也就是业务逻辑在实体内部。这意味着,实体可能会持有其他对象的引用。

在 C++ 中,处理所有权是噩梦。谁拥有这个对象?谁负责释放它?如果两个实体都指向同一个对象,它是 shared_ptr 还是 weak_ptr

在 DDD 中,我们通常遵循“谁创建,谁负责销毁”的原则。这正好对应了 std::unique_ptr

1. 聚合根与值对象的所有权

假设我们有一个“订单”实体,它包含一个“金额”值对象。通常,值对象是轻量级的,可以被复制(拷贝语义)。但如果是“用户画像”,它可能包含复杂的属性,我们就需要管理它的生命周期。

#include <memory>

class User {
    std::string name;
    int age;

public:
    User(std::string n, int a) : name(std::move(n)), age(a) {}
    // ... getters ...
};

class Order {
    // 这里我们使用 unique_ptr,明确表示:Order 实体拥有这个 User 对象
    // 一旦 Order 被销毁,User 也会被销毁(除非有其他引用)
    std::unique_ptr<User> buyer;

    // 业务规则:只有拥有用户,才能下单
    Order(std::unique_ptr<User> user) : buyer(std::move(user)) {
        if (!user) {
            throw std::runtime_error("Order must have a buyer!");
        }
    }

    // 订单确认逻辑
    void confirm() {
        // 这里我们可以安全地访问 buyer
        std::cout << "Order confirmed for " << buyer->name << "n";

        // 业务规则:只有年满 18 岁才能下单
        if (buyer->age < 18) {
            throw std::runtime_error("User is too young to order!");
        }
    }
};

// 使用示例
int main() {
    // 创建用户
    auto user = std::make_unique<User>("Alice", 25);

    // 创建订单,所有权转移给 Order
    Order order(std::move(user));

    try {
        order.confirm();
    } catch (const std::exception& e) {
        std::cerr << e.what();
    }

    // 注意:此时 user 已经失效了!
    // std::cout << user->name; // 编译错误!
}

通过 std::unique_ptr,我们在代码层面强制执行了生命周期规则。你没法在不拥有资源的情况下访问它,你也没法在拥有资源之后继续持有它。这消除了“悬垂指针”和“内存泄漏”的可能性。

如果业务需要共享所有权(比如两个订单引用同一个用户),那我们用 std::shared_ptr,但必须非常小心,因为这意味着生命周期变得复杂了。

五、 深入实战:一个复杂的电商结算系统

好了,理论讲多了,我们来点干货。假设我们要设计一个电商系统的结算模块。

需求:

  1. 购物车:包含多个商品。
  2. 商品:有价格、库存。
  3. 结算:检查库存,计算总价,扣减库存,生成订单。
  4. 约束:库存不足不能结算;总价不能为负;只能结算当前有效的商品。

1. 定义商品(值对象)

struct ProductId {
    std::string value;
    // 为了演示方便,重载 ==,让 ProductId 可以作为 map 的 key
    bool operator==(const ProductId& other) const = default;
};

// 商品实体,拥有自己的库存逻辑
class Product {
    ProductId id;
    std::string name;
    int priceCents; // 价格
    int stock;

public:
    Product(ProductId pid, std::string n, int p, int s) 
        : id(pid), name(std::move(n)), priceCents(p), stock(s) {}

    // 检查库存,返回一个 Result 类型(可选值)
    std::optional<int> getStock() const {
        return stock;
    }

    // 扣减库存:这是充血模型的核心!
    // 业务逻辑:如果库存不够,返回 false;否则扣减并返回 true
    bool reserveStock(int quantity) {
        if (stock < quantity) {
            return false; // 业务规则:库存不足
        }
        stock -= quantity;
        return true;
    }
};

2. 定义购物车(值对象 + 集合)

#include <vector>
#include <unordered_map>

// 购物车条目:商品 + 数量
struct CartItem {
    ProductId productId;
    int quantity;

    bool operator==(const CartItem& other) const = default;
};

class ShoppingCart {
    std::vector<CartItem> items;

public:
    void addItem(const ProductId& pid, int qty) {
        // 业务规则:数量至少为 1
        if (qty <= 0) return; 

        // 检查是否已存在,存在则增加数量
        for (auto& item : items) {
            if (item.productId == pid) {
                item.quantity += qty;
                return;
            }
        }
        items.push_back({pid, qty});
    }

    void removeItem(const ProductId& pid) {
        items.erase(std::remove_if(items.begin(), items.end(), 
            [pid](const CartItem& item) { return item.productId == pid; }), 
            items.end());
    }

    // 获取所有条目
    const std::vector<CartItem>& getItems() const {
        return items;
    }
};

3. 定义结算服务(聚合根逻辑)

这是最关键的部分。结算服务不仅仅是计算价格,它还要验证业务规则。

#include <numeric> // for accumulate

class CheckoutService {
public:
    // 复杂类型别名,为了可读性
    using Money = ::Money; // 假设上面定义了 Money
    using ProductMap = std::unordered_map<ProductId, Product>;

    // 结算函数:输入购物车和产品库,输出订单
    // 注意返回值类型:std::optional<Order>,因为结算可能失败
    std::optional<Order> checkout(const ShoppingCart& cart, const ProductMap& catalog) {
        // 1. 检查购物车是否为空
        if (cart.getItems().empty()) {
            std::cerr << "Checkout failed: Cart is empty.n";
            return std::nullopt;
        }

        int totalCents = 0;
        std::vector<ProductId> reservedProducts;

        // 2. 遍历购物车,验证库存并计算总价
        for (const auto& item : cart.getItems()) {
            // 在 catalog 中查找商品
            auto it = catalog.find(item.productId);
            if (it == catalog.end()) {
                std::cerr << "Checkout failed: Product not found.n";
                return std::nullopt;
            }

            const Product& product = it->second;

            // 3. 检查库存
            if (!product.getStock().has_value() || product.getStock().value() < item.quantity) {
                std::cerr << "Checkout failed: Insufficient stock for product: " << product.name << "n";
                return std::nullopt;
            }

            // 4. 扣减库存(这里是核心:在计算总价的同时,修改了 Product 的状态!)
            // 如果这里返回 false,说明库存被其他线程抢光了(虽然单线程演示看不出,但逻辑上是这样的)
            if (!product.reserveStock(item.quantity)) {
                std::cerr << "Checkout failed: Concurrent modification or logic error.n";
                return std::nullopt;
            }

            totalCents += product.priceCents * item.quantity;
            reservedProducts.push_back(item.productId);
        }

        // 5. 创建订单
        // 注意:这里我们返回的是 Order 对象,而不是 void
        // 业务逻辑已经全部在函数体内执行完毕
        return Order(totalCents, reservedProducts);
    }
};

4. 定义订单(最终产物)

class Order {
    Money totalAmount;
    std::vector<ProductId> productIds;

public:
    Order(int cents, const std::vector<ProductId>& ids) 
        : totalAmount(Money(cents, "USD")), productIds(ids) {}

    Money getTotalAmount() const {
        return totalAmount;
    }

    void printReceipt() const {
        std::cout << "=== Receipt ===n";
        std::cout << "Total: " << totalAmount.toString() << "n";
        std::cout << "Items: " << productIds.size() << "n";
    }
};

这段代码的精髓在哪里?

  1. 充血模型CheckoutService 包含了所有的业务逻辑(库存检查、价格计算、状态变更)。它不是一个简单的数据传输对象(DTO),而是一个真正的领域服务。
  2. 不变式保护Product::reserveStock 修改了库存,这改变了对象的状态。如果检查失败,整个结算流程回滚(在这个简单例子中,我们直接返回 nullopt,实际上应该有事务机制)。
  3. 类型安全Money 对象确保了金额的计算是安全的。std::optional 确保了操作失败时,调用者必须显式处理错误,而不是得到一个 null 值然后继续计算。

六、 模板元编程:终极的约束

如果你觉得上面的 concept 还不够劲爆,我们还可以用模板元编程(TMP)来做到极致。

假设我们定义了一个“银行账户”的概念,它必须满足“余额不能为负”这个不变式。

template<typename T>
concept BankAccount = requires(T account) {
    { account.getBalance() } -> std::convertible_to<int>;
    // 这是一个静态断言,在编译时检查
    { account.getBalance() >= 0 } -> std::convertible_to<bool>; 
};

// 一个不满足条件的类(故意写的坏代码)
struct BadAccount {
    int getBalance() const { return -100; }
};

// 一个满足条件的类
struct GoodAccount {
    int getBalance() const { return 1000; }
};

// 业务逻辑:只有银行账户才能被转账
template<BankAccount T>
void transferMoney(T& account, int amount) {
    if (account.getBalance() >= amount) {
        std::cout << "Transfer successful.n";
    } else {
        std::cout << "Insufficient funds.n";
    }
}

int main() {
    GoodAccount good;
    transferMoney(good, 50); // 编译通过

    BadAccount bad;
    // transferMoney(bad, 50); // 编译报错!
    // 错误信息大概是:static assertion failed: constraint expression is not satisfied
    // 因为 BadAccount 的 getBalance() >= 0 永远是 false,不满足 BankAccount 概念
}

你看,BadAccount 甚至不需要有 transferMoney 这个函数,只要它不满足 BankAccount 的约束,它就被系统排除了。

七、 错误处理与异常:业务规则的最后防线

虽然我们用类型系统锁住了大部分错误,但总有一些错误是运行时才知道的(比如数据库连接断了,或者网络请求超时)。这时候,C++ 的异常机制就是我们的救命稻草。

在 DDD 中,异常应该是业务错误的直接表达。

class DomainException : public std::runtime_error {
public:
    explicit DomainException(const std::string& msg) : std::runtime_error(msg) {}
};

// 在业务逻辑中使用
class Warehouse {
public:
    void shipItem(const Item& item) {
        if (!item.isAvailable()) {
            // 直接抛出业务异常,而不是通用的 std::runtime_error
            throw DomainException("Item is out of stock!");
        }
        // 执行发货逻辑...
    }
};

使用自定义的异常类,可以方便地在上层架构中进行统一的捕获和处理。

八、 总结:为什么这很重要?

各位,我们讲了这么多代码,讲了 std::variant,讲了 concept,讲了 unique_ptr。这有什么用?

在传统的“贫血模型”里,业务逻辑散落在各个 Service 层,数据对象(Entity/DTO)只是数据的搬运工。你想改一个业务规则,得满世界找 Service 方法,还得担心有没有遗漏。

而在 C++ 的 DDD 里,类型就是文档

当你看到一个 std::variant<OrderState> 时,你一眼就知道这个对象有几种状态,而且不需要看注释。
当你看到一个 template<PositiveMoney T> 时,你一眼就知道这个函数只能处理合法的钱。
当你看到一个 std::unique_ptr 时,你一眼就知道谁拥有谁。

强类型系统是业务规则的编译器。

它强制你在写代码的时候,就要思考业务的边界。它消除了 90% 的“如果…怎么办”的猜测。它让代码在编译通过的那一刻,就已经通过了业务规则的审查。

这就是 C++ 的魅力。它不原谅你,但如果你遵守规则,它会为你遮风挡雨,构建出坚不可摧的系统。

记住,代码是写给机器看的,但架构是写给人类看的。用 C++ 的强类型系统来表达 DDD,就是在用最精确的语言,去描述最复杂的业务世界。

好了,今天的讲座就到这里。希望下次你们写代码的时候,能感受到编译器那严厉的目光,并在那目光下,写出完美的业务逻辑。下课!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注