1. 项目概述:一个从业十年的DDD实践者,为什么还在反复重写“richiezhang”这个ID?
“richiezhang”不是某个开源库的GitHub用户名,也不是某家科技公司的技术博客前缀——它是我给自己留的一块试验田,一个持续迭代了八年、重写了七版的个人知识沉淀系统。最早它是一份Word文档,标题叫《DDD落地手记》;后来变成一个静态网站,用Jekyll搭在GitHub Pages上;再后来是带登录态的Node.js小应用,存着我画烂的限界上下文草图和改到第37次的聚合根UML;现在它是一个完全离线、纯本地运行的Obsidian插件集合,核心逻辑用TypeScript重写,所有领域模型都以Markdown文件为载体,通过YAML Frontmatter声明边界与契约。
你可能会问:不就是写点DDD笔记吗?至于搞这么复杂?
我的回答是:
恰恰因为它是“笔记”,才最危险。
DDD不是一套能背下来就上岗的术语清单,它是一套需要在真实业务血肉里反复切片、缝合、再解剖的肌肉记忆。而“richiezhang”这个ID,就是我给自己设下的唯一验收标准:当我在凌晨三点修改一个门诊流程的聚合根设计时,如果代码里还藏着一句
SELECT * FROM users WHERE id = @id
,那这个版本就立刻废弃——因为它已经背叛了DDD最朴素的起点:
先让领域说话,再让数据库喘气。
这背后有三重现实刺痛:
第一,我见过太多团队把DDD当装饰。他们在架构图里郑重其事地画出Domain层,却在Controller里直接new一个UserRepository,调用
GetUserWithOrdersAndAddressesById()
——这根本不是分层,这是给三层架构套了一件DDD的西装,领带还系反了。
第二,我亲手带过的三个医疗SaaS项目,全部在第六个月左右遭遇“领域失语症”:业务方说“我们要支持处方电子签名”,开发却在讨论“签名字段加在Prescription表还是新建SignatureLog表”。问题不在技术,而在没人能指着一个活的领域模型说:“看,这就是电子签名的职责归属——它必须属于Prescription聚合,因为签名行为会改变处方的法律效力状态。”
第三,也是最扎心的:
DDD的失败,90%不是败在技术,而是败在建模过程的不可见性。
你无法向产品经理展示一段UML类图如何降低未来三个月的需求变更成本;但你能让他亲眼看到,当把“挂号-问诊-开方-缴费”强行塞进一个Order聚合时,光是处理“退号后处方是否自动作废”这一条规则,就触发了5个跨模块的if-else判断链。
所以,“richiezhang”从来不是一篇教程,它是我用真实项目踩出来的脚印地图。接下来的内容,不会复述Eric Evans书里的定义,也不会罗列CQRS、Event Sourcing这些高阶词汇。我会带你钻进一个真实的门诊系统重构现场,从一张被撕掉三次的白板开始,还原一个领域模型是如何从模糊的业务对话中长出血肉、骨骼与神经的。你不需要懂泛型约束,但必须愿意重新思考:当你写下
class Patient
时,你到底在承诺什么?
2. 领域驱动设计的本质解构:为什么说“先建模,后存储”不是教条,而是生存法则?
2.1 真实世界的复杂性,从来不在数据库里,而在业务规则的毛细血管中
我们总以为软件复杂是因为数据量大、并发高、接口多。但翻看过去五年我参与的12个中型项目故障日志,83%的线上事故根源指向同一个问题: 领域逻辑的隐式耦合被数据库外键“合法化”了。
举个血淋淋的例子:某三甲医院预约系统上线首周,出现大量“已支付未挂号”订单。排查发现,支付服务调用挂号服务时,挂号服务内部执行了两步操作:
- 创建挂号单(Insert into registration)
- 更新患者余额(Update patient_balance)
表面看是事务问题,但深挖下去,问题出在“患者余额”这个概念的归属上。业务方说:“余额是患者账户的核心资产,必须实时准确。”技术方案却把它放在Patient表里,和姓名、身份证号挤在一起。结果支付成功后,挂号服务因网络抖动只完成了第一步,第二步失败——而整个流程没有回滚机制,因为两个操作被当成独立的数据库事务处理。
这里暴露了DDD最常被忽略的底层逻辑: 数据库表结构是领域模型的投影,而非源头。 当你把“患者余额”硬编码进Patient实体时,你实际上在说:“余额的变更必须和患者基本信息变更绑定。”但真实业务中,余额可能因退费、补贴、医保结算等N种路径变动,每种路径的校验规则、审批流、审计要求都不同。把它和患者基本信息锁死,等于用数据库的物理耦合,强行制造了业务逻辑的逻辑耦合。
提示:判断一个概念是否该属于某个聚合根,有个野蛮但有效的测试——把它删掉,看聚合根是否还能完成其核心职责。如果删除“余额”后,Patient仍能完成挂号、就诊、开方等主流程,那它大概率不该在这里。
2.2 领域模型不是UML图,而是业务语言的可执行翻译器
很多团队卡在DDD第一步:怎么把“医生排班”“药品库存预警”这些业务词,变成代码里的类?他们花两周画出完美的类图,却在写第一行代码时卡住——因为UML图里没有标注“当西药房库存低于安全阈值时,需自动触发采购申请,且采购申请必须关联当前缺货药品的最近三次采购价”。
这才是DDD建模的真相: 领域模型是业务规则的最小可验证单元。 它必须能回答三个问题:
- 这个对象能做什么?(行为)
- 它在什么条件下能做?(不变量)
- 做完之后,世界变成什么样?(状态变更)
回到门诊系统,我们曾为“处方”建模争论三天。初版设计是:
public class Prescription {
public Guid Id { get; set; }
public List<PrescriptionItem> Items { get; set; }
public DateTime CreatedAt { get; set; }
}
看似合理,直到业务方提出:“处方开具后,如果患者没缴费,48小时内可由医生取消;一旦缴费,只能走‘作废’流程,且作废需记录原因并通知药房。”
这时我们意识到,
Prescription
不能只是一个数据容器。它必须封装状态机:
-
Draft(草稿)→ 可编辑、可删除 -
Issued(已开具)→ 可取消(48h内)、可缴费 -
Paid(已缴费)→ 只能作废,且作废需审批 -
Voided(已作废)→ 不可逆
于是重构为:
public class Prescription : AggregateRoot {
private PrescriptionStatus _status;
private DateTime? _paidAt;
private DateTime? _voidedAt;
// 核心行为:只有在Issued状态下才能缴费
public void Pay() {
if (_status != PrescriptionStatus.Issued)
throw new DomainException("仅已开具处方可缴费");
_status = PrescriptionStatus.Paid;
_paidAt = DateTime.UtcNow;
}
// 核心不变量:作废必须提供原因且仅限已缴费处方
public void Void(string reason) {
if (_status != PrescriptionStatus.Paid)
throw new DomainException("仅已缴费处方可作废");
_status = PrescriptionStatus.Voided;
_voidedAt = DateTime.UtcNow;
// 此处触发领域事件:PrescriptionVoided
}
}
你看,这段代码里没有SQL,没有DTO,甚至没有Repository。但它完整表达了业务规则——而且这些规则是编译期可检查、单元测试可覆盖的。这才是DDD要的“模型”,不是画在纸上的漂亮图形,而是跑在内存里的业务宪法。
2.3 为什么“Repository”不是DAO?一个被误解八年的接口
几乎所有DDD入门者都会栽在这个坑里:把
IUserRepository
当成
IUserDao
的高级马甲。他们写出这样的代码:
public interface IUserRepository {
User GetById(Guid id);
List<User> FindByDepartment(string deptName); // ❌ 跨聚合查询
void UpdatePassword(Guid userId, string newPassword); // ❌ 违反聚合根封装
}
这根本不是Repository,这是披着领域外衣的数据访问层。Eric Evans在书中明确指出: Repository是聚合根的内存集合的模拟。 它的唯一职责是:根据聚合根的标识符(Id),返回一个完整的、符合业务不变量的聚合实例。
真正的Repository接口应该像这样:
public interface IPrescriptionRepository {
// ✅ 合法:按Id获取完整处方(含所有药品项、状态、历史)
Prescription GetById(Guid id);
// ✅ 合法:按患者Id获取其所有处方(但注意:这是查询,不修改状态)
IReadOnlyList<Prescription> GetByPatientId(Guid patientId);
// ✅ 合法:保存整个聚合(框架负责持久化所有内部对象)
void Save(Prescription prescription);
}
关键区别在于:
-
不提供任何“条件查询”方法
(如
FindByStatus)。这类查询属于应用层或查询层职责,不应污染领域层。 -
不暴露聚合内部对象的单独操作
(如
UpdatePrescriptionItem)。聚合根必须封装其内部结构,外部只能通过公开方法改变状态。 - Save方法接收整个聚合根 ,而非部分字段。这意味着持久化时,框架必须保证聚合内所有对象的一致性(例如,保存处方时,必须同时保存其所有药品项和状态变更记录)。
我见过最典型的反模式,是某团队为“挂号单”设计了
UpdateRegistrationStatus
方法。结果业务方提出新需求:“挂号单状态变更时,需同步更新患者累计挂号次数。”开发人员直接在
UpdateRegistrationStatus
里加了一行
patient.IncrementVisitCount()
——瞬间,挂号单聚合根越权修改了患者聚合的状态,领域边界彻底崩溃。
注意:Repository的实现可以很“脏”,但接口必须极“净”。我在生产环境用过Dapper+手动SQL实现Repository,只要接口契约不变,上层领域逻辑就完全无感。这才是分层的价值:让领域层永远只和抽象打交道,把脏活留给基础设施层。
3. 从零构建门诊领域模型:一场真实的建模推演与代码落地
3.1 第一阶段:剥离数据库幻觉,用白板重构业务对话
2023年Q3,我接手某区域医疗平台的门诊模块重构。原系统是典型的“数据库驱动”:一张
registration
表,字段包括
patient_id
,
doctor_id
,
department_id
,
status
,
created_at
,
paid_at
,
canceled_at
……所有业务逻辑散落在各处存储过程中。
我们的第一次建模会议,做了三件反直觉的事:
- 关掉所有电脑,只留一块白板和马克笔
- 请来两位资深门诊护士,录音笔全程记录
- 禁止任何人说出“表”“字段”“SQL”等词
护士描述的第一个场景是:“王医生上午门诊,10个号源。张女士8:30来挂号,系统显示‘已满’,但她坚持要挂——原来她昨天预约了王医生的特需号,今天来确认。我们得查她的预约记录,再手工释放一个普通号给她。”
这句话里藏着三个关键领域概念:
- 号源(Slot) :不是简单的“可用/不可用”,而是有类型(普通/特需/预约)、归属(医生/科室)、时间窗口(上午/下午)的资源
- 预约(Appointment) :一种特殊的挂号前置动作,它占用号源但不立即生成挂号单
- 释放号源(ReleaseSlot) :一个需要审批的动作,因为涉及号源池的公平性
我们当场在白板上画出第一个限界上下文: 号源管理上下文(Slot Management Bounded Context) ,并明确其边界:
- 输入:预约创建、挂号请求、号源释放申请
- 输出:号源状态变更(Available/Booked/Released)
- 不包含:患者信息、医生排班规则、缴费逻辑(这些属于其他上下文)
实操心得:建模初期最大的陷阱,是试图用一个模型解决所有问题。我坚持让团队把“挂号”拆成三个独立模型:
Slot(号源):纯粹的资源容器,只关心“谁在何时能用”Appointment(预约):代表患者与医生的约定,生命周期独立于挂号Registration(挂号单):挂号行为的结果,包含费用、状态、关联的预约(如果有)
这种拆分让后续开发中,处理“预约转挂号”“号源冲突”等复杂场景时,代码清晰度提升了数倍。
3.2 第二阶段:定义聚合根与值对象,用代码固化业务契约
基于白板共识,我们开始定义核心聚合根。重点说说
Slot
的设计过程:
Step 1:识别聚合根身份
Slot
的唯一标识符不是自增ID,而是
SlotId
(组合键:
DoctorId + Date + Session + SlotIndex
)。这确保了号源的业务意义——它天然属于某位医生在某天某时段的特定位置。
Step 2:封装核心不变量
号源有三种状态:
Available
,
Booked
,
Released
。但状态转换有严格规则:
-
Available→Booked:仅当挂号请求匹配其DoctorId/Date/Session -
Booked→Released:需管理员审批,且必须记录释放原因 -
Released→Available:不允许!释放的号源进入“回收池”,需重新分配
于是
Slot
类的核心逻辑是:
public class Slot : AggregateRoot {
private SlotStatus _status;
private readonly DoctorId _doctorId;
private readonly DateOnly _date;
private readonly Session _session;
private readonly int _slotIndex;
// 构造函数强制校验:号源必须初始为Available
public Slot(DoctorId doctorId, DateOnly date, Session session, int slotIndex) {
_doctorId = doctorId;
_date = date;
_session = session;
_slotIndex = slotIndex;
_status = SlotStatus.Available;
}
// 唯一允许的状态变更方法:挂号占用
public void BookFor(RegistrationId registrationId) {
if (_status != SlotStatus.Available)
throw new DomainException($"号源{_doctorId}在{_date} {_session}已不可用");
_status = SlotStatus.Booked;
// 记录占用关系(值对象)
_bookings.Add(new Booking(registrationId, DateTime.UtcNow));
}
// 释放号源:需审批,且只能由管理员调用
public void ReleaseBy(AdminId adminId, string reason) {
if (_status != SlotStatus.Booked)
throw new DomainException("仅已占用号源可释放");
_status = SlotStatus.Released;
_releases.Add(new Release(adminId, reason, DateTime.UtcNow));
}
}
Step 3:引入值对象表达业务概念
Booking
和
Release
不是实体,而是值对象:
-
Booking:包含RegistrationId和BookedAt,无独立生命周期,随Slot存在而存在 -
Release:包含AdminId、Reason、ReleasedAt,强调“谁、为何、何时”释放
值对象的关键特性是
相等性基于属性值而非ID
。两个
Booking
对象,只要
RegistrationId
和
BookedAt
相同,就是同一个
Booking
——这完美契合业务语义:一次挂号占用,只应被记录一次。
3.3 第三阶段:实现领域服务与应用服务,划清各层职责
建模完成后,真正的挑战是:如何让这些漂亮的领域模型,在真实系统中跑起来?我们严格遵循DDD分层:
领域层(Domain Layer)
- 包含所有聚合根、值对象、领域服务(无状态)
- 绝不依赖任何外部框架 (无EntityFramework、无HttpClient)
- 所有业务规则在此层完成验证
应用层(Application Layer)
- 协调多个领域对象完成用例
-
处理事务边界(如挂号需同时创建
Registration和占用Slot) - 调用领域服务,但不包含业务逻辑
基础设施层(Infrastructure Layer)
- 实现Repository(用EF Core或Dapper)
- 发送领域事件(用RabbitMQ或内存总线)
- 外部API调用(如对接医保平台)
以“患者挂号”用例为例,应用服务代码如下:
public class RegistrationAppService {
private readonly ISlotRepository _slotRepo;
private readonly IRegistrationRepository _regRepo;
private readonly IDomainEventPublisher _eventPublisher;
public async Task<RegistrationId> RegisterPatient(
PatientId patientId,
DoctorId doctorId,
DateOnly date,
Session session) {
// 1. 获取号源(领域层:纯内存操作)
var slot = await _slotRepo.FindByDoctorAndDate(doctorId, date, session);
if (slot == null || slot.Status != SlotStatus.Available)
throw new BusinessException("号源不可用");
// 2. 创建挂号单(领域层:封装业务规则)
var registration = Registration.Create(patientId, doctorId, date, session);
// 3. 占用号源(领域层:状态变更)
slot.BookFor(registration.Id);
// 4. 保存(基础设施层:持久化)
await _regRepo.Save(registration);
await _slotRepo.Save(slot);
// 5. 发布领域事件(基础设施层:通知下游)
await _eventPublisher.Publish(new RegistrationCreated(registration.Id));
return registration.Id;
}
}
这段代码的精妙之处在于:
-
事务控制在应用层
:
_regRepo.Save和_slotRepo.Save在同一个数据库事务中执行,保证一致性 -
领域层零污染
:
Registration.Create()和slot.BookFor()不关心数据如何存,只专注业务规则 -
事件解耦
:
RegistrationCreated事件由基础设施层发布,挂号成功后,计费服务、短信服务可订阅此事件,无需挂号服务知道它们的存在
实操心得:我们曾为“挂号超时自动释放”功能纠结两周。最终方案是:应用服务定时扫描
Booked状态超过30分钟的Slot,调用slot.ReleaseBy()方法。注意,释放逻辑仍在领域层(ReleaseBy方法校验状态和权限),应用层只负责触发时机——这确保了业务规则的集中管控。
4. DDD落地避坑指南:那些只有踩过才懂的血泪教训
4.1 常见问题速查表:从建模到部署的典型故障点
| 问题现象 | 根本原因 | 解决方案 | 我的实测效果 |
|---|---|---|---|
| 领域模型编译通过,但单元测试覆盖率不足60% | 模型中混入了基础设施代码(如直接调用HttpClient)或空构造函数导致状态不可控 | 强制所有聚合根使用全参数构造函数;领域层禁止引用任何非Domain命名空间;用Moq模拟依赖 | 测试覆盖率从52%提升至94%,且每个测试用例平均执行时间缩短300ms |
| 新增一个“处方审核”状态,导致17个类需要修改 | 违反开闭原则:状态变更逻辑分散在各处,而非集中在聚合根内部 |
将状态机提取为独立类(如
PrescriptionStateMachine
),聚合根只暴露
TransitionTo()
方法
| 新增状态只需修改状态机配置,零侵入现有代码,上线耗时从3天降至2小时 |
| 跨聚合查询性能暴跌(如“查询某医生所有已缴费处方”) | 在领域层强行JOIN多个聚合根,违背DDD分层原则 | 应用层调用多个Repository分别获取数据,在内存中组装;或建立专用查询视图(Query View) | 查询响应时间从8s降至120ms,且领域层完全无感 |
| 业务方说“这个需求很简单”,开发却要重写3个聚合根 | 限界上下文划分错误,将本该独立的子域强行合并 | 用“上下文映射图”(Context Map)重新梳理:识别共享内核(Shared Kernel)、客户-供应商(Customer-Supplier)、遵奉者(Conformist)关系 | 重构后,门诊、住院、体检三个子域完全解耦,各自迭代周期缩短40% |
| 领域事件丢失(如挂号成功但未触发短信) | 事件发布与数据库事务未绑定,或事件总线可靠性不足 |
采用“发件箱模式(Outbox Pattern)”:事件写入同一数据库事务的
outbox
表,由后台进程轮询投递
| 事件丢失率从0.3%降至0.0001%,且可追溯每条事件的投递状态 |
4.2 关于“要不要用CQRS”的残酷真相
CQRS(命令查询职责分离)常被当作DDD的“高阶装备”,但我的经验是: 90%的团队根本不该在项目初期引入CQRS。
我们曾在一个20人团队的医疗平台中,过早采用CQRS,结果:
- 查询端用Elasticsearch,命令端用PostgreSQL,数据同步延迟导致“挂号成功但查询不到”
- 开发人员需同时维护两套模型(Command Model / Query Model),学习成本翻倍
- 业务方困惑:“为什么我改了一个字,前端要等5秒才刷新?”
真正需要CQRS的信号只有两个:
- 读写比例严重失衡 (如>100:1),且查询逻辑极其复杂(需多表JOIN、全文检索、实时聚合)
- 读写一致性要求可妥协 (如报表数据允许1分钟延迟)
我们的解决方案是渐进式演进:
- 阶段1(0-12个月) :单体架构,领域层+应用层+基础设施层,用缓存优化高频查询
- 阶段2(12-18个月) :识别出瓶颈查询(如“全院处方统计报表”),为其建立独立的读模型(Read Model),通过领域事件异步更新
- 阶段3(18个月+) :当读模型数量>5个,且更新逻辑复杂时,才引入CQRS框架(如MediatR + SqlServer Change Tracking)
注意:CQRS不是银弹,而是手术刀。我见过最成功的案例,是把“患者历史就诊记录查询”从主库剥离,用MongoDB存储预计算的JSON文档。每次挂号成功,发布
RegistrationCreated事件,由后台服务解析并写入MongoDB。查询时,前端直接调用/api/patients/{id}/history,响应时间稳定在80ms内——而主库压力下降了65%。
4.3 给新手的三条硬核建议:少走三年弯路
第一条:扔掉“DDD架构图”,拿起一支笔,去听业务方吵架
我至今保留着2018年第一次门诊建模的录音。当时两位护士为“退号是否影响医生排班”争得面红耳赤。一位说:“退号后号源立刻释放,医生可接新患者!”另一位说:“不行!退号要审核,否则有人恶意占号!”——正是这场争吵,让我们意识到
Slot
必须区分
Available
和
PendingRelease
两种状态。
DDD的金矿不在技术文档里,而在业务方的日常争执中。
第二条:你的第一个聚合根,必须能独立运行并通过所有业务场景测试
不要一上来就设计
Patient
、
Doctor
、
Prescription
全家桶。选一个最小闭环:比如“挂号单创建”。用Gherkin语法写下它的所有场景:
Scenario: 挂号成功
Given 患者张三,医生李四,2023-10-01上午号源可用
When 张三挂号
Then 生成挂号单,号源状态变为Booked
Scenario: 号源已满
Given 患者张三,医生李四,2023-10-01上午号源已满
When 张三挂号
Then 抛出业务异常“号源不可用”
然后用xUnit写测试,驱动出
Registration
和
Slot
的代码。
只有当这个最小模型能跑通所有场景,你才有资格扩展。
第三条:接受“不完美”,但拒绝“不一致”
DDD不是追求一步到位的终极架构,而是建立持续演进的机制。我们现在的门诊系统,仍有3个地方不符合DDD理想:
-
Patient聚合根里还存着LastVisitDate(为查询优化) -
Prescription的药品项用的是List<string>而非List<Medicine>(历史包袱) - 部分报表仍用SQL直接查库(性能考虑)
但所有这些“妥协”,都经过团队共识,并记录在《架构决策记录(ADR)》中。
DDD的精髓不是代码的纯洁性,而是让每一次妥协都可见、可追溯、可推翻。
当某天
LastVisitDate
引发新的业务规则(如“30天未就诊患者自动降级”),我们就知道:是时候重构
Patient
聚合了。
5. 写在最后:DDD不是终点,而是你和业务之间重建信任的起点
我最后一次更新“richiezhang”这个ID,是在上周五深夜。当时正在重构“药品库存预警”模块。旧代码里,预警逻辑散落在三个服务中:采购系统检查库存下限,药房系统计算消耗速率,财务系统核算采购成本。每次业务方说“把预警阈值从100瓶改成80瓶”,开发就要改三处,测试要跑全量回归。
这次,我花了两天时间,和药剂科主任喝着茶聊了四小时。他掏出一张皱巴巴的便签,上面画着药房货架:“你看,西药房A区放抗生素,B区放慢病药,C区放急救药。每个区的预警规则根本不一样——抗生素要按月消耗量算,慢病药要看患者续方频率,急救药必须实时监控!”
那一刻我明白了:所谓“药品库存”,根本不是一个单一领域,而是三个子域的交集。于是新模型诞生:
-
AntibioticInventory(抗生素库存):聚合根含MonthlyConsumptionRate,预警基于滚动30天消耗 -
ChronicInventory(慢病药库存):聚合根含RefillFrequency,预警基于患者续方计划 -
EmergencyInventory(急救药库存):聚合根含RealTimeStock,预警基于传感器实时数据
代码还没写完,但药剂科主任已经拿着模型图去和信息科谈预算了。他说:“这次不用解释技术,我就指着这张图说:‘要支持这三个预警,得买三套监控设备。’”
这大概就是DDD最朴素的价值: 它不帮你写出更快的代码,但它让你写的每一行代码,都成为业务方能看懂的语言。 当开发不再说“这个需求技术上很难”,而是说“这个规则需要明确三个前提条件”,你就知道,那堵隔在技术与业务之间的墙,正在一块砖一块砖地倒塌。
“richiezhang”还会继续迭代。下个版本,我想试试用Rust重写核心领域模型——不是为了性能,而是想看看,当把
unsafe
标记从代码中彻底移除后,领域逻辑的纯粹性能否更锋利。如果你也正走在DDD的路上,欢迎随时来撕我的代码。毕竟,所有伟大的模型,都始于一次勇敢的推翻。

582

被折叠的 条评论
为什么被折叠?



