DDD 实战:从领域划分到微服务落地#

很多团队做微服务拆分时,第一反应是"按表拆"“按功能拆”,结果服务越拆越细,调用链越来越长,事务一致性越来越难,业务逻辑散落在四五个服务里。问题往往不在技术,而在边界没划清。更要命的是,这种拆分几乎不可逆——一旦下游服务消费了你的接口契约,重新划边界的成本会随着接入方数量指数上升。DDD 的价值,正是提供一套从业务出发划分边界、组织模型的方法,让边界在写第一行代码之前就被论证清楚。


一、DDD 是什么:它到底在对付哪种复杂度#

软件系统的复杂度大致有三个来源:

复杂度类型来源典型应对手段
业务复杂度业务规则多、例外多、语义歧义大、变化快领域建模、限界上下文、通用语言
技术复杂度高并发、分布式、存储选型、性能瓶颈架构模式、中间件、容量设计
规模复杂度人多、模块多、协作链路长团队拓扑、康威定律、平台工程

DDD 的靶心是第一种。它不解决"如何扛住 10 万 QPS",也不解决"如何让 50 个团队不互相阻塞"——虽然它对后者有影响。很多团队用 DDD 去解决技术复杂度,最后只得到了一堆没人看得懂的分层和抽象。

一句话概括 DDD 的定位:当"业务怎么说"与"代码怎么写"之间的鸿沟成为主要成本时,DDD 才值得上。

它通常分为两部分:

层次解决的问题核心产出失败代价
战略设计边界在哪子域、限界上下文、通用语言、上下文映射边界错了,模型再精致也没用,且重构成本极高
战术设计边界内怎么建模实体、值对象、聚合、领域服务、领域事件、仓储模型贫血,业务规则散落,但边界仍可保留
flowchart TB
    subgraph PS["问题空间(业务侧)"]
        D["领域(业务全景)"] --> SD1["核心域"]
        D --> SD2["支撑域"]
        D --> SD3["通用域"]
    end

    subgraph SS["解决方案空间(系统侧)"]
        BC1["限界上下文 A"]
        BC2["限界上下文 B"]
        BC1 <-->|"上下文映射"| BC2
    end

    SD1 -->|"战略设计:划边界"| BC1
    SD2 -->|"战略设计:划边界"| BC2
    BC1 -->|"战术设计:建模型"| M["实体 / 值对象 / 聚合 / 领域事件 / 仓储"]
    BC1 -.->|"通用语言双向校准"| D

战略设计决定系统怎么拆,战术设计决定拆完之后怎么建。 很多团队只学战术设计,一上来就研究聚合根、值对象,却忽略了限界上下文——结果是模型建得很精致,但服务边界依然混乱,模型放错了地方。战略设计比战术设计更重要,也更难,因为它没有编译器帮你检查。


二、战略设计:先在问题空间想清楚#

战略设计的本质是在两个空间里各做一次切分:在问题空间切出子域,在解决方案空间切出限界上下文。二者的关系是整个 DDD 最容易混淆的地方。

1. 领域与子域:问题空间的切分#

领域是业务问题空间本身,子域是对领域的自然切分,通常按业务能力划分。注意:子域是业务视角的划分,与"你们公司有几个开发组"“数据库里有多少张表"无关。

子域分三类,分类的意义在于决定投入策略——这是 DDD 里少数能直接指导资源分配的部分:

类型含义投入策略判断信号
核心域公司竞争力的来源,决定差异化自研,投入最强资源与最好的人做不好会失去竞争力;同行做法差异大;变化快
支撑域业务必需,但不构成核心竞争力自研或采购,追求稳定与低成本必须做,但做成什么样客户不关心
通用域行业通用能力直接买或开源,不要自研同行做法基本一致;有成熟商业方案

判断核心域的方法很简单:这件事做不好,公司会不会失去竞争力? 会,就是核心域。

但要注意两个常见误判:

  • 把支撑域当核心域自研:比如自建 IM、自建工作流引擎、自建权限系统,投入巨大但产生不了差异化,最后拖垮主干研发。
  • 把核心域当支撑域外包:核心域恰恰是不能外包的部分,因为它的知识沉淀就是竞争力本身。

再补一个动态视角:核心域会漂移。一个子域今天是不是核心域,取决于公司的战略。电商早期交易是核心域,随着履约网络成为壁垒,履约也可能变成核心域。所以核心域判断应该是一个定期重审的结论,不是一次性标签。

2. 限界上下文:解决方案空间的边界#

限界上下文是 DDD 中最重要的概念之一,也是微服务拆分的直接依据。

它的核心规则是:

一个限界上下文内部,所有术语必须有且仅有一个明确含义。

例如"商品"这个词:

  • 在交易上下文里,商品是"可售 SKU + 价格 + 库存快照”;
  • 在履约上下文里,商品是"物理货物 + 库位 + 包裹";
  • 在售后上下文里,商品是"保修对象 + 服务期限"。

同一个词,不同含义,就说明它们属于不同的限界上下文。

