Published on

模块该合还是该分:用"共享生命周期"而不是"业务相关"做领域拆分

Authors
  • avatar
    Name
    Jack Qin
    Twitter

领域拆分的讨论几乎总是聚焦在"怎么分",但真正难的、也真正容易做错的,是它的反面——什么时候不该分、什么时候该把两个东西合起来。 而判断"该不该合"时,最常见的错误依据是"它们业务上相不相关":储罐告警和储罐配置相关吗?相关,那合一起吧。流量计和储罐相关吗?也相关,那也合?顺着"业务相关"这条线走,最后要么把所有东西揉成一团,要么在任意两个"相关"的能力之间画满跨模块调用。

问题出在判据本身。"业务相关"是一个太弱的信号——在一个完整的业务系统里,几乎任何两个能力都多少相关。真正该用的判据是另一个:它们是否共享同一个聚合根、同一个生命周期。 共享生命周期的能力(围绕同一个实体的不同侧面)该合,否则即便业务相关也该分。这个判据比"相关性"精确得多,因为它指向一个可观察的事实——它们是不是在改同一个东西的状态。

本文承接《模块化单体后端》(那篇讲"边界靠什么强制"),转而讲"边界本身怎么划"。以某环境监测平台的 11 个后端模块为样本,拆解真实拆分里的几个判断:什么该合什么该分、为什么读数类数据一律不可变、模块协作为什么分两条路、以及一个"无状态"的诊断模块为什么也算一个模块。


拆分要解的核心矛盾

这套矿区环境监测系统业务面很宽:粉尘颗粒物读数、水流量、储罐水位、设备校准、GPS 定位、气象数据、热力图、周报、邮件、用户权限……不切模块,糊成大泥球;切得太碎,制造一堆跨模块调用。拆分的核心矛盾就是这个张力——切得太粗失去隔离,切得太细制造协作地狱,要在两者之间找到那个粒度。

约束前文已述:单进程部署、单 PostgreSQL 实例靠 schema 隔离、跨模块只走 .Contracts、异步走消息。11 个模块,每个独占一个 schema(除了一个刻意无 schema 的):

#模块schema职责
1Monitoringmonitoring监测设备、PM 读数、粉尘水平、站点观测、抓取触发(核心域,数据量最大)
2FlowMetersflow_meters水流量计读数、用水量追踪
3TankManagementtanks储罐容量、补水、修正、双阈值告警
4Reportingreporting周报、AI 生成描述
5Emailemail邮件计划、模板、发件人、发送历史
6Assetsassets设备注册、校准追踪、GPS 定位
7Weatherweather气象数据缓存、站点管理
8Geospatialgeospatial(PostGIS)热力图、地理围栏、空间查询
9Identityauth用户、RBAC、组、身份认证
10Settingsconfig应用设置、矿区站点配置、特性开关
11HealthCheck(无 schema)跨模块设备/传感器健康诊断

合并的判据:共享生命周期就合,否则分

模块拆分最难的不是"分",而是"什么时候该合"。这套系统有几个明确的合并决策,每一个都能用"共享聚合根/生命周期"这把尺子量:

  • TankManagement 合并了四块:早期的 tank-alertstank-configurationcorrection-managementrefill-management 合成一个模块。理由不是"它们都跟储罐有关"(那是弱判据),而是它们共享同一个储罐生命周期——容量配置、水位监控、补水记录、修正记录、双阈值告警,本质是"储罐"这一个聚合根的不同侧面。它们都在读写同一个储罐的状态,强行拆开只会制造大量内部协作;
  • Assets 合并了资产管理和资产定位:两者共享 assets schema,注册、校准、GPS 定位围绕同一个"资产"实体的生命周期;
  • Monitoring 保持为核心域不再细分:设备、读数、粉尘水平、站点观测、道路温度数据虽多,但都紧密围绕"监测"这一核心聚合。

反过来,FlowMeters 刻意只管流量读数——储罐、校准、补水交给各自模块。判据正是生命周期:流量计的数据采集和储罐的水位管理虽然业务相关,但生命周期不同——一个是高频读数流(不断追加的事实),一个是离散的运维事件(补水、修正这样的动作)。生命周期不同,就该分,哪怕业务上挨得很近。

核心理念:合并看"是否共享同一个聚合根/生命周期",而不是看"业务上是否相关"。 相关但生命周期独立的,分;相关且生命周期共享的,合。这把尺子的好处是它可复述、可争论——"它们是不是在改同一个聚合根的状态"是个能查证的事实,而"它们相不相关"只是一种感觉。一个拆分决策能不能被复述和质疑,决定了它会不会在下一个人手里被随意推翻。


读数为什么必须不可变

