- Published on
模块化单体不是微服务的妥协,而是把拆分变成一个可以延后行权的期权
- Authors

- Name
- Jack Qin
"要不要上微服务"这个问题,几乎总是被当成一个二选一来问:要么忍受单体迟早退化成大泥球,要么背上分布式系统的全部运维负担。但这个二选一本身藏着一个没人拆开看过的捆绑:它默认"模块隔离"和"独立部署"是同一件事的两面,要拿就一起拿,要不拿就都别想。
模块化单体存在的全部意义,就是把这个捆绑拆开。它说:模块隔离的好处(边界清晰、可独立演进)是一种真实的工程需求;而独立部署的代价(分布式事务、服务发现、网络分区)是一种与之正交的运维成本。 这两者没有理由必须捆在一起买。一旦你接受它们可以分开,"先要隔离、把部署拆分留作期权"就成了一个显而易见的选项。
本文以某环境监测平台的后端为样本——一个 11 模块的 .NET 10 模块化单体——但重点不在"它用了什么框架",而在拆解这个决策空间:为什么不拆微服务、模块边界靠什么从机制上强制(而不是靠君子协定)、以及这个"拆分期权"具体是怎么设计进代码里、等真要行权时怎么低成本兑现。
决策空间:四个选项,三个被否,为什么
这是一套面向矿区作业的环境粉尘监测与数据管理后端。早期它直连一套 BaaS(PostgREST + RPC 函数 + Edge Functions),随着权限、报表、定时任务、AI 描述这些需求堆上来,业务逻辑散落在数据库函数和前端里,没有明确的模块边界,也没有统一的错误模型。新后端要把这些能力用一套完整的 .NET API 层收回来。
真正约束决策的,是这几条硬条件——它们不是背景信息,而是直接砍掉选项的剪刀:
- 团队只有 1–2 人。任何需要专职运维的分布式设施都出局——不是"不想要",是"养不起"。
- 运行时锁定 .NET 10 LTS、C# 14,在
global.json里钉死。选 .NET 10 而不是 8/9,是因为 .NET 8 LTS 支持期只剩约 8 个月,刚上线就被迫升级不划算;.NET 10 给到 2028 年 11 月的 2.5 年窗口。 - 单台 PostgreSQL 15.8 实例,已有,不迁移。模块之间靠 schema 隔离,不各拆一个库。
TreatWarningsAsErrors全量开启,可空引用类型启用。
把这几条剪刀套到选项上,四个候选只剩一个能活:
| 方案 | 结论 |
|---|---|
| 微服务 | 否决 —— 对 1–2 人团队是巨大运维负担:分布式事务、服务发现、网络延迟全得自己扛 |
| 简单分层单体 | 否决 —— 没有模块边界,迟早退化成大泥球 |
| 纯 Clean Architecture | 否决 —— 对混合复杂度模块过度抽象,逼所有模块(包括只有几个字段的 CRUD)都套同样四层 |
| 模块化单体 + WQW | 选中 —— 既有模块隔离,又有单体的部署/调试简单性 |
注意"纯 Clean Architecture"为什么也被否:它和微服务是相反方向的过度。微服务在部署维度过度切分,纯 Clean 在抽象维度过度切分——逼一个三字段的 CRUD 模块也老老实实套四层。两种过度都来自同一个错误:把一种在某些场景成立的纪律,无差别地施加到所有场景。模块化单体的克制恰恰在于它承认复杂度是不均匀的——复杂模块用完整分层,简单 CRUD 用薄分层。
整体是"Web-Queue-Worker + 模块化单体"的组合,两套模式各管一件事:
flowchart TD
WQW[Web-Queue-Worker<br/>负载分离: 同步 HTTP vs 异步后台]
WQW --> Api[Api Host]
WQW --> Worker[Worker Host]
Api --> Mono[模块化单体<br/>模块边界强制<br/>每模块内部轻量 Clean Architecture]
Worker --> WMono[同样的模块结构<br/>消费者 + Quartz 定时任务]
WQW 解决"同步请求要快、后台任务可以慢"(另文详述),模块化单体解决"单进程部署但模块之间不能互相穿透"——后者是本文主题。
边界为什么必须靠编译期强制,而不是靠自觉
模块化单体最容易死的方式,不是设计得不好,而是设计得很好但守不住。"只准走 Contracts、不准引用内部实现"这种规则,如果只写在文档里,它的半衰期大约是几个迭代——某天某人赶进度,直接 import 了另一个模块的内部类,编译通过、测试通过、合并上线,边界就破了一个洞。下一个人看到这个洞,默认它是被允许的,于是又开一个。腐化是这样累积的:不是一次崩塌,而是每个调用点上一次"反正只是这一处"的妥协。
这就是为什么这套系统从第一天起就把边界规则下沉到 NetArchTest,在编译期强制。11 个模块共享一次部署,但保持硬边界:
- 每个模块自己拥有
DbContext和 EF Core 迁移; - 跨模块调用只能走
.Contracts工程(纯 DTO 和事件),不能引用别的模块的内部实现; - 异步边界走消息(MassTransit)。
架构测试校验的,正是这些"靠人记一定会忘"的规则:
- 模块只能引用其他模块的
.Contracts工程; BuildingBlocks(共享内核)不依赖任何基础设施(不依赖 EF Core、不依赖 MassTransit);- Api 宿主不引用 Worker,Worker 不引用 Api;
Contracts工程没有任何内部依赖(纯 DTO)。
依赖方向是严格单向的:
flowchart TD
BB[BuildingBlocks<br/>无基础设施依赖]
Contracts[Module.Contracts<br/>DTOs, events]
Msg[Infrastructure.Messaging<br/>MassTransit 适配器]
Module[Module 内部实现<br/>Endpoints/App/Domain/Infra<br/>可引用其他模块的 Contracts,绝不引用内部]
Host[Api / Worker 宿主<br/>组合根,引用所有模块]
BB --> Contracts
BB --> Msg
Contracts --> Module
Module --> Host
把这条放到更一般的层面:任何"靠纪律维持"的架构约束,本质上是一条只活在文档里的契约,而文档约束会随系统增长稳定地腐烂。 能拦住腐烂的,从来不是更醒目的注释,而是一个会在 CI 里变红的测试。NetArchTest 在这里扮演的就是边界的守门人——谁想穿透模块,构建直接挂。这一点是模块化单体能不能长期不退化的分水岭,没有之一。
共享内核为什么要刻意"什么都不依赖"
BuildingBlocks 被 Api 和 Worker 同时引用,但它里面只有抽象,没有任何具体技术:
| 抽象 | 作用 |
|---|---|
Result<T> | 统一返回类型:成功+数据 或 失败+错误。用它代替"预期内失败"的异常 |
ICommandDispatcher | 发送命令,调用方不需要知道是进程内还是经 RabbitMQ |
IEventPublisher | 发布集成事件给所有订阅者,模块永远不直接 import MassTransit |
IIntegrationEvent | 跨模块/跨宿主事件的标记接口 |
IScraperApiClient | 调用外部抓取系统的 HTTP 客户端抽象 |
"刻意不依赖基础设施"听起来像洁癖,但它换来的是三件具体的、可以指着说的好处:
- 可单测:直接 mock
ICommandDispatcher/IEventPublisher,测试不需要拉起 RabbitMQ; - 传输可换:想把 MassTransit 换成别的中间件,只改
Infrastructure.Messaging一个工程; - 模块安全:任何模块引用
BuildingBlocks都不会意外把 EF Core 或 RabbitMQ 拖进来。
第三条是和上一节的边界强制咬合的——如果共享内核自己就拖着 EF Core,那"模块不许碰基础设施"这条规则从地基上就漏了。所以这对应一个通用原则:基础设施放在边缘(Infrastructure at the edges),核心抽象保持纯净。 纯净不是为了好看,是为了让边界规则在地基处就成立。
CQRS-lite 和那个被点名的反模式
不是完整 CQRS,没有事件溯源,所以叫"lite":
- 命令改状态,走
ICommandDispatcher(跨模块时经 RabbitMQ 异步); - 查询是同步 HTTP,复杂读用 Dapper 优化。
数据访问是双策略:
| 关注点 | 工具 |
|---|---|
| CRUD、事务、聚合持久化 | EF Core |
| 复杂列表、报表、统计、跨 schema 读 | Dapper |
为什么不纯 EF Core?因为有大量报表、时序聚合、跨 schema 读,纯 EF Core 写窗口函数和 pivot 会很别扭。为什么不纯 Dapper?因为需要数据库可移植性——EF Core 给写入、迁移、schema 管理提供了 provider 抽象。这里没有"哪个更好",只有"哪个工具在哪段路上更省"。
值得单独拎出来的是一个被明确否决的反模式:为什么不要 Generic Repository。 IRepository<T> 制造的是一种假抽象——它要么泄漏 ORM 细节(暴露 IQueryable<T>,那它就没真正封装什么),要么过度约束查询(封死了 ORM 本来能做的事)。两头不讨好。所以每个模块直接通过 DbContext 或领域专用查询服务定义持久化,不套一层无意义的仓储接口。这是一个很好的判据:一个抽象如果既不能完全隐藏被它包装的东西、又会限制你使用被包装物的能力,它就是负资产。
显式优于魔法:小团队里"可读"比"少打几行"值钱
每个模块有一个 ModuleRegistration.cs,提供 Add{Module}(services, config) 和 Map{Module}(app)。不做程序集扫描自动注册。 启动流程可以从上到下读下来:哪个模块被注册了、哪些路由被挂上了,一目了然。配合 Minimal API(不是 MVC Controllers),每个模块在自己的 Endpoints/ 里拥有端点,没有中央 Controllers/ 大杂烩。
这条取舍的本质是一笔时间账:程序集扫描省的是写注册代码那几分钟,代价是出问题时排查链路变长——"这个端点到底是哪注册的、为什么没生效"在扫描式注册里要翻半天,在显式注册里一眼看到。对 1–2 人团队,调试时间远比敲键盘时间稀缺。把"魔法"换成"可读",省的那几行根本不值一提。
工程布局与模块内部结构
flowchart TD
Root[apps/api/]
Root --> Props[Directory.Build.props / Directory.Packages.props]
Root --> Src[src/]
Root --> Tests[tests/]
Src --> BB2[BuildingBlocks 共享内核,仅接口]
Src --> Msg2[Infrastructure.Messaging]
Src --> ApiH[Api 组合根]
Src --> WorkerH[Worker 组合根]
Src --> Modules[Modules/ 每模块一对工程]
Tests --> Arch[Architecture: NetArchTest 边界强制]
Tests --> Hosts[Hosts: Testcontainers 集成测试]
Tests --> ModTests[Modules: 模块测试]
每个模块内部是"轻量 Clean Architecture"——复杂模块用完整分层,简单 CRUD 不强行套:
Modules.{Name}/
├── ModuleRegistration.cs # Add{Name} + Map{Name}
├── Endpoints/ # Minimal API 端点组(只做 HTTP 映射)
├── Application/
│ ├── Commands/ # 写侧用例
│ └── Queries/ # 读侧(复杂的;简单读直接留在端点里)
├── Domain/ # 实体、值对象、领域事件
└── Infrastructure/
├── Persistence/
│ ├── {Name}DbContext.cs # .HasDefaultSchema("{schema}") 做隔离
│ └── Migrations/
└── Queries/ # Dapper 查询服务
Application/Queries 旁边那句"简单读直接留在端点里",就是"轻量"的全部含义:不为一个三字段查询硬造一个 Query handler。
schema-per-module,以及一条务实的破例
单实例 PostgreSQL,靠 schema 隔离模块:
| 模块 | schema |
|---|---|
| Monitoring | monitoring |
| FlowMeters | flow_meters |
| TankManagement | tanks |
| Identity | auth |
| Geospatial | geospatial(需 PostGIS) |
| ... | ... |
DbContext 用 UseNpgsql().UseSnakeCaseNamingConvention(),PascalCase 实体自动映射 snake_case 列。迁移按模块各自存放。
破例在这里:跨模块的只读 Dapper 查询是允许的(只读读模型,不产生写耦合)。比如热力图模块为了出报表,可以 Dapper 查粉尘监测模块的表。判断标准一句话——读模型 OK,写耦合不行。 这条破例不是边界的漏洞,而是边界的精确定义:边界要挡的是"写"造成的耦合,不是"读"。把读也一刀切死,只会逼出一堆纯粹为了搬数据而存在的事件往返。
测试边界,而不是测试内部
- 架构测试(NetArchTest)—— 编译期强制依赖规则;
- 集成测试(Testcontainers)—— 拉起真实 PostgreSQL + RabbitMQ,
CustomWebApplicationFactory包住完整 ASP.NET Core 管道。坚持用真实基础设施而不是内存 fake,因为 EF 内存提供器的行为和真实 PostgreSQL 不一样,内存 fake 测出来的"绿"是假绿; - HTTP 契约测试 —— 校验端点是否符合 OpenAPI schema,CI 里检测漂移;
- 模块测试 —— 领域逻辑和 Dapper 查询,尽量与基础设施隔离。
那个期权,是怎么真的留在手里的
回到开头那个"拆分期权"的说法。期权不是一句口号,它必须在代码里有实际兑现路径才算数,否则就是自欺。这套设计里,期权的兑现路径是这样的:模块边界从一开始就考虑了"以后想拆怎么拆"——任何模块都能被抽成独立服务。届时 .Contracts 工程直接变成该服务的对外 API 契约,而消息本来就走 RabbitMQ。
关键在这一句:今天的进程内调用和未来的跨服务调用,在模块代码看来是同一个 ICommandDispatcher 接口。也就是说,拆分时模块的业务代码几乎不用改——变的是 ICommandDispatcher 背后的实现从"进程内分发"换成"跨网络分发",而这个实现被关在 Infrastructure.Messaging 一个工程里。
这就是期权的全部价值:你先享受单体的简单(一次部署、好调试、无分布式事务),把"拆分成微服务"这个昂贵的动作留作一个可以延后、可以低成本行权的期权,等真有扩展压力时再付那笔钱——而不是在不确定要不要扩展的时候就提前付。 微服务的代价不是不付,是延后到证据出现之后再付。
适用边界:诚实标注甜区在哪、坑在哪
模块化单体在这个项目上几乎是教科书最优解——用一次部署的运维成本,拿到模块隔离的工程收益。但它不是万灵药,有几条明确的越界信号:
- 团队足够大、且子系统有独立的伸缩/发布节奏:这时微服务的运维代价才换得回收益,模块化单体的"单次部署"反而成了瓶颈——多个团队被迫共享一个发布管道,互相阻塞;
- 纯粹的简单 CRUD、且确定不会长大:连模块拆分都多余,一个分层单体足够,11 个模块是过度工程;
- 强一致的分布式事务是核心需求:模块化单体靠最终一致(Outbox + 消息)解耦,如果业务本质上需要跨模块强一致事务,这套异步模型会别扭——你会发现自己在和它对抗。
模块化单体的甜区是:业务有清晰的领域边界、团队不大、且对"未来要不要拆"存在真实的不确定性。 注意最后那个条件——如果你确定永远不拆,那期权一文不值,直接分层单体;如果你确定马上要拆,那直接上微服务。模块化单体卖的恰恰是不确定性的定价:它在你还不知道未来的时候,让你用很低的成本把选择权握在手里。这个监测平台正好落在甜区中央——领域宽、团队小、未来朦胧。