限界上下文实际上承担了三重边界,这也是它比"模块划分"重得多的原因:

边界维度含义越界的后果
语言边界同一术语在此内只有一个含义需求评审时各说各话,代码里出现 Order 与 Trade 混用
模型边界模型只服务于本上下文的业务规则一个 User 类里塞进登录、会员、客服、风控四套字段
事务边界跨上下文不追求强一致分布式事务满地跑,锁竞争与失败重试拖垮链路

3. 子域 ≠ 限界上下文#

这是实践中最容易踩的坑。子域在问题空间(业务怎么说),限界上下文在解决方案空间(系统怎么建),二者不是天然一一对应的:

flowchart LR
    subgraph OS["问题空间:子域"]
        A1["子域 A:交易"]
        A2["子域 B:履约"]
    end

    subgraph SS["解决方案空间:限界上下文"]
        B1["交易上下文"]
        B2["拣货上下文"]
        B3["配送上下文"]
    end

    A1 -->|"1:1"| B1
    A2 -->|"1:N:技术/团队/伸缩性驱动拆分"| B2
    A2 --> B3

常见的四种映射形态:

形态何时出现风险
1 子域 : 1 上下文最理想,业务与系统边界一致无
1 子域 : N 上下文团队规模大、伸缩性诉求不同、读写负载差异大上下文间协调成本上升
N 子域 : 1 上下文业务能力很弱、合并成本低于拆分成本模型容易互相污染,需警惕
1 子域 : 0 上下文该子域已采购或尚未启动需在上下文映射里标注为外部系统

判断口诀:子域的划分只能来自业务,限界上下文的划分可以再叠加技术与组织因素。

4. 通用语言:不只是术语表#

通用语言是团队和代码共享的一套词汇表。它必须贯穿需求、设计、代码、测试和文档。

如果业务方说"取消订单",代码里却叫 deleteOrder,这就是语言断裂。而且断裂会沿着链路扩散:

环节业务方表述断裂后的代码后果
需求取消订单deleteOrder取消是状态流转,删除是物理消失,语义完全不同
接口取消订单POST /order/remove契约无法表达"可取消"的约束
存储取消订单DELETE FROM orders审计与对账数据永久丢失
测试取消订单testDelete()测试用例名无法与业务用例对齐

通用语言不是写一份术语表就完事,而是要反映在代码命名、接口定义和数据库字段里。一套可执行的校准机制:

  1. 术语表双向维护:业务侧的名词与代码里的类型名一一映射,新增术语必须同时更新两侧;
  2. 命名即评审项:Code Review 时,OrderCancelReason 是否对应业务说的"取消原因"是一等公民的评审点;
  3. 测试即文档:用例名直接使用业务原话,Test_取消订单_已发货时应当拒绝;
  4. 语言断裂即重构信号:发现同一个业务概念出现两个名字,或同一个名字承担两种含义,优先质疑边界,而不是加一个转换层糊过去。

5. 上下文映射:9 种模式与背后的权力关系#

划分完上下文后,还要定义它们之间的关系。上下文映射不只是技术集成方案,它同时是一份团队协作与谈判结果的记录——上游是谁、谁有话语权,决定了你该用哪种模式。

模式含义适用场景代价
共享内核多个上下文共享一小部分模型两个团队高度协同、模型稳定任一方改动都会牵动另一方,需严格管控
客户-供应商上游按下游需求调整接口上下游有明确的服务关系上游需为下游排期,下游有等待成本
遵奉者下游直接遵循上游模型,不做适配上游强势且模型够用(如云厂商 API)下游模型被上游绑架
防腐层下游加一层转换,隔离上游模型变化上游模型与本地语义差异大,或上游不稳定多一层维护与映射成本
开放主机服务上游提供统一协议供多方调用一上游对多下游协议设计需足够稳定、版本管理复杂
发布语言用统一格式发布事件或数据跨上下文异步集成需要schema治理与兼容性策略
各行其道两个上下文没有直接关系业务上确实无关可能重复建设
合作关系双方共同成功或共同失败,互相排期强耦合的两块业务沟通成本高,一方延期拖累全局
大泥球无边界、无规则的共享模型无(应避免)任何改动都可能引发不可预知的影响

上游与下游的权力关系,可以归纳成这张判断图:

flowchart TB
    Q1{"上游是否愿意<br/>为下游调整?"}
    Q1 -->|"愿意且双方对等"| P1["合作关系 / 共享内核"]
    Q1 -->|"愿意,但上游主导"| P2["客户-供应商"]
    Q1 -->|"不愿意,上游强势"| Q2{"上游模型是否<br/>基本可用?"}
    Q2 -->|"可用"| P3["遵奉者"]
    Q2 -->|"差异大或不稳定"| P4["防腐层(ACL)"]
    Q1 -->|"上游服务多方"| P5["开放主机服务 + 发布语言"]

一个典型的电商上下文地图:

flowchart LR
    Product["商品上下文"]
    Trade["交易上下文"]
    Settle["结算上下文"]
    Fulfill["履约上下文"]
    After["售后上下文"]
    Pay["第三方支付"]
    Logistics["第三方物流"]

    Product -->|"OHS + PL:SKU 事件"| Trade
    Trade -->|"Customer-Supplier:订单事件"| Fulfill
    Trade -->|"Customer-Supplier:应付凭证"| Settle
    Settle -->|"ACL:隔离支付网关模型"| Pay
    Fulfill -->|"ACL:隔离物流商模型"| Logistics
    After -.->|"Shared Kernel:售后单与订单号"| Trade

读图要点:凡是外部不可控的系统(支付、物流),一律用防腐层挡住;凡是自己在主导的上下文之间,用事件解耦;共享内核只用在真正需要强协同的地方,且范围要小。

避免外部模型污染是防腐层的首要职责,它的代码骨架长这样:

// internal/biz/payment_gateway.go —— 领域层定义的端口
type PaymentGateway interface {
    Pay(ctx context.Context, orderNo string, amount Money) (PaymentID, error)
}

// internal/data/payment_gateway.go —— 基础设施层的实现(防腐层)
type alipayGateway struct {
    client *alipay.Client
}

func (g *alipayGateway) Pay(ctx context.Context, orderNo string, amount Money) (PaymentID, error) {
    // 上游模型 -> 本地模型 的转换集中在此处,不外泄
    resp, err := g.client.TradePay(ctx, &alipay.TradePayRequest{
        OutTradeNo: orderNo,
        TotalAmount: amount.YuanString(), // 第三方用"元"字符串,本地用"分"整数
    })
    if err != nil {
        return "", mapAlipayError(err) // 把上游错误码翻译成领域语义错误
    }
    return PaymentID(resp.TradeNo), nil
}

注意最后一行:错误也要翻译。把 ACQ.TRADE_HAS_SUCCESS 这类上游错误码直接抛到业务代码里,等于把上游的语言灌进了领域层,防腐层就白加了。


三、领域如何划分:从业务能力到边界#

领域划分不是拍脑袋,可以按以下步骤推进:

  1. 业务能力盘点:和业务方一起列出所有业务能力,而不是先看数据库表。开场问题应该是"公司靠什么赚钱",而不是"有哪些系统"。
  2. 区分核心域、支撑域、通用域:确定投入优先级。
  3. 找语言边界:同一个词在不同业务里含义不同,就是拆分信号。
  4. 看变化频率:变化节奏一致的东西放一起,变化节奏不同的分开。频繁变化的部分不应污染稳定的部分。
  5. 看团队边界:康威定律绕不开,一个限界上下文最好由一个团队负责。
  6. 看事务边界:一个聚合的修改应在一个事务内完成。如果两个子域需要强一致事务,边界可能划错了。
  7. 看伸缩性需求:读写比、峰值 QPS、延迟敏感度差异巨大的两块能力,分开部署更划算。
  8. 看合规与安全边界:数据分级、跨境合规、审计要求,往往天然形成上下文边界。
  9. 用事件风暴工作坊验证:通过领域事件、命令、聚合、策略,把业务流程和边界可视化。

1. 事件风暴:三个阶段的产出不同#

事件风暴常被当成"贴便利贴的团建",其实它是有明确阶段和产出的。

flowchart LR
    A["阶段一:Big Picture<br/>贴领域事件,还原业务流程"] --> B["阶段二:Process Level<br/>补齐命令—事件—策略闭环"]
    B --> C["阶段三:Design Level<br/>识别聚合与限界上下文"]
    C --> D["产出:上下文地图<br/>+ 聚合清单 + 关键领域事件"]
阶段参与者关键产出常见错误
Big Picture业务 + 产品 + 研发按时间轴排列的领域事件墙陷入细节争论,纠结字段与表结构
Process Level产品 + 研发命令、事件、策略(Policy)闭环,热点与瓶颈只有"正常流程",忽略补偿与异常分支
Design Level研发聚合、不变量、限界上下文、上下文映射直接跳到技术实现,忽略语言一致性检查

阶段二里最容易被漏掉的是策略(Policy)——即"当某事件发生时,应当触发什么命令"。策略恰恰是跨聚合、跨上下文协作的落点:

flowchart LR
    C1["命令:提交订单"] --> E1["事件:订单已提交"]
    E1 --> P1{"策略:库存是否充足?"}
    P1 -->|"充足"| C2["命令:锁定库存"]
    P1 -->|"不足"| C3["命令:取消订单"]
    C2 --> E2["事件:库存已锁定"]
    E2 --> P2["策略:发起支付"]
    P2 --> C4["命令:创建支付单"]

看图的关键:一条命令产生事件,事件触发策略,策略再发出命令——这个闭环是最终一致性的骨架。把它画出来,跨上下文的事务边界就自然浮现了。

2. 以电商为例:划分结果与边界依据#

  • 交易上下文:下单、取消、价格计算;
  • 履约上下文:仓库、发货、物流;
  • 结算上下文:支付、退款、对账;
  • 售后上下文:退换货、维修、保修。

