技术札记

用务实 DDD 构建 Docket:从业务边界到可演进的模块化单体

以 Docket 的真实代码为例,拆解专业服务协作平台如何划分有界上下文、组织 API、领域服务与数据模型,并讨论模块化单体走向更严格 DDD 的演进路径。

HOUHUIYANG.COM

扫码继续阅读

正在生成…

用务实 DDD 构建 Docket:从业务边界到可演进的模块化单体

houhuiyang.com/zh/notes/building-docket-with-pragmatic-ddd

Docket 是一个面向律师与专业服务团队的工作台,覆盖获客、材料收集、协议签署、项目交付、收费回款与客户转介绍。它看起来像一个“项目管理系统”,但真正困难的地方不是增删改查,而是让多个角色、多个阶段和多种业务规则在同一条服务链路中保持一致。

我在实现它时采用了 DDD(领域驱动设计)的核心思想,但没有机械复制教科书式的四层目录。Docket 的准确定位是:以业务有界上下文组织的模块化单体。它保留单体部署和本地事务的效率,同时用领域边界控制复杂度。

Docket 的务实 DDD 总体架构

先划分业务,再选择技术

如果按页面拆系统,很容易得到“项目页模块”“上传页模块”“后台页模块”;如果按数据库表拆,则会得到一组彼此调用的 CRUD Service。这两种方式都没有回答最重要的问题:业务能力的边界在哪里?

Docket 先把核心业务划为若干有界上下文:

上下文核心职责代表对象
Identity账号、认证、套餐身份Lawyer、LoginLog
Lead线索、跟进、转化LeadEntry、LeadFollowUp
Project项目生命周期与协作主线Project、ProjectItem
Collection客户上传、审核、签收DocumentFile、ReviewService
Agreement多方协议与签署流程ProjectAgreement、AgreementSigner
Delivery证照与成果交付Delivery、DeliveryPhoto
Finance / Billing收费回款、套餐与用量FeeRecord、PaymentEntry、CoinAccount
Profile / Portal公开获客页与客户门户LawyerProfile、PortalProjectService
Notification邮件、提醒与任务记录NotificationLog

这不是一次性完成的分类。边界来自业务语言和变化原因:材料审核规则变化不应迫使身份模块一起修改;签署状态机与项目状态有关,但不等于项目本身;通知是多个领域共享的支撑能力,却不应拥有项目决策权。

Docket 有界上下文及其协作关系

代码中的三层职责

后端由 Flask 应用工厂统一装配,主要形成三个可观察层次。

