模块还在各扫门前雪:用领域事件把业务串起来

SailTrack
2026-06-01
点 赞
0
热 度
7
评 论
0
  1. 首页
  2. 后端开发
  3. 模块还在各扫门前雪:用领域事件把业务串起来

写在前面

模块化单体有一个不太好听的别名:“分布式单体在单机上的复刻”——模块划得清清楚楚,但模块之间全是直接调用,A 模块注入 B 模块的 Service,B 又注入 C 的,最后调用链绕来绕去,拆谁都得连坐。

系列之前写过模块化单体的选型,也写过用 SPI 做能力解耦。这篇说第三件武器:领域事件。它解决的是另一类问题——“这件事发生了,谁关心谁自己处理”,发布方不再需要知道消费方的存在。

一、一个具体的场景

员工入职,组织模块要做一系列事:创建账号、分配部门、记录入职。但"员工入职"这件事,组织模块自己知道的只是冰山一角。

入职之后还有谁会关心?插件系统可能要给第三方推送通知;将来做消息模块,可能要给新员工发欢迎语;做考勤模块,可能要初始化考勤账套。如果组织模块直接调用这些下游,每接一个新需求,组织模块就要改一次——它明明是"员工档案"的专家,却被迫成为"全公司业务"的调度员。

我的做法是让组织模块只负责"宣布":

// OrgManagementService:只发布,不关心谁听
domainEventPublisher.publishAfterCommit(new EmployeeHiredEvent(
    userId, orgId, employeeName, hireDate, operatorId, LocalDateTime.now()
));

一行的宣告,替代了 N 行的下游调用。组织模块的代码里从此没有任何"入职之后要干什么"的逻辑——那些逻辑各自长在各自的模块里。

二、事件本身是个契约

每个事件是一个 record,实现了统一的 DomainEvent 接口:

public record FileUploadedEvent(
    Long attachmentRefId,
    String ownerType,
    Long ownerId,
    String displayName,
    String mimeType,
    Long fileSize,
    String uploaderType,
    Long uploaderId,
    String scanStatus,
    Long operatorId,
    LocalDateTime occurredAt
) implements DomainEvent {

    public static final String EVENT_NAME = "file.uploaded";
}

几个我坚持的约定:

字段是平铺的原始类型,不塞实体对象。 事件里放的是 ownerId、displayName 这样的快照字段,而不是把数据库实体整个塞进去。实体对象会被懒加载、会被后续修改、会带着一堆消费方根本不需要的字段——事件应该是"发生时刻的一张照片",不是"原对象的引用"。

事件名是稳定契约。 file.uploaded 这种点分命名,将来跨进程(消息队列)、跨语言(插件 SDK)都用它做路由。进程内的事件如果连名字都没有,搬出进程时全要返工。

携带 occurredAt 和 operatorId。 "什么时候发生的、谁触发的"是审计和排查的底线字段,放进契约里,消费方就永远不用猜。

三、核心在"提交后"三个字

发布器是整篇文章最值得抄走的一段:

@Component
public class DomainEventPublisher {

    public void publishAfterCommit(DomainEvent event) {
        if (!TransactionSynchronizationManager.isActualTransactionActive()
            || !TransactionSynchronizationManager.isSynchronizationActive()) {
            throw new IllegalStateException("publishAfterCommit 必须在活跃事务中调用");
        }

        TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
            @Override
            public void afterCommit() {
                applicationEventPublisher.publishEvent(event);
            }
        });
    }
}

为什么非要等事务提交?推演一个事故现场就明白了:

业务方法在事务里插入了入职记录,然后发布 EmployeeHiredEvent。如果事件立即投递,监听方在同一个事务里(或另一个连接里)去查这位新员工——事务还没提交,查不到。监听方要么报错,要么"安静地什么都没做",而后者是最阴险的 bug:不报错、不重试、无声丢失。