每个上下文内部都可以有"用户"“订单"“商品"的概念,但含义不同,模型也不同。注意下表中的同名不同义——这就是边界的直接证据:

概念交易上下文中的含义履约上下文中的含义拆分依据
订单一份购买契约、金额、优惠一组待出库的拣货任务一个管钱,一个管货;变化频率与团队不同
商品可售 SKU、价格、库存快照物理货物、库位、体积重量交易关心价格,履约关心物理属性
用户买家账号、收货信息、等级收件人、联系方式、地址交易需要画像,履约只需要可送达性
完成支付成功即完成签收才算完成同一流程两种终态,是最典型的边界信号

关键原则:领域划分依据业务能力和语言边界,不是数据表,也不是技术分层。

3. 边界粒度:拆分的收益会递减,成本不会#

划完边界,还要回答"多大算合适”。拆分不是免费的:

拆分收益拆分成本
独立部署、独立发版网络调用替代本地调用,延迟与失败率上升
独立伸缩,按热点分配资源跨服务事务一致性需要最终一致 + 补偿
团队自治,减少协作阻塞分布式追踪、日志聚合、链路告警的复杂度
故障隔离,避免一处崩全站契约管理、版本兼容、联调成本
模型边界清晰,减少相互污染数据冗余与跨库查询能力的丧失

经验法则:每拆出一个服务,都要能明确说出它解决了上述哪一个成本问题。说不出,就不拆。服务数量本身不是架构质量的指标。


四、战术设计:边界内如何建模#

进入一个限界上下文后,才用战术设计构建模型。战术设计的目标只有一个:让业务规则有唯一的、可读的、不可绕过的承载点。

1. 实体与值对象:不是分类,是约束#

  • 实体:有唯一标识,生命周期内标识不变。例如订单、用户。
  • 值对象:没有唯一标识,靠属性值判断相等。例如金额、地址、时间范围。

区别在于相等性语义,以及由此衍生的并发特性:

维度实体值对象
相等判定标识相同即相等所有属性相同才相等
可变性可变(标识不变,状态可变)不可变,修改返回新对象
并发安全需要版本号 / 锁保护天然线程安全,可自由共享
生命周期有明确的生命周期与状态机无生命周期,随用随弃
典型例子订单、账户、优惠券金额、地址、时间区间、坐标

值对象最大的价值常被低估:它是消灭"原始类型偏执”(Primitive Obsession)的工具。当一个金额、电话、邮箱被建模为值对象时,校验规则、格式约束、运算逻辑就有了唯一的归宿,而不再散落在各个 Service 里各写一遍。

type Money struct {
    cents    int64  // 用整数分存储,避免浮点误差
    currency string
}

// 构造函数是唯一的校验入口,非法状态根本无法被创建
func NewMoney(cents int64, currency string) (Money, error) {
    if currency == "" {
        return Money{}, ErrCurrencyRequired
    }
    return Money{cents: cents, currency: currency}, nil
}

// 值对象返回新对象,不修改自身
func (m Money) Add(other Money) (Money, error) {
    if m.currency != other.currency {
        return Money{}, ErrCurrencyMismatch
    }
    return Money{cents: m.cents + other.cents, currency: m.currency}, nil
}

注意 Add 里那道币种校验——如果金额是 int64,这个规则会散落在每一个做加法的 Service 里,漏掉一处就是线上事故。

2. 聚合:一致性边界的本质#

聚合是 DDD 里技术含量最高的概念,也是最容易被简化成"一个主表带几个从表"的概念。

聚合的真正定义:一组必须保持一致性不变量的对象的集合,聚合根是唯一入口。 拆开看,聚合同时是三种边界:

聚合承担的边界含义直接后果
不变量边界聚合内所有业务规则在任何时刻都必须成立决定"哪些对象必须放在一起"
事务边界一次事务只修改一个聚合决定"哪些修改可以原子完成"
并发边界锁与版本号的作用范围决定"哪些操作会互相阻塞"

由此推出聚合设计的四条规则,以及它们为什么成立:

规则原因
小聚合聚合越小,并发冲突概率越低,加载的数据越少。聚合粒度直接等于锁竞争的概率
通过 ID 引用其他聚合持有对象引用会诱导跨聚合的联动修改,破坏事务边界;同时导致对象图膨胀与懒加载陷阱
一个事务只修改一个聚合跨聚合的原子性会迫使引入分布式事务,而分布式事务的可用性代价极高
跨聚合一致性用领域事件与最终一致性把强一致换成有序事件的异步传播,用幂等与补偿换取可用性

聚合粒度的取舍,本质上是一道冲突率与加载成本的权衡题:

flowchart TB
    subgraph AR["订单聚合(一个事务边界)"]
        ROOT["Order(聚合根<br/>持有全部不变量)"]
        ROOT --> I1["OrderItem(实体)"]
        ROOT --> I2["OrderItem(实体)"]
        ROOT --> V1["Address(值对象)"]
        ROOT --> V2["Money(值对象)"]
    end

    ROOT -->|"仅持 ID 引用,不持有对象"| SKU["Product 聚合"]
    ROOT -->|"领域事件 OrderPlaced"| EV["事件发布"]
    EV -.->|"另一事务"| INV["Inventory 聚合:扣减库存"]
    EV -.->|"另一事务"| CRM["CRM 聚合:更新客户画像"]

    style AR fill:#f8f9fa