第一层是 app/api/*_api.py。Blueprint 负责 HTTP 协议:解析参数、验证身份、调用服务、序列化响应。路由不应该决定“项目何时完成”或“协议能否签署”。这样,同一业务能力未来可以被后台任务、CLI 或其他接口复用。

第二层是 app/{domain}/services.py。这里承载用例编排和业务规则。例如材料域处理上传验证、批次与审核;协议域管理邀请、拒签、完成和结果 PDF;门户域将多个上下文的数据组织成客户视图。Service 是系统当前最接近应用层与领域层合并体的部分。

第三层是 app/{domain}/models.py。SQLAlchemy 模型保存实体状态、关系和部分枚举语义;MySQL、邮件、文件、定时任务和区块链存证则提供基础设施能力。

一次典型请求如下:

Next.js 页面
  → Flask Blueprint:认证与参数
  → Domain Service:执行业务用例
  → ORM Model / db.session:读取并提交状态
  → Notification / File / Scheduler:触发支撑能力
  → JSON 响应

依赖方向的约束很简单:页面不直接理解数据库,API 不复制业务规则,领域模块不依赖前端展示细节。

聚合不是“有关联的表放在一起”

Project 是 Docket 最明显的聚合入口。外部通过 public_id 找到项目,内部整数主键只用于关联;ProjectItem 和 DocumentFile 作为协作链路中的子对象,由服务在明确的权限和状态约束下改变。

这种设计解决了两个问题。其一,对外标识与存储标识分离,URL 不暴露业务规模,也便于跨环境引用。其二,状态变化必须经过用例,而不是任意控制器直接更新字段。

无登录场景则使用独立 token:客户收集链接的 client_token、签署人的 signer_token 既是定位信息也是能力凭证,不能与需要 JWT 保护的 public_id 混用。这是领域安全规则,而不只是 URL 风格。

跨域协作如何保持可控

一个真实流程会跨越多个上下文:线索转为客户和项目;客户上传材料;律师审核;协议完成;成果交付;系统邀请评价并沉淀转介绍。

Docket 当前采用单体内的显式 Service 调用与同库事务。它的优势是部署简单、调试直接,对早期产品尤其重要。需要发送邮件、生成 PDF 或写入存证时,则通过 Notification、任务函数或队列式组件把副作用从主流程中分离。

这里的关键不是禁止跨域调用,而是明确谁拥有规则。例如“协议是否完成”由 Agreement 决定;Project 可以消费完成结果,但不应自己重写签署判断。跨域传递的是结果和标识,而不是共享一段散落在路由中的判断逻辑。

为什么没有一开始就上微服务

有界上下文不等于微服务。Docket 的各业务域共享一个 Flask 进程和 MySQL 数据库,这让涉及项目、材料和通知的操作可以使用本地事务,也避免了分布式调用、消息一致性和运维平台的过早成本。

模块化单体的前提是边界真实存在:目录独立、入口清晰、数据所有权可解释。如果模块只是换了文件夹却随意互相导入模型,它最终仍会退化成“大泥球”。因此是否拆服务,应由独立扩缩容、故障隔离、团队自治或合规隔离等实际压力决定,而不是由模块数量决定。

这套实现并不是“纯 DDD”

真实架构需要诚实描述。Docket 的 Service 直接使用 SQLAlchemy 模型和 db.session,领域对象同时也是持久化对象;部分服务承担了序列化和跨域查询;跨模块依赖也尚未全部通过端口、领域事件或仓储抽象隔离。

这是有意识的工程取舍:在业务高速变化期,先获得清楚的语言和模块边界,比提前维护大量 Repository 接口、DTO 映射与消息设施更有价值。但随着复杂度增长,可以按风险逐步演进:

  1. 把关键状态机和不变量从大型 Service 移入领域对象或独立 Domain Policy。
  2. 为文件存储、邮件、AI、区块链等外部能力定义 Port,并在基础设施层实现 Adapter。
  3. 用应用层 DTO 隔离 API 表达与 ORM 模型,避免序列化规则渗入领域服务。
  4. 对“协议完成”“项目签收”“线索转化”等事实引入领域事件,并用 Outbox 保证提交与投递一致。
  5. 建立模块依赖检查,只允许公开入口,阻止跨域直接修改他方数据。
  6. 只有当运行边界真的不同,再把成熟上下文拆成独立服务。

如何验证边界是否有效

架构图不是验收标准,变化成本才是。一个边界有效,通常能通过三个问题:修改一个领域规则时,影响是否主要停留在该模块;一次业务动作是否只有一个明确入口;失败后能否判断是哪个上下文、哪条用例、哪个副作用出了问题。

测试也应围绕这些边界组织:领域规则测试覆盖状态转换和不变量;Service 测试覆盖用例与事务;API 测试覆盖认证、参数和响应契约;少量端到端测试覆盖从获客到交付的关键旅程。

最后的判断

DDD 的价值不在目录名,而在让代码结构跟业务结构保持一致。对 Docket 来说,最重要的不是是否拥有 domain/application/infrastructure 三个文件夹,而是团队能否清楚回答:谁拥有项目状态,谁决定材料合格,谁确认协议完成,谁负责交付,跨域事实如何传播。

先用模块化单体建立边界,再让架构随业务证据演进,是 Docket 采用 DDD 的方式。它不追求形式上的纯粹,但追求每一次复杂度增加,都能被放回一个清楚、可测试、可演进的业务模型中。

项目

返回技术札记