×

Spring Modulith 有了 .NET 版本:一个基于 ASP.NET Core 10 的模块化单体完整实践

独孤求败 独孤求败 发表于2026-06-10 09:49:04 浏览7 评论0

抢沙发发表评论

每次技术选型会上,"单体还是微服务"这个话题都能吵到天昏地暗。

选单体,怕的是业务增长后变成一团面条代码,改一行代码牵一发动全身。选微服务,怕的是运维复杂度爆炸,一个简单需求要部署三个服务,出了问题排查链路长得像地铁线路图。

有没有一种架构,先用单体的简单性快速交付,同时保持模块边界的清晰,需要时又能平滑演进到微服务

有。它叫模块化单体(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;
    }
}

这个接口做了五件事:

  1. 声明身份

    我是谁,我的命名空间是什么
  2. 声明依赖

    我依赖哪些模块
  3. 声明事件契约

    我发布什么事件,订阅什么事件
  4. 注册服务

    我对外暴露什么能力
  5. 自描述

    每个模块通过 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 网关——你知道对方能做什么,但你不需要知道对方怎么做。

Spring Modulith
DotNetModulith
@ApplicationModuleListener
CAP [CapSubscribe] 异步订阅
@NamedInterface
DI 注入 IXxxService 公开接口
Architecture Tests
ArchUnitNET 架构测试

设计思想完全一致,技术实现各取所长。

图片


最硬核的部分:架构测试守护模块边界

很多项目的模块化最后都走向了"名存实亡"——一开始分得很好,但赶工期时有人偷懒直接引用了别的模块的 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);
    }
}

这条链路做了什么?

  1. TickerQ

     每 5 分钟触发一次扫描
  2. 扫描出低库存商品后,在同一个事务中更新库存状态 + 发布集成事件
  3. Notifications 模块

    订阅 LowStockDetectedIntegrationEvent,收到后发送通知
  4. 整个过程的 Span、Metric、结构化日志 通过 OpenTelemetry 自动采集

你在 OpenObserve 里可以看到完整的链路:TickerQ 触发 → 库存扫描 → 事件发布 → 通知发送,每一步的耗时、状态、异常清清楚楚。

定时任务跑在独立的 JobHost 进程中,用独立的 tickerqdb 数据库,不跟业务表混在一起。 这种物理隔离的思路,在生产环境中非常实用。

图片

技术栈速览:.NET 生态的前沿阵地

这个项目几乎把 .NET 生态里最值得关注的技术都串起来了:

领域
技术
亮点
运行时
.NET 10
最新 LTS
编排
.NET Aspire
一行命令拉起全栈环境
CQRS
Mediator.SourceGenerator
编译时生成,零运行时开销
对象映射
Riok.Mapperly
编译时映射,告别 AutoMapper 反射
消息
CAP + RabbitMQ
Outbox 模式保证可靠投递
缓存
FusionCache
L1 内存 + L2 Redis,多级缓存
调度
TickerQ
持久化任务 + Dashboard
可观测
OpenTelemetry + OpenObserve
Logs / Traces / Metrics 统一
认证
JWT + 服务端会话 + RBAC
混合方案,支持实时权限失效
测试
xUnit v3 + ArchUnitNET + Testcontainers
单元/架构/集成三层测试

注意看其中两个"编译时":Mediator.SourceGenerator 和 Riok.Mapperly。它们用 Source Generator 在编译时生成代码,完全避免了运行时反射的性能开销。这在 .NET 生态中是一个明显的趋势——能编译时做的,不要留到运行时。


认证鉴权:不是纯 JWT,而是混合方案

一个有意思的设计细节:项目的认证方案不是"纯无状态 JWT",而是 JWT + 服务端会话表 的混合模式。

为什么?因为纯 JWT 有一个致命问题——一旦签发,无法主动失效。用户退出登录、管理员禁用账号、修改密码,旧 Token 在过期前仍然有效。

DotNetModulith 的解法:

  1. 登录时写入 user_sessions 表,记录会话 ID
  2. JWT 中携带 modulith_token_version 声明
  3. 每次请求时,OnTokenValidated 从数据库实时查询角色和权限
  4. 退出登录 → 撤销会话记录
  5. 禁用用户/修改密码 → 提升 TokenVersion

两层失效机制叠加,任何一个被触发,旧 Token 立即失效。 权限变更也不需要等待 Token 自然过期——因为每次请求都会重新从数据库装载。

这不是一个教科书式的 JWT 实现,而是一个真正考虑了生产环境需求的方案。

图片

给不同角色的你

如果你是架构师:这个项目的 ModulithCore 值得细读。IModule 接口设计、ModuleRegistry 的拓扑排序和循环依赖检测、ModuleBoundaryVerifier 的模块边界验证能力——这些是可以直接搬到你项目里的基础设施。

如果你是业务开发者:看看库存模块的完整实现。从 Controller 到 Command Handler,从 Domain Entity 到 Repository,从定时任务到事件订阅——一个模块该有的东西全有了,而且每一层的职责划分都很清晰。

如果你是技术决策者:这个项目的价值在于,它证明了模块化单体在 .NET 生态中是完全可行的。你不需要一开始就押注微服务,也不需要忍受传统单体的混乱。先用模块化单体快速验证业务,等某个模块真的需要独立扩展时,再把它拆出去——边界本来就是清晰的。

写在最后

模块化单体这个概念不新。但真正把它做完整、做可落地、做到"边界不可侵犯"的项目,在 .NET 生态中确实不多见。

DotNetModulith 做的事情,用一句话概括:

它把 Spring Modulith 的设计哲学搬到了 .NET 世界,用 .NET 自己的方式重新实现了一遍,然后加上了 Aspire 编排、编译时生成、全链路可观测这些现代 .NET 的最佳实践。

不是每个项目都需要微服务。但每个项目都需要清晰的模块边界。


群贤毕至

访客