反例警示:不要让 Order 直接持有 Product 对象。理由是——商品的价格改了,订单的历史价格就被篡改了;商品对象还会把整个商品表拖进订单事务的加载路径里。正确做法是订单项里保存价格快照 + 商品 ID,这正是"快照式建模"在聚合设计中的体现。

聚合的并发控制通常用乐观锁 + 版本号:

type Order struct {
    ID      string
    version int64      // 乐观锁版本号,由 Repository 统一维护
    Status  OrderStatus
    Items   []OrderItem
}

// 状态流转的规则必须封在聚合内,外部无法绕过
func (o *Order) Cancel(reason CancelReason, now time.Time) error {
    if o.Status == OrderShipped {
        return ErrOrderAlreadyShipped // 已发货不可取消
    }
    if o.Status == OrderCancelled {
        return nil                    // 幂等:重复取消不报错
    }
    o.Status = OrderCancelled
    o.CancelReason = reason
    o.CancelledAt = now
    o.AddEvent(OrderCancelled{OrderID: o.ID, Reason: reason})
    return nil
}

判断聚合粒度是否合理,可以用这三个问题快速自检:

  1. 不变量:这两块数据之间,是否存在"任何时刻都必须同时成立"的规则?没有,就不该在同一聚合。
  2. 原子性:是否存在"必须同时成功或同时失败"的修改?没有,就可以拆开。
  3. 冲突率:合并后,并发写冲突是否显著上升?上升,说明拆得太粗。

3. 领域事件与最终一致性:从"发事件"到"事件不丢"#

领域事件表示领域内已经发生的事实,例如 OrderPlaced、PaymentCompleted。命名一律用过去式——因为它描述的是不可更改的事实,而不是请求。

这里有一个关键区分,很多团队混用了:

维度领域事件集成事件
作用范围限界上下文内部,用于解耦聚合跨上下文,用于对外集成
内容可携带聚合内部结构,字段丰富只携带契约字段,需向后兼容
契约稳定性可随模型重构而变一旦发布就是对外承诺,只能追加不能删除
传输方式进程内发布订阅消息队列 / 事件总线

直接把领域事件丢进 MQ 是常见错误:本地模型一重构,下游全部炸掉。正确做法是在上下文边界做一次转换——领域事件在内部流转,出边界时翻译成稳定的集成事件。

接下来是真正有技术含量的部分:事件发布的时机陷阱。

朴素写法是在事务提交后发事件,但这中间存在一个致命的窗口:

sequenceDiagram
    participant S as 应用服务
    participant DB as 数据库
    participant MQ as 消息队列

    S->>DB: COMMIT(订单已落库)
    Note over S,DB: ⚠️ 崩溃窗口:此处宕机则事件永久丢失
    S->>MQ: 发布 OrderPlaced

反过来,先发消息再提交,则会遇到另一个问题:事务回滚了,消息却已发出,下游看到的是一个不存在的订单。

这两种失败模式无法用"调整顺序"解决,因为数据库事务与消息中间件无法共享同一个原子提交(除非使用支持事务消息的中间件)。工程上成熟的解法是事务性发件箱(Transactional Outbox):

sequenceDiagram
    participant App as 应用服务
    participant DB as 数据库
    participant Relay as 中继(轮询 / CDC)
    participant MQ as 消息队列
    participant Sub as 订阅方

    App->>DB: BEGIN
    App->>DB: UPDATE 聚合状态
    App->>DB: INSERT outbox 事件记录(同一事务内)
    App->>DB: COMMIT
    Note over App,DB: 业务状态与事件记录原子写入,要么都成功要么都失败
    Relay->>DB: 轮询未投递事件 / 读取 binlog
    Relay->>MQ: 至少一次投递
    Relay->>DB: 标记已投递
    MQ->>Sub: 消费
    Note over Sub: 消费方必须幂等(去重表 / 唯一键)

这个方案把"分布式原子性"问题降级成了"本地事务 + 至少一次投递",代价是引入幂等消费的要求。这套组合拳是最终一致性的标准骨架,缺一不可:

  • 至少一次投递保证不丢,但会重复;
  • 幂等消费消化重复,通常用业务唯一键或去重表;
  • 补偿事务处理业务上的最终失败,例如库存扣减失败后取消订单并退款;
  • 对账兜底作为最后一道防线,处理所有前面都没覆盖到的异常。

一句话原则:分布式系统里没有"不丢不重",只有"不丢重了也能正确处理"。

4. 领域服务、应用服务与策略#

  • 领域服务:处理跨实体或跨聚合的领域逻辑,不适合放在单个实体里。例如"根据用户等级、订单金额、活动规则计算最终优惠"。
  • 应用服务:编排用例,不包含业务规则。它负责事务、鉴权、日志、事件发布,把真正的决策委托给领域对象。
  • 策略 / 规格(Specification):把可组合的规则对象化,用于复杂的校验与筛选条件。