领域模型里有个一致的模式:所有"读数"类数据都是不可变值对象。

  • Reading(PM 读数)、DustLevel(粉尘水平聚合)、FlowReading(流量读数)、TankLevel(储罐水位读数)、WeatherObservation(气象观测)、AssetLocation(GPS 位置)、CalibrationRecord(校准记录)—— 全部 immutable。

而聚合根(SiteTankAssetFlowMeterWeatherStation)是可变的,承载配置和状态。

这个区分不是风格偏好,而是对应一个本体论上的区别读数是"采集进来的事实",设备是"承载配置的实体"。 事实和配置在"能不能改"这件事上有根本不同的语义——一条 PM 读数代表"某时刻某传感器测到了这个值",这是一个已经发生的历史事件,改它等于篡改历史记录;而一个储罐的容量配置代表"当前的设定",它本就该随运维而更新。

对一个要做合规审计的监测系统,这个区分是硬要求:读数一旦写入就是不可篡改的历史事实,把它建模成不可变值对象,等于在类型层面就锁死了"任何人都不能改一条历史读数"。可迁移的认知是:当一个领域里同时存在"已发生的事实"和"当前的配置/状态"时,前者建模成不可变、后者建模成可变,这个区分往往直接对应业务的审计和正确性要求,不是技术洁癖。


模块协作的两条路:事件 vs 跨 schema 只读

模块之间有真实的协作需求,但走两条不同的路——而分成两条路的依据,是协作是"写"还是"读"。

集成事件(写侧协作)——发生了状态变更、需要别的模块响应时,发事件:

发布模块事件谁消费 / 做什么
TankManagementTankLevelCriticalWorker 发告警邮件给配置的收件人
AssetsDeviceCalibrationExpired触发校准提醒流程
MonitoringScrapingCompletedGeospatial 触发热力图重算
EmailEmailSent / EmailFailed审计、后续处理
IdentityUserGroupMembershipChanged权限缓存失效

事件是"我变了,谁关心谁接",发布者不知道也不关心谁在听——这是松耦合的关键。

跨 schema 只读(读侧协作)——纯报表/诊断目的的读,允许直接 Dapper 查别的模块的表:

  • Geospatial 的热力图monitoring.readings 跨 schema Dapper 查询派生而来;
  • HealthCheck 模块完全是跨 schema 只读,查 monitoring.*flow_meters.*assets.*config.* 做诊断。

判据很明确:读模型 OK,写耦合不行。 模块 A 可以为了出报表读模块 B 的表,但绝不能写模块 B 的表——写必须通过事件或命令。

为什么这条边界画在"读 vs 写"而不是"跨不跨 schema"上,值得想清楚。模块隔离要挡的根本是"耦合",而耦合主要来自写——A 直接写 B 的表,就把 A 绑死在 B 的内部 schema 上,B 一改表结构 A 就崩,这正是模块边界要防的。但读不制造这种结构耦合(A 读 B 的表,最多是 B 改 schema 时 A 的查询要跟着调,但 A 没有篡改 B 的状态、没有绕过 B 的业务规则)。所以把读也一刀切死,是把边界的目的(防写耦合)错当成了手段(禁止一切跨 schema 访问),结果是逼出一堆纯粹为了搬数据而存在的事件往返——报表需求被"严格边界"逼成事件地狱。精确的边界,要挡住真正制造耦合的东西,而不是挡住一切跨界。


HealthCheck:为什么"无状态"也算一个模块

HealthCheck 很特殊:它没有自己的 schema、没有领域模型、不拥有任何数据,全是跨 schema 的只读诊断查询(流量计超量、连续无数据、车辆零读数比例过高、PM10 低于滚动均值等)。它的阈值配置甚至归 Settings 模块所有。

为什么这也算一个模块?因为它有清晰的职责边界(设备/传感器健康诊断)和独立的 API 表面。这逼出一个对"模块本质"的澄清:模块的本质是"一组内聚的能力",不是"一组数据的所有权"。 大多数模块恰好既内聚能力又拥有数据,于是人们容易把"拥有数据"误当成模块的必要条件。HealthCheck 证明它不是——它一行数据都不拥有,但它的能力(诊断)高度内聚、有清晰的对外表面。把诊断逻辑收拢成一个模块,比让它散落在各数据模块里清晰得多。判断"该不该独立成模块",看能力是否内聚、职责是否清晰,而不是看它有没有自己的表。


一个反复出现的优化模式:把前端的多次查询收回后端

几个模块的优化记录有共同模式——把原来前端多次往返的计算挪回后端一次算完:

  • TankManagement:前端原本要发 3N 次顺序查询算当前水位(N 个储罐各查补水、修正、基准),现在后端一条 SQL 算出来;
  • Monitoring:6 个遗留 RPC 调用合并成 4 个 REST 端点;
  • Reporting:3 个独立的图表描述生成函数合并成一个带 dataType 参数的异步端点;
  • Email:变量替换逻辑从前端挪到 Worker——前端只配模板,Worker 发送时查数据源解析变量。

