- Published on
模块化单体的边界受力:数据归谁写、路由归谁管、权限放多大
- Authors

- Name
- Jack Qin
模块化单体(Modular Monolith)这个架构,它的全部价值压在一条线上:模块之间的边界。一旦这条边界被悄悄打穿,它就退化成一个"分了文件夹的大泥球"——你以为模块是独立的,改一处却波及一片,而且没有任何编译错误提醒你边界已经不在了。
这篇想拆的,不是"约定有哪些",而是这条边界在三个地方各承受什么样的受力:数据归谁写、路由归谁管、权限到底放多大。前两个是直觉上能想到的,第三个最隐蔽——一个不拥有数据的模块需要别人的一小片数据时,最自然的做法恰恰会把权限放得太大。把这三处的受力讲清楚,边界才守得住。
数据:按模块拥有,EF Core 写、Dapper 读
后端持久化遵循按模块拥有的模型:
- 每个模块拥有自己的
DbContext。 - EF Core 是主要的写入与聚合持久化机制。
- Dapper 允许用于那些在 EF Core 里难以干净表达的只读查询路径。
- 迁移放在各模块的
Infrastructure/Persistence/Migrations/下。 - 没有通用 Repository 层。
最后那条——"没有通用 Repository"——常被当成怪癖,其实它直接关系到边界受力。一个横跨所有模块的通用 Repository,本质上是在所有模块之间架了一条隐形的写访问通道:A 模块通过它就能写 B 模块的表,而这条通道不会出现在任何 using 语句里,评审时看不见,架构测试也未必拦得住。通用 Repository 不是方便,是把边界偷偷拆掉的工具。 所以这里的设计选择是宁可重复一点 CRUD,也不要这条通道。
DbContext 拥有权:每个模块在自己的 ModuleRegistration.cs 里注册自己的 DbContext。实体配置和迁移都留在拥有它的模块内。不要让一个模块依赖另一个模块的 DbContext,也不要通过共享 Repository 引入跨模块写访问。
EF Core vs Dapper 怎么选——这不是"哪个快"的问题,而是"这段操作的本质是写还是读":
| 用 EF Core | 用 Dapper |
|---|---|
| 常规 CRUD | 复杂报表与列表查询 |
| 聚合持久化 | 只读查询服务 |
| 事务性写入 | SQL 重、直接写 SQL 比硬塞 EF 更清晰的端点 |
| 模块内的实体加载与更新 |
Dapper 的规矩:保持在 query/read-model 层或类似明显的只读位置;不要把原始 SQL 散落到不相关的应用代码里;除非有非常强、且有文档记录的理由,不要用 Dapper 做写入。把写入收口到 EF Core,是为了让"谁改了状态"这件事有一个统一的、可追踪的入口——散落的 ExecuteAsync("UPDATE ...") 会让状态变更无处可查。
迁移流程(沿用仓库根流程):
- 在拥有它的模块里更新实体模型
- 从
apps/api/生成迁移 - 评审生成的迁移
- 用
dotnet ef database update应用 - 更新受影响的 Dapper 查询或契约
一个容易被忽略的宿主行为:API host 在启动时会自动应用待处理的迁移,除非开启了 Testing:SkipMigrations。这意味着 schema 改动有两个落地场景要同时想到——生产启动的自动迁移,以及测试宿主的建表逻辑。host 集成测试用 Testcontainers,会显式建表并禁用 host 的迁移循环。只想着生产、忘了测试宿主,schema 改动就会在 CI 里以意想不到的方式炸开。
路由:Minimal API,按模块组织,端点要薄
后端用按模块组织的 Minimal API:
- 每个模块在
Endpoints/下暴露端点映射扩展方法。 - 模块注册和端点映射通过
ModuleRegistration.cs串接。 - 路由分组在
/api/v1/...下。 - 授权通常在分组级要求,更细的权限检查放在 handler 内。
- 端点返回状态码和 JSON 结果,而不是 MVC controller 响应。
端点形状的标准模式:
public static IEndpointRouteBuilder MapAssetEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/v1/assets")
.WithTags("Assets")
.RequireAuthorization(); // 分组级授权
group.MapGet("/", /* ... */);
group.MapPost("/", /* ... */);
group.MapPut("/{id}", /* ... */);
group.MapDelete("/{id}", /* ... */);
return app;
}
"端点要薄"不是审美,而是边界纪律的体现。端点应该拥有:路由形状、请求绑定、权限检查、把 missing/forbidden/created/ok 翻译成 HTTP 结果。端点不应该变成跨模块捷径、共享持久化抽象或宿主级 bootstrap 逻辑的垃圾场。一旦端点开始变厚,最先溜进去的往往就是跨模块逻辑——因为端点是请求的汇聚点,"顺手在这里调一下别的模块"是最自然的诱惑。薄端点的价值在于:它让这种诱惑无处藏身。
授权与权限:授权通常从分组级 .RequireAuthorization() 起步。handler 内的权限检查用当前用户抽象和模块权限,比如 user.CanAccess(AppModules.X)、user.CanAccessSite(siteId),以及在合适处做 admin-only 分支。注意这里有两层——"已认证"和"有权访问这个站点/模块"是两回事,分组级授权只解决第一层。
被反复忽略的那个点:最小权限的跨模块端点
前两节的边界都比较直觉。真正容易出错的是这一类场景。
问题:一个不拥有数据的模块(比如移动端热力图用户)需要另一模块数据的一个窄切片(比如资产坐标)。这时有两条看似可行、实则都把权限放太大的路:给消费方完整的模块访问权——太宽,它只要坐标却拿到了整个模块;或者把数据拥有者的通用端点对消费方权限开放——也太宽,那个通用端点本是为拥有者设计的,开放给消费方意味着消费方能看到远超它需要的字段。
解法:在拥有数据的模块内铸造一个聚焦端点,用一个权限 OR 集合,恰好覆盖需要这片数据的那些消费方。拥有者的通用端点保持只限拥有者权限。
// 站点作用域,精简 DTO,权限 OR 集合:Assets 或 Heatmap。
group.MapGet("/markers", async (..., Guid siteId, CancellationToken ct) =>
{
if (!user.CanAccess(AppModules.Assets) && !user.CanAccess(AppModules.Heatmap))
return Results.Forbid();
if (!user.CanAccessSite(siteId)) return Results.Forbid();
// ... 只返回标记点相关字段的 SensorMarkerDto[]
});
通用 AssetEndpoints 和 AssetLocationEndpoints 保持各自 Assets-only 检查;只有专门的 marker 路由加了 OR-Heatmap 分支。这个设计的好处值得逐条体会,因为它们都是"最小权限"原则的具体兑现:精简 DTO + 专用路由让权限范围在调用点一目了然;通用端点即便将来精简消费方角色扩张了也依然安全;评审者在一个文件里就能看到 OR 集合,不必 grep 每个端点去拼凑权限全貌。
要强调的是,这不是用来绕过边界检查的——拥有数据的模块仍然拥有数据,路由也活在它自己的 ModuleRegistration 里。它的精髓是:跨模块取数时,权限要在数据拥有方收紧到恰好够用,而不是在消费方放宽到大而化之。
结果、错误与契约纪律
结果模式:一致地用 Results.*,常见的有 Results.Ok(...)、Results.Created(...)、Results.NotFound()、Results.Forbid()。API 特定的认证行为:未认证请求返回 401,被禁止请求返回 403,登录/拒绝访问不把 HTML 客户端从 API 路由重定向走。最后这条很关键——API 是给程序消费的,重定向式认证是给浏览器页面设计的,混进 API 路由会让客户端拿到一个它无法理解的 302。
错误处理:用集中式异常处理,而不是到处复制泛型 try/catch。宿主行为:AddExceptionHandler<GlobalExceptionHandler>() + AddProblemDetails(),从异常处理器返回经过净化的 ProblemDetails JSON。
契约纪律:返回 DTO 和契约形状,而不是 EF 实体。如果某个端点改动影响到前端或其它消费者,就把它当成契约改动,更新相关测试和生成的客户端。
反例
// 反例 1:跨模块直接写另一模块的表
public async Task Handle(...)
{
await _emailDbContext.Schedules.AddAsync(...); // ❌ 这是 Assets 模块的代码
}
// 反例 2:为新功能引入 Controller(仓库已用 Minimal API)
[ApiController]
[Route("api/v1/assets")]
public class AssetsController : ControllerBase { /* ❌ */ }
// 反例 3:认证通过就跳过站点/模块权限
group.MapGet("/sites/{siteId}/data", async (Guid siteId, /* ... */) =>
{
// ❌ 有 RequireAuthorization 不等于这个用户能访问这个站点
return Results.Ok(await query.GetSiteData(siteId));
});
// 正确:仍需 user.CanAccessSite(siteId) 检查
// 反例 4:用 Dapper 做写入,没有强理由
await connection.ExecuteAsync("UPDATE assets SET name = @name WHERE id = @id", ...); // ❌
// 写入应走 EF Core
其它要避免的:不要加横跨所有模块的通用 Repository 抽象;不要绕过模块边界直接写另一模块的表或 DbContext;不要在既有 Dapper 模式更合适时,把复杂报表查询硬塞进 EF Core;不要在 schema 改动后忘了更新 Dapper SQL;不要给 API 端点加重定向式认证行为;不要把一个端点表面铺到多个不相关模块。
落地建议
- 数据归属看模块:写谁的数据,就在谁的模块里、用谁的
DbContext。 - 写用 EF、读重用 Dapper:Dapper 只待在 query/read-model 层,且永远不用来写。
- 端点保持薄:HTTP 关注点、权限、路由本地的持久化编排——仅此而已。
- 跨模块取数用最小权限端点:在数据拥有方铸造精简 DTO + 权限 OR 集合的专用路由,别放宽通用端点。
- schema 改动要连测试一起想:既要过生产启动的自动迁移,也要过 Testcontainers 测试宿主的显式建表。
- 端点形状变了就当契约变了:更新契约测试和生成客户端。
可迁移的那一层
抛开 .NET 和 EF Core 的具体 API,模块化单体真正可迁移的认知是:边界不会被一次大重构打穿,它是被一连串"顺手"侵蚀掉的。 顺手在 A 模块写 B 的表、顺手把通用端点对别人开放、顺手用 Dapper 写一次——每一次单独看都微不足道,累积起来模块就不再是模块。
守边界的关键不在于记住所有规则,而在于一个习惯:每当你要跨过一条模块线时,停下来问一句——这片数据归谁拥有?这个权限放到了哪一侧?我是在数据拥有方收紧,还是在消费方放宽? 把约束放在数据拥有方、收紧到恰好够用,几乎是所有跨边界设计的共同正解。