三者的分工可以用一句话记住:应用服务负责"做什么",领域服务负责"怎么按业务规则做",实体负责"我自己的状态我说了算"。

一个常见反面模式是"应用服务膨胀"——事务脚本式的写法把所有规则堆在应用服务里,最终它变成了一个什么都懂、什么都改的上帝类,而领域对象退化成数据传输对象。

5. 仓储、工厂与依赖倒置#

  • 仓储:面向聚合根,不是面向表。接口定义在领域层,实现在基础设施层。
  • 工厂:处理复杂聚合的创建逻辑,尤其当创建过程本身涉及业务规则时。
  • 工作单元(Unit of Work):为"一个事务修改一个聚合"提供落地机制。

仓储的语义是集合,不是 DAO。这个区别很实际:

// 领域层定义:语义是"聚合的集合"
type OrderRepo interface {
    NextID(ctx context.Context) (string, error)
    Save(ctx context.Context, order *Order) error // 新增与更新统一为 Save
    FindByID(ctx context.Context, id string) (*Order, error)
    FindByUser(ctx context.Context, userID string, status OrderStatus) ([]*Order, error)
}

// 反例:把仓储写成 DAO,暴露 SQL 式的操作
// type OrderDAO interface {
//     UpdateStatus(ctx context.Context, id string, status int) error  // ❌ 绕过聚合,破坏不变量
//     UpdateAmount(ctx context.Context, id string, amount int64) error // ❌ 业务规则无处安放
// }

关键判断:如果仓储接口里出现了 UpdateXxx 这种"改某个字段"的方法,说明聚合的行为被绕过了,聚合内的不变量形同虚设。

至于 FindByUser 这类查询——它返回的是聚合集合,用于业务决策,属于仓储。而报表、列表页这类复杂查询,应该走独立的读模型(CQRS 的 Q 端),不要污染仓储接口:

查询类型归属理由
按 ID 加载聚合仓储为写操作服务,需要完整聚合
按业务条件筛选聚合仓储结果仍用于业务决策
多表联查、分页列表、报表读模型 / QueryService不需要聚合行为,走仓储反而造成过度加载

6. 一个完整的对比:贫血模型与充血模型#

同一段"取消订单"的业务,两种写法的差别就是 DDD 是否真正落地的分水岭。

// ❌ 贫血模型:业务规则散落在应用服务里,任何调用方都能绕过
func (s *OrderService) Cancel(ctx context.Context, orderID string) error {
    order, err := s.repo.FindByID(ctx, orderID)
    if err != nil {
        return err
    }
    if order.Status == 3 {          // 魔法数字:3 是什么?调用方还得再判断一次
        return errors.New("已发货")
    }
    order.Status = 4                // 直接赋值,规则可被任意绕过
    return s.repo.UpdateStatus(ctx, orderID, 4)
}
// ✅ 充血模型:规则封在聚合内,唯一的修改入口
type Order struct {
    ID      string
    Status  OrderStatus
    Version int64
    events  []DomainEvent
}

func (o *Order) Cancel(reason CancelReason, now time.Time) error {
    if !o.Status.Cancellable() {          // 规则由状态自身表达,而非魔法数字
        return ErrOrderNotCancellable{Status: o.Status}
    }
    o.Status = OrderCancelled             // 只有聚合自己能改自己的状态
    o.CancelReason = reason
    o.CancelledAt = now
    o.events = append(o.events, OrderCancelled{OrderID: o.ID, Reason: reason})
    return nil
}

// 应用服务只做编排:加载 -> 委托 -> 保存 -> 发布事件
func (uc *OrderUsecase) Cancel(ctx context.Context, orderID string, reason CancelReason) error {
    order, err := uc.repo.FindByID(ctx, orderID)
    if err != nil {
        return err
    }
    if err := order.Cancel(reason, uc.clock.Now()); err != nil {
        return err
    }
    if err := uc.repo.Save(ctx, order); err != nil {
        return err
    }
    return uc.publisher.Publish(ctx, order.Events()...)
}

差别不在于"写得更啰嗦",而在于约束的强制性:贫血模型下,任何人新增一个入口都可能忘记校验;充血模型下,引擎层面就无法构造出非法状态。


五、DDD 与微服务:边界如何映射到部署#

限界上下文是微服务拆分的候选边界,但不是强制一一对应。

flowchart TB
    BC["限界上下文<br/>(逻辑边界,由语言与模型决定)"]
    BC -->|"最常见"| M1["1:1 一个微服务"]
    BC -->|"伸缩性/团队驱动"| M2["1:N 拆成多个服务<br/>如读写分离、CQRS"]
    BC -->|"慎用"| M3["N:1 一个服务含多个上下文"]
    M3 -.->|"后果"| BAD["分布式单体:<br/>共享模型 + 同步调用 + 强一致事务"]
  • 一个限界上下文通常对应一个微服务;
  • 也可以根据团队、性能、部署需求,把一个上下文拆成多个服务;
  • 不推荐一个服务包含多个限界上下文,那会变成分布式单体。