核心理念:N+1 式的前端多次往返,本质是把一个本该在数据层一次完成的计算,泄漏到了客户端。 这个"泄漏"的视角很有用——前端发 3N 次查询去拼一个水位,不是前端写得笨,而是后端没把"算水位"这个本该属于它的职责收回去,于是这个职责漏给了前端,前端只好用多次往返硬拼。收回后端后,前端从"编排多次查询"退化成"拿一个结果",既快又简单。看到前端在做大量编排查询、跨多次请求拼装一个结果时,往往不是前端的问题,而是某个计算职责漏到了错误的层。


实现要点

模块的标准三件套

每个模块按同一结构组织,新模块照着填:API 表面(对外 HTTP 端点)、领域模型(聚合根 + 值对象)、事件与任务(发布/消费的集成事件 + Quartz 定时任务)。

特性开关下沉到站点配置

Settings 模块的 MineSite 聚合根带一组每站点的特性开关

public sealed class MineSite
{
    public string TimeZone { get; set; } = "Australia/Perth";  // 权威时区源
    public decimal? Latitude, Longitude { get; set; }

    // 每站点特性开关
    public bool? DustLevelEnabled { get; set; }
    public bool? FlowMeterEnabled { get; set; }
    public bool? HeatmapEnabled { get; set; }
    public bool? SensorEnabled { get; set; }
    public bool? AssetLocationEnabled { get; set; }
    public bool? GeofenceEnabled { get; set; }
}

不同站点启用不同能力,靠这组开关控制。MineSite.TimeZone全系统站点级时区计算的权威来源——所有时区相关计算最终都追溯到这里,不在别处硬编码。这又是"唯一真相源"的应用:时区这种一旦散落就静默出错的东西,必须有一个权威出处。

配置归属:谁用谁拥有

有个值得记的归属调整:热力图的渲染配置(bbox 边界 + 缩放)原本在 Settings 模块,后来搬到了 Geospatial 模块。理由是"谁用谁拥有"——热力图渲染是 Geospatial 的职责,相关配置就该归 Geospatial。

这条调整防的是一个常见退化:Settings 这类模块容易变成"什么配置都往里塞"的杂物间。 杂物间的问题是它没有内聚性——里面的东西唯一的共同点是"都是配置",而"是不是配置"和模块该有的"内聚能力"无关。把配置跟着"谁使用它"走,等于给每块配置找到它真正内聚的归属。判据是:配置应该和使用它的能力放在一起,而不是和其他配置放在一起——后者按"数据类型"分类,前者按"职责"分类,模块该按职责分。


适用边界:这套粒度的甜区与失效点

换来的东西很清楚:边界划得有可复述的依据(合看共享生命周期、分看生命周期独立)、协作路径清晰(写走事件、读允许跨 schema)、读数不可变满足审计、优化方向一致(持续把 N+1 收回后端)。容易踩的坑也都对应着前面的判据被违反:

  • 过度拆分制造内部协作地狱:TankManagement 若保持四个独立模块,会有大量围绕同一个储罐的跨模块往返——共享聚合根的能力要合,别为了"模块多"而拆;
  • 跨 schema 读的边界要守住"只读":一旦允许跨 schema 写,schema 隔离就名存实亡;
  • 配置归属要明确,否则 Settings 膨胀成杂物间;
  • 不是所有模块都要有数据:HealthCheck 无 schema 无模型,但职责清晰就该独立。

什么时候这套粒度不合适:

  • 业务域很窄:整个系统就是个简单 CRUD 的话,11 个模块是过度工程,几个甚至不分就够;
  • 某模块确实要独立伸缩:当前所有模块共享一次部署,如果 Monitoring 数据量大到需独立扩展,就走《模块化单体》里"抽成独立服务"的路径——这套 .Contracts + 事件的设计正是为此预留。

甜区是:业务域宽、有清晰领域边界、团队不大、未来可能要拆部分模块。 监测平台的领域天然适合按"监测对象"(粉尘、水流、储罐、资产、气象、地理)切分,拆分粒度和领域结构高度吻合。

可迁移的那一层:领域拆分里那些看似主观的"该合该分""算不算模块""归谁所有"的问题,其实都有比"感觉相关"更硬的判据——合分看共享聚合根/生命周期、是否成模块看能力是否内聚、配置归属看谁使用。 这些判据的共同点是它们都指向可观察、可争论的事实,而不是直觉。一个拆分决策能被复述和质疑,它才经得起下一个人的手——经不起复述的边界,迟早被随手推翻。