挂到 afterCommit 之后,事件投递时数据必然已经提交,监听方永远读到确定的世界。

另一个设计点是那个 IllegalStateException:没有事务就报错,而不是悄悄降级为立即发布。 早期版本我做了"无事务时直接发布"的宽容分支,结果某个本该有事务的路径因为 @Transactional 写错了层,事件悄悄变成了"提交前发布",上面的事故原地复现。宽容是 bug 的温床——这种约束宁可炸在开发环境,不要软在生产线。

四、监听方:独立消费,错误隔离

消费方就是普通的 Spring @EventListener:

@Component
public class PluginFileUploadedEventListener {

    @EventListener
    public void onFileUploaded(FileUploadedEvent event) {
        try {
            DomainEvent pluginEvent = eventAdapter.adapt(event, EventTypes.FILE_UPLOADED, ...);
            eventDispatcher.dispatch(pluginEvent);
        } catch (Throwable e) {
            if (e instanceof VirtualMachineError error) throw error;
            if (e instanceof ThreadDeath threadDeath) throw threadDeath;
            log.warn("文件上传插件事件分发失败: attachmentRefId={}", event.attachmentRefId(), e);
        }
    }
}

监听方把"内部事件"翻译成"插件事件"再分发——这一层桥接让插件生态和领域事件解耦:内部事件字段演进时,只改适配器,插件的契约不动。

这个 catch (Throwable) 值得单独说。事件监听是"附加动作",它失败不应该把主流程掀翻——文件上传成功了,不能因为通知插件挂了就给用户报错。但 Throwable 里藏着 VirtualMachineError(OOM)和 ThreadDeath 这类不该被任何人捕获的致命错误,吞掉它们等于让 JVM 带病运行。所以先放行致命错误,再兜底记录。这段防御代码看着啰嗦,但它是"监听方错误不传染"和"不掩盖致命错误"两条原则的最小交集。

五、事件的代价,提前说清

领域事件不是免费午餐,两个代价要有心理准备:

调用链变成隐形的。 直接调用在 IDE 里点一下就能跳转,事件不行——你得全局搜"谁在监听这个事件"。缓解办法就是前面说的稳定事件名:搜索 file.uploaded 基本能一网打尽。

一致性问题被推迟而不是消除。 afterCommit 意味着主事务提交了,但监听方的处理如果失败,主流程不会回滚——这是特性也是代价。所以只有"最终一致可接受"的事情才适合做成事件:通知、推送、缓存失效都可以;"扣款"和"发货"这种强绑定动作,老老实实放同一个事务里。

我的分界线很简单:问一句"这件事晚几分钟做、甚至重做一遍,有没有人受损?"——没有,就是事件;有,就是事务内的直接调用。

六、小结

三点体会:

  1. 事件是"宣布",不是"广播命令"。 发布方只陈述事实(员工入职了、文件上传了),不指挥任何人做什么——这才叫解耦。如果事件名里带动词命令(如 SendWelcomeEmailEvent),那只是把调用链换了个语法。
  2. afterCommit 是进程内事件的生死线。 未提交数据上做的一切消费都是空中楼阁,而"无事务就报错"的强硬约束是这条生死线的护栏。
  3. 先想清楚最终一致,再上事件。 事件把"谁关心"的复杂度转移给了时间轴——能接受延迟一致的用事件,不能接受的留在事务里,别用错了方向。

现在系统里 11 类领域事件贯穿组织、考勤、文件、工作流四个模块,而发布它们的代码加起来不到一百行。模块还在各扫门前雪,只是下了雪之后,有人知道该来扫了。


让我们忠于理想,让我们面对显示

SailTrack

entp 辩论家

站长

不具版权性
不具时效性

文章内容不具时效性。若文章内容有错误之处,请您批评指正。

目录

欢迎来到SailTrack的站点,为您导航全站动态

65 文章数
11 分类数
5 评论数
60标签数