每次技术选型会上,"单体还是微服务"这个话题都能吵到天昏地暗。
选单体,怕的是业务增长后变成一团面条代码,改一行代码牵一发动全身。选微服务,怕的是运维复杂度爆炸,一个简单需求要部署三个服务,出了问题排查链路长得像地铁线路图。
有没有一种架构,先用单体的简单性快速交付,同时保持模块边界的清晰,需要时又能平滑演进到微服务?
有。它叫模块化单体(Modular Monolith)。
Java 世界已经有了 Spring Modulith 作为标准答案。而 .NET 生态,长期以来缺少一个对标项目。
现在有了——DotNetModulith。
https://github.com/ZUOXIANGE/dotnet-modulith
一句话启动,看看全貌
在深入架构之前,先看看这个项目跑起来有多简单:
dotnet run --project src/DotNetModulith.AppHost
就这一行命令。
.NET Aspire 会自动拉起 PostgreSQL、RabbitMQ、Redis、RustFS(S3 兼容对象存储)、OpenObserve(可观测平台)、OTEL Collector。打开 Aspire Dashboard,所有服务的状态、端点、日志一目了然。
不需要写 docker-compose,不需要手动装中间件,不需要配一堆环境变量。一条命令,完整的项目运行环境就绑定了。
这就是 .NET 10 + Aspire 的编排能力。
但这个项目真正值得深挖的,不是"怎么启动",而是"怎么组织代码"。
模块化单体:不是不分,而是分得讲究
先看项目结构:
src/
DotNetModulith.Api/ # API 主机
DotNetModulith.AppHost/ # Aspire 编排入口
DotNetModulith.JobHost/ # 定时任务宿主
DotNetModulith.ModulithCore/ # 模块注册与边界验证核心
DotNetModulith.Abstractions/ # 共享抽象与事件契约
DotNetModulith.Modules.Orders/ # 订单模块
DotNetModulith.Modules.Inventory/ # 库存模块
DotNetModulith.Modules.Payments/ # 支付模块
DotNetModulith.Modules.Notifications/ # 通知模块
DotNetModulith.Modules.Users/ # 用户模块
DotNetModulith.Modules.Storage/ # 文件存储模块
6 个业务模块,每个模块都是独立的 .NET 项目。但它们在同一个进程中运行——这就是模块化单体:物理上的单体,逻辑上的微服务。
关键在于:每个模块内部严格遵循四层架构:
Api/ # 对外接口层:Controller、Request/Response DTO
Application/ # 应用层:Command/Query Handler、事件订阅、定时任务
Domain/ # 领域层:聚合根、实体、领域事件、仓储接口
Infrastructure/ # 基础设施层:DbContext、仓储实现、EF Core 配置
这个分层不是写在文档里的"建议",而是通过架构测试强制执行的。
用代码说话:模块是怎么"注册"的
DotNetModulith 借鉴了 Spring Modulith 的核心设计——每个模块必须实现一个 IModule 接口:
publicinterfaceIModule
{
string Name { get; }
string BaseNamespace { get; }
IReadOnlyList<string> Dependencies { get; }
IReadOnlyList<string> PublishedEvents { get; }
IReadOnlyList<string> SubscribedEvents { get; }
IServiceCollection AddModuleServices(
IServiceCollection services,
IConfiguration configuration);
}
看看库存模块的实现:
publicsealedclassInventoryModule : IModule
{
publicstring Name => "Inventory";
publicstring BaseNamespace => "DotNetModulith.Modules.Inventory";
public IReadOnlyList<string> Dependencies => ["Orders"];
public IReadOnlyList<string> PublishedEvents =>
[
"modulith.inventory.StockReservedIntegrationEvent",
"modulith.inventory.StockInsufficientIntegrationEvent",
"modulith.inventory.StockReplenishedIntegrationEvent",
"modulith.inventory.LowStockDetectedIntegrationEvent"
];
public IReadOnlyList<string> SubscribedEvents =>
[
"modulith.orders.OrderCreatedIntegrationEvent",
"modulith.orders.OrderCancelledIntegrationEvent"
];
public IServiceCollection AddModuleServices(...)
{
services.AddScoped<IInventoryService, InventoryService>();
services.AddInventoryJobServices(configuration);
services.AddTransient<OrderEventSubscriber>();
return services;
}
}
这个接口做了五件事:
声明身份
我是谁,我的命名空间是什么 声明依赖
我依赖哪些模块 声明事件契约
我发布什么事件,订阅什么事件 注册服务
我对外暴露什么能力 自描述
每个模块通过 RegisterModule<T>()注册后,ModuleRegistry统一管理所有模块元数据,构建依赖图,甚至输出 Mermaid 格式的可视化
这不是约定,是类型系统级别的模块注册。新增一个模块?实现 IModule,调用 RegisterModule<T>(),系统就认识你了。
模块间通信:两条路,各有所长
模块化单体最核心的问题是:模块之间怎么通信?
DotNetModulith 给出了两条路,直接对标 Spring Modulith 的设计:
路线一:事件驱动(异步解耦)
适用场景:订单创建后通知库存预留、支付完成后更新订单状态、库存不足时触发告警。
// 在数据库事务内更新状态 + 发布事件(Outbox 模式)
await CapTransactionScope.ExecuteAsync(
_dbContext,
_capPublisher,
async ct =>
{
// 先更新业务状态
stock.LowStockAlertSentAt = DateTimeOffset.UtcNow;
await _stockRepository.UpdateAsync(stock, ct);
// 再发布集成事件(与上面的更新在同一个事务中)
await _capPublisher.PublishAsync(
"modulith.inventory.LowStockDetectedIntegrationEvent",
new LowStockDetectedIntegrationEvent(threshold, detectedAt, items),
cancellationToken: ct);
},
cancellationToken);
通过 CAP + RabbitMQ 的 Outbox 模式,事件发布与数据库操作在同一个事务中。要么一起成功,要么一起失败,不会出现"数据库写成功了但消息没发出去"的尴尬。
路线二:同步调用(模块公开 API)
适用场景:下单时实时校验库存、创建订单时预留库存——这些操作必须在同一个请求内同步返回结果。
这里的设计非常精巧:
// 被调用模块暴露公开 API 接口(public)
namespaceDotNetModulith.Modules.Inventory.Api;
publicinterfaceIInventoryService
{
Task<Result> CheckStockAsync(IReadOnlyList<CheckStockLine> lines, CancellationToken ct = default);
Task<Result> ReserveStockAsync(string orderId, string customerId, decimal totalAmount, IReadOnlyList<ReserveStockLine> lines, CancellationToken ct = default);
}
// 实现类标记为 internal sealed —— 外部模块无法直接实例化
namespaceDotNetModulith.Modules.Inventory.Application.Services;
internalsealedclassInventoryService : IInventoryService
{
// ... 实现细节对外不可见
}
调用方注入 IInventoryService 接口,永远看不到实现细节。这就像微服务之间的 API 网关——你知道对方能做什么,但你不需要知道对方怎么做。
@ApplicationModuleListener | [CapSubscribe] 异步订阅 |
@NamedInterface | IXxxService 公开接口 |
设计思想完全一致,技术实现各取所长。
最硬核的部分:架构测试守护模块边界
很多项目的模块化最后都走向了"名存实亡"——一开始分得很好,但赶工期时有人偷懒直接引用了别的模块的 DbContext,慢慢地模块边界就形同虚设。
DotNetModulith 的解法很直接:不要相信约定,要相信测试。
项目使用 ArchUnitNET 编写了一系列架构测试,在 CI 中自动执行:
[Fact]
publicvoidOrdersModule_ShouldNotReferenceOtherModuleInternals()
{
var rule = Types().That().Are(_ordersModule)
.Should().NotDependOnAny(_ordersExternalModules)
.Because("Orders should communicate with other modules only " +
"through their published API or integration events");
rule.Check(Architecture);
}
这条规则说得很清楚:Orders 模块可以引用 Inventory.Api(公开接口),但禁止引用 Inventory.Domain、Inventory.Application、Inventory.Infrastructure(内部实现)。
违反了?测试直接失败,CI 直接红了。不是 Code Review 里的一句"建议不要这样写",而是测试不过就是不过,CI 不绿就不能发布。
类似的规则还有一堆:
Domain 层不依赖 Application 层(DDD 基本规则) Domain 层不依赖 Infrastructure 层(依赖倒置) Application 层不依赖 Api 层(传输无关) Infrastructure 层不依赖 Api 层(基础设施与传输解耦)
这些规则不是写给人看的文档,而是机器执行的门禁。
一个完整的业务链路:低库存告警
来看看这些设计是怎么串起来的。以库存模块的低库存扫描定时任务为例:
publicsealedclassLowStockAlertJob
{
privatestaticreadonly ActivitySource ActivitySource =
new("DotNetModulith.Modules.Inventory");
privatestaticreadonly Meter Meter =
new("DotNetModulith.Modules.Inventory", "1.0.0");
privatestaticreadonly Counter<long> ScanCount =
Meter.CreateCounter<long>("modulith.inventory.low_stock.scan_count");
[TickerFunction("Inventory.LowStockAlertScan", cronExpression: "*/5 * * * *")]
publicasync Task ExecuteAsync(TickerFunctionContext context,
CancellationToken cancellationToken)
{
usingvar activity = ActivitySource.StartActivity("Inventory.LowStockAlertScan");
// 1. 扫描低库存商品
var lowStocks = await _stockRepository.GetLowStockAsync(
options.Threshold, options.BatchSize, cancellationToken);
// 2. 在事务中更新状态 + 发布事件
await CapTransactionScope.ExecuteAsync(
_dbContext, _capPublisher, async ct =>
{
foreach (var stock in alertCandidates)
{
stock.LowStockAlertSentAt = DateTimeOffset.UtcNow;
await _stockRepository.UpdateAsync(stock, ct);
}
await _capPublisher.PublishAsync(
"modulith.inventory.LowStockDetectedIntegrationEvent",
new LowStockDetectedIntegrationEvent(...),
cancellationToken: ct);
}, cancellationToken);
}
}
这条链路做了什么?
TickerQ
每 5 分钟触发一次扫描 扫描出低库存商品后,在同一个事务中更新库存状态 + 发布集成事件 Notifications 模块
订阅 LowStockDetectedIntegrationEvent,收到后发送通知整个过程的 Span、Metric、结构化日志 通过 OpenTelemetry 自动采集
你在 OpenObserve 里可以看到完整的链路:TickerQ 触发 → 库存扫描 → 事件发布 → 通知发送,每一步的耗时、状态、异常清清楚楚。
定时任务跑在独立的 JobHost 进程中,用独立的 tickerqdb 数据库,不跟业务表混在一起。 这种物理隔离的思路,在生产环境中非常实用。
技术栈速览:.NET 生态的前沿阵地
这个项目几乎把 .NET 生态里最值得关注的技术都串起来了:
注意看其中两个"编译时":Mediator.SourceGenerator 和 Riok.Mapperly。它们用 Source Generator 在编译时生成代码,完全避免了运行时反射的性能开销。这在 .NET 生态中是一个明显的趋势——能编译时做的,不要留到运行时。
认证鉴权:不是纯 JWT,而是混合方案
一个有意思的设计细节:项目的认证方案不是"纯无状态 JWT",而是 JWT + 服务端会话表 的混合模式。
为什么?因为纯 JWT 有一个致命问题——一旦签发,无法主动失效。用户退出登录、管理员禁用账号、修改密码,旧 Token 在过期前仍然有效。
DotNetModulith 的解法:
登录时写入 user_sessions表,记录会话 IDJWT 中携带 modulith_token_version声明每次请求时, OnTokenValidated从数据库实时查询角色和权限退出登录 → 撤销会话记录 禁用用户/修改密码 → 提升 TokenVersion
两层失效机制叠加,任何一个被触发,旧 Token 立即失效。 权限变更也不需要等待 Token 自然过期——因为每次请求都会重新从数据库装载。
这不是一个教科书式的 JWT 实现,而是一个真正考虑了生产环境需求的方案。
给不同角色的你
如果你是架构师:这个项目的 ModulithCore 值得细读。IModule 接口设计、ModuleRegistry 的拓扑排序和循环依赖检测、ModuleBoundaryVerifier 的模块边界验证能力——这些是可以直接搬到你项目里的基础设施。
如果你是业务开发者:看看库存模块的完整实现。从 Controller 到 Command Handler,从 Domain Entity 到 Repository,从定时任务到事件订阅——一个模块该有的东西全有了,而且每一层的职责划分都很清晰。
如果你是技术决策者:这个项目的价值在于,它证明了模块化单体在 .NET 生态中是完全可行的。你不需要一开始就押注微服务,也不需要忍受传统单体的混乱。先用模块化单体快速验证业务,等某个模块真的需要独立扩展时,再把它拆出去——边界本来就是清晰的。
写在最后
模块化单体这个概念不新。但真正把它做完整、做可落地、做到"边界不可侵犯"的项目,在 .NET 生态中确实不多见。
DotNetModulith 做的事情,用一句话概括:
它把 Spring Modulith 的设计哲学搬到了 .NET 世界,用 .NET 自己的方式重新实现了一遍,然后加上了 Aspire 编排、编译时生成、全链路可观测这些现代 .NET 的最佳实践。
不是每个项目都需要微服务。但每个项目都需要清晰的模块边界。