识别分布式单体的四个信号(命中两个以上就要警惕):

信号表现说明
共享模型多个服务引用同一个 common 包里的实体定义模型没有边界,改动会牵一发动全身
同步调用链一个请求串行经过 4 个以上服务可用性相乘,链路一坏全坏
强一致事务跨服务使用分布式事务或"先改 A 再改 B,失败回滚"边界与事务边界不一致
联合发版两个服务必须同时发布才能上线拆了个寂寞,只是把单体拆成了进程

微服务拆分的关键不是"拆得多细",而是边界是否与业务语言一致。如果两个服务共享同一套模型、频繁同步调用、需要强一致事务,那它们很可能应该属于同一个限界上下文。

反过来也要注意:不要为了"架构先进"而拆。一个日活几千的内部系统,强行拆成 12 个微服务,得到的不是可扩展性,而是一个需要专职运维的分布式单体。


六、DDD 与 Kratos 四层架构#

Kratos 的四层架构常被理解为:Server / Service / Biz / Data,外加 api 和 cmd。

它可以映射到 DDD 分层:

flowchart TB
    S["Server 层<br/>HTTP / gRPC 启动、中间件、生命周期"]
    SV["Service 层<br/>应用层:接口适配、DTO 转换、用例编排"]
    B["Biz 层<br/>领域层:聚合、实体、值对象、用例、Repository 接口"]
    D["Data 层<br/>基础设施:Repository 实现、DB、缓存、外部 RPC"]

    S --> SV --> B
    B -.->|"依赖倒置:只依赖接口"| D
    D -.->|"运行时由 wire 注入实现"| B

    style B fill:#f8f9fa
Kratos 层DDD 对应层职责不该出现的东西
Server基础设施 / 用户界面HTTP、gRPC 启动,中间件,生命周期业务判断
Service应用层接口适配,DTO 转换,编排用例业务规则、领域计算
Biz领域层领域模型、用例、Repository 接口SQL、框架依赖、具体中间件
Data基础设施层Repository 实现,数据库、缓存、外部 RPC业务规则(除了持久化细节)

Kratos 是一个 DDD-ready 的容器,但它不强制你写出 DDD 代码。

如果 Biz 层里只有一堆过程式 Use Case,操作的是贫血 struct,业务规则散落在 Service 和 SQL 里,那它只是分层架构,不是 DDD。

判断标准:

  1. 领域对象有行为吗?
  2. Repository 接口是为聚合根定义的吗?
  3. 业务规则在领域对象里,还是散落在应用服务里?

三个都是"否",就还没进入 DDD。

一个典型的 Kratos 目录结构:

api/                       # proto 契约:对外承诺,需保持兼容
cmd/                       # wire 组装,启动

internal/
  server/                  # HTTP / gRPC 启动、中间件
  service/                 # 应用服务:接口适配、DTO <-> 领域模型转换
  biz/
    order.go               # 聚合、实体、值对象、领域事件
    order_status.go        # 状态枚举与状态流转规则
    order_usecase.go       # 用例编排:事务、事件发布
    order_repo.go          # Repository 接口(领域层定义)
  data/
    order_repo.go          # Repository 实现(基础设施层)
    model/                 # ORM 模型,与领域模型分离

Repository 接口定义在 Biz,实现在 Data,这就是依赖倒置——编译期领域层不依赖任何基础设施。

这里有一个实践中最容易含糊的点:Biz 里的聚合与 Data 里的 ORM 模型该不该是同一个结构体?

方案做法优点缺点
复用同一个结构体(常加 biz:"-" 标签)聚合直接带 ORM 标签少一层转换,代码量低领域模型被持久化细节污染,重构受限
模型分离biz.Order 与 data.OrderPO 分离领域模型纯净,两边可独立演进需要写转换代码,有维护成本

另一个高频问题是:事务该由谁管? 常见做法是应用服务持有一个 Transaction 抽象,在用例层开启事务,把带事务的 ctx 传给仓储实现。这样事务边界是显式的、出现在用例里的,一眼就能看出"这个用例动了几个聚合":

func (uc *OrderUsecase) Cancel(ctx context.Context, orderID string, reason CancelReason) error {
    return uc.tx.Transaction(ctx, func(ctx context.Context) error {
        order, err := uc.repo.FindByID(ctx, orderID)
        if err != nil {
            return err
        }
        if err := order.Cancel(reason, uc.clock.Now()); err != nil {
            return err
        }
        // 同一事务内:保存聚合 + 写入事件(在 repo 内部完成)
        // 跨聚合/跨上下文的动作,交给事件异步处理
        return uc.repo.Save(ctx, order)
    })
}

注意函数体里的注释:事务内只有本聚合的写操作。如果这里出现了对第二个聚合的直接调用,就说明事务边界被破坏了。


七、落地路线图:分阶段推进#

DDD 很难一次全面铺开,按下面的节奏推进,风险最低:

阶段关键活动产出物参与人周期参考
一、事件风暴还原业务流程,识别领域事件、命令、策略、读模型事件墙、业务流程闭环图业务 + 产品 + 研发1–2 天工作坊
二、划分子域区分核心域、支撑域、通用域子域清单 + 投入策略业务负责人 + 架构师1 周
三、定义限界上下文做语言一致性检查,确定边界上下文清单 + 边界依据说明研发主导1–2 周
四、建立通用语言术语表双向维护,落到命名规范术语表 + 命名约定 + 评审清单全团队持续
五、上下文映射确定集成方式与上下游关系上下文地图 + 集成方式决策记录架构师 + 各团队代表1 周
六、设计聚合识别不变量,小聚合,ID 引用聚合清单 + 领域事件清单研发与开发同步
七、定义仓储接口面向聚合根,不面向表领域层接口定义研发与开发同步
八、试点落地从核心域的一个上下文开始,跑通全流程一个完整的 DDD 服务试点团队3–6 周
九、复盘推广沉淀模式与反模式,逐步推广团队内部规范文档试点团队持续

三个执行要点:

  1. 顺序不能倒:跳过战略设计直接做战术设计,团队会在错误的边界内精益求精。先战略,后战术。
  2. 必须试点:不要全公司铺开。选一个中等复杂度、业务方配合度高的上下文试点,成功后再复制。
  3. 允许边界演化:第一次划分几乎不可能完全正确。把边界当作可迭代的假设,在试点中验证——但要记住,边界调整的成本随接入方数量增长,越晚调整越贵。

八、常见误区:症状、根因与修复#

误区症状根因修复方向
贫血模型实体只有 getter/setter,Service 里全是过程式代码,同一规则在多处重复把领域对象当数据传输对象,规则无处安放把规则搬回聚合,用构造函数守住非法状态,禁止公开 setter
按表拆服务用户表一个服务、订单表一个服务,改一个功能要动三个服务用数据模型替代领域模型,把 ER 图当边界依据回到语言边界与业务能力重新划分,参考"同名不同义"矩阵
上下文过细每张小功能一个上下文,分布式事务与调用链爆炸混淆了"模块"与"上下文",忽略分布式成本合并变化频率一致、团队一致的上下文,用"能否独立发版"检验
聚合过大一个聚合几十个实体,并发冲突严重,加载一次上百行按对象关联而非不变量确定聚合边界用"是否存在必须同时成立的不变量"重新切分,改 ID 引用
通用语言停留在文档术语表写得漂亮,代码里 Order/Trade/Deal 混用语言没有被纳入工程约束术语表双向维护,命名作为 Code Review 必查项
忽略组织架构边界与团队边界严重冲突,两个团队共写一个上下文忽视康威定律,只做技术设计边界要么对齐团队,要么调整团队;不要指望跨团队共治一个模型
战术设计过度简单 CRUD 也硬上聚合、领域事件、CQRS,代码量翻三倍把 DDD 当模板套用,而非按复杂度匹配手段简单子域用简单方案,DDD 的投入应与业务复杂度成正比
上下文映射缺失服务间调用哪里方便哪里连,形成网状依赖只做了划分,没定义关系画出上下文地图,明确每个连接的模式(ACL / OHS / 事件)
领域事件当集成事件用内部模型一重构,下游服务全挂混淆两种事件的契约稳定性要求在上下文边界做一次翻译,对外只暴露稳定的集成事件
先发消息后落库偶发"下游收到不存在的订单"或"事件丢失"数据库与消息队列无法原子提交引入事务性发件箱,配合幂等消费与对账兜底

九、总结#

DDD 的本质是降低业务复杂度,不是技术炫技。判断一次 DDD 实践是否成功,不看用了多少模式,而看三个问题:

  1. 业务方和研发是否在用同一套词汇讨论需求?
  2. 新的业务规则,是否有一个明确的、唯一的落点?
  3. 服务边界是否稳定——改动大部分时候只在一个上下文内?

它的核心顺序是:

先战略,后战术;先边界,后模型;先业务语言,后技术实现。

贯穿全文的几条主线:

  • 限界上下文是微服务拆分的依据,它同时是语言边界、模型边界和事务边界;
  • 聚合是事务一致性的边界,粒度由不变量决定,跨聚合协作只能靠领域事件与最终一致性;
  • 领域事件是上下文解耦的手段,但真正让它在生产环境可靠的是发件箱 + 幂等 + 补偿 + 对账这套组合拳;
  • 通用语言是团队协作的基础,它必须活在代码里,而不是活在文档里;
  • 上下文映射不只是集成方案,它记录了团队之间的权力关系与协作方式。

Kratos 这类框架可以提供 DDD 的物理分层,但真正的 DDD 取决于你是否在 Biz 层认真建模,是否让领域对象承载业务规则,是否用业务语言划分边界。

边界比技术重要,模型比框架重要。 而在这两者之前,还有一个更重要的前提:这件事的业务复杂度,是否真的值得上 DDD。

你又来啦!好开心 🐟