Published on

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

Authors
  • avatar
    Name
    Jack Qin
    Twitter

"要不要上微服务"这个问题,几乎总是被当成一个二选一来问:要么忍受单体迟早退化成大泥球,要么背上分布式系统的全部运维负担。但这个二选一本身藏着一个没人拆开看过的捆绑:它默认"模块隔离"和"独立部署"是同一件事的两面,要拿就一起拿,要不拿就都别想。

模块化单体存在的全部意义,就是把这个捆绑拆开。它说:模块隔离的好处(边界清晰、可独立演进)是一种真实的工程需求;而独立部署的代价(分布式事务、服务发现、网络分区)是一种与之正交的运维成本。 这两者没有理由必须捆在一起买。一旦你接受它们可以分开,"先要隔离、把部署拆分留作期权"就成了一个显而易见的选项。

本文以某环境监测平台的后端为样本——一个 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
Monitoringmonitoring
FlowMetersflow_meters
TankManagementtanks
Identityauth
Geospatialgeospatial(需 PostGIS)
......

DbContextUseNpgsql().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 + 消息)解耦,如果业务本质上需要跨模块强一致事务,这套异步模型会别扭——你会发现自己在和它对抗。

模块化单体的甜区是:业务有清晰的领域边界、团队不大、且对"未来要不要拆"存在真实的不确定性。 注意最后那个条件——如果你确定永远不拆,那期权一文不值,直接分层单体;如果你确定马上要拆,那直接上微服务。模块化单体卖的恰恰是不确定性的定价:它在你还不知道未来的时候,让你用很低的成本把选择权握在手里。这个监测平台正好落在甜区中央——领域宽、团队小、未来朦胧。