BitzOrcas 用不同抽象表达三种意图:聚合内部发生了什么、模块之间承诺发布什么、通知系统需要投递什么。把它们都叫“事件”容易掩盖事务和兼容性差异。
三类契约
| 契约 | 主要边界 | 作用 |
|---|---|---|
IDomainEvent / DomainEvent | Domain | 记录聚合状态变化事实,不直接跨模块 |
IIntegrationEvent 与 owner-local Contracts 类型 | 模块契约 | 跨模块或跨进程的稳定消息契约 |
INotificationPublisher / NotificationMessage | Application port | 以 code 和参数请求通知发布,不依赖 CAP |
集成事件放在拥有该事实的模块 Contracts 项目中,例如 Identity、Tickets 或 Authorization,而不是集中到一个全局 SaaS.Contracts 包。消费者只依赖发布者的契约边界。
生命周期与事务边界
这张图解释自动领域事件桥接,而不是事务内显式集成事件发布。两条路径可以共存,但必须按业务是否需要原子 Outbox 选择,不能用同一个“事件已发布”描述掩盖差异。
领域事件生命周期
聚合通过 Entity<TId>.Raise(IDomainEvent) 保存领域事件。Handler 必须把相关实体交给 scoped IDomainEventCollector.Track(entity);收集器用强类型闭包读取和清空事件,不做运行时反射扫描。
note.Raise(new NoteCreatedDomainEvent(note.Id, note.Title));domainEvents.Track(note);一个完整聚合行为会在状态改变成功后抛出事实:
public Result Rename(NoteTitle title, IAppClock clock){ // 先保护不变量;失败时既不修改状态,也不产生事件。 if (title == Title) return Result.Failure(NoteErrors.TitleUnchanged);
Title = title; ModifyTime = clock.UtcNow; // 事件只携带稳定事实,不把整个聚合或 ORM 状态交给消费者。 Raise(new NoteRenamedDomainEvent(Id, TenantId, title.Value)); return Result.Success();}Handler 保存并 Track 同一个聚合实例。若只 Raise 而未 Track,收集器无法发现事件;若 Restore 时调用产生事件的公共行为,则可能错误重放历史事实。
当前 DomainEventDispatchPipelineBehavior 只处理成功的 Command。它在事务管道返回后收集事件,通过 IIntegrationTopicRegistry 查找编译期登记的主题,再调用 INotificationPublisher。
没有主题映射的领域事件会被跳过;这是允许纯领域事件保留在模块内部。需要跨模块传播时,应显式定义稳定集成契约,而不是依赖 CLR 类型名碰巧一致。
类型化集成事件端口
IIntegrationEventPublisher<TEvent> 让 Application 发布具体契约,而不依赖 ICapPublisher。生产适配器 CapIntegrationEventPublisher<TEvent> 优先使用 [IntegrationTopic] 指定的主题,未指定时回退到类型全名。
显式主题更适合长期契约:它不会因为命名空间重构悄悄改变。事件至少应包含稳定事件 ID、发生时间、租户标识和消费者真正需要的业务数据;不要把持久化实体直接序列化出去。
[IntegrationTopic(Topic)]public sealed record TicketOpenedIntegrationEvent( // EventId、TenantId 与发生时间支撑幂等、隔离和链路排错。 Guid EventId, string TenantId, string TicketId, string Subject, DateTimeOffset OccurredAt){ // Topic 是跨版本运维契约,不随 CLR 命名空间重构。 public const string Topic = "tickets.ticket.opened.v1";}公开载荷只包含消费者需要的稳定标量。新增可选字段通常兼容;删除字段、改变含义或重用枚举序号属于破坏性变更,应发布新版本并安排并行迁移。
通知端口
INotificationPublisher 接收 NotificationMessage(Code, Parameters)。CapNotificationPublisher 把 Code 作为 CAP topic 发布参数字典。它适用于模板化通知,不应替代强类型业务集成事件。
本地或最小组合可能注册 NullNotificationPublisher、NullIntegrationEventPublisher<T>。Null 适配器用于可选能力和测试隔离;若某条商业流程依赖消息交付,生产就绪检查必须拒绝这种组合,而不能静默接受丢消息。
选择指南
- 只描述聚合内部事实:领域事件。
- 需要其他模块可靠消费:owner-local 强类型集成事件。
- 请求通知模板或渠道投递:通知端口。
- 需要与业务写原子提交:在 CAP 感知事务中发布,不依赖提交后 best-effort 桥接。
失败语义对照
| 路径 | 失败发生时的业务状态 | 调用方应做什么 |
|---|---|---|
| 领域行为返回失败 | 状态与事件都不产生 | 返回类型化 Result |
| 事务内集成发布失败 | 业务写与 Outbox 一起回滚 | 保留异常,让事务与重试策略处理 |
| 提交后自动桥接失败 | 业务已经提交 | 告警、审计并按业务设计补偿 |
| Null 通知端口 | 取决于能力是否可选 | 可选能力显式降级;必需能力启动失败 |
| 消费者失败 | 发布方状态不回滚 | 消费者抛出,CAP 重试并记录失败消息 |
测试清单
- 聚合失败时不修改状态、不收集领域事件。
- 成功行为只 Raise 一次,Restore 不 Raise。
- Handler Track 正确聚合,成功后收集器会 Clear。
- 无 topic 的内部事件不跨模块发布。
- 显式 topic 与模块治理声明、运行时订阅保持一致。
- 事务内发布失败会回滚业务行与 Outbox。
- 提交后桥接失败不会伪装成业务回滚,并产生可观测告警。
- Null 适配器只出现在明确允许的 Edition 与环境。
源码入口
领域事件基类与收集方法位于 BitzOrcas.Domain/Entities;收集器、主题注册表和管线位于 BitzOrcas.Application;CAP 发布适配器位于 BitzOrcas.Infrastructure.Outbox.Cap/Events(独立包);IIntegrationEvent 本体在 BitzOrcas.Modularity.Governance。修改任一契约时,应同时核对发布方契约测试、消费者契约测试与商业 GA 门禁。
传输、Outbox 和消费语义见事件构建块。