Modular Monolith跨模块通信读写场景实现咨询
ASP.NET Core 模块化单体跨模块读写通信实现方案
核心规则的本质是每个模块独占自身领域模型与对应数据库表的写入权限,外部模块禁止直接注入其他模块的DbContext、操作其他模块的表,所有跨模块交互必须走模块公开的契约,这样后续拆分微服务时,仅需替换契约的底层实现,无需改动上层业务逻辑。
针对你提到的Security模块DisableUser端点场景,结合Unit of Work(工作单元)模式与后续微服务拆分需求,有两种可落地的实现方式:
方案1:同步接口契约调用(强一致性场景适配)
这种方式适合需要事务原子性的强一致性需求,实现成本最低,完全符合单库阶段的模块化单体开发节奏。
- 先在所有模块可引用的公共契约层(仅存放接口、事件DTO,不包含任何业务实现)定义各模块对外暴露的业务接口:
// User模块对外公开的业务契约 public interface IUserDomainService { Task MarkUserAsDisabledAsync(Guid userId, CancellationToken ct); } // Product模块对外公开的业务契约 public interface IProductDomainService { Task MarkUserProductsAsDisabledAsync(Guid userId, CancellationToken ct); } - 各模块在内部实现契约接口,注意实现类标记为
internal,仅在模块自身的依赖注入扩展方法中注册,避免外部模块直接访问内部实现:
Product模块的实现逻辑同理,仅操作自身的ProductDbContext,完成关联商品的状态标记,不独立提交变更。// User模块内部实现,仅注入自身模块的UserDbContext internal class UserDomainService : IUserDomainService { private readonly UserDbContext _dbContext; public UserDomainService(UserDbContext dbContext) => _dbContext = dbContext; public async Task MarkUserAsDisabledAsync(Guid userId, CancellationToken ct) { var user = await _dbContext.Users.FindAsync(new object[] { userId }, ct); if (user == null) throw new KeyNotFoundException("指定用户不存在"); user.IsDisabled = true; // 此处不直接调用SaveChanges,变更交给统一UoW跟踪 } } - 实现共享工作单元:单库部署阶段,所有模块的DbContext共享同一个数据库连接与事务,UoW统一协调所有DbContext的变更提交与事务回滚。
- Security模块的端点逻辑完全基于契约调用,不直接依赖其他模块的内部实现:
// DisableUser端点处理逻辑 public static async Task<IResult> HandleDisableUserRequest( Guid userId, IUserDomainService userService, IProductDomainService productService, IUnitOfWork unitOfWork, CancellationToken ct) { await using var transaction = await unitOfWork.BeginTransactionAsync(ct); try { // 按业务顺序调用各模块公开的操作方法 await userService.MarkUserAsDisabledAsync(userId, ct); await productService.MarkUserProductsAsDisabledAsync(userId, ct); // 统一提交所有模块的变更,事务保证原子性 await unitOfWork.SaveChangesAsync(ct); await transaction.CommitAsync(ct); return Results.Ok(); } catch { await transaction.RollbackAsync(ct); throw; } }
后续拆分微服务时,仅需将IUserDomainService、IProductDomainService的本地实现替换为HTTP/Grpc远程调用实现,Security模块的业务逻辑无需任何修改。
方案2:内存事件总线驱动(最终一致性场景适配,天然支持后续切换消息代理)
如果业务允许最终一致性,想提前适配微服务的事件驱动架构,可采用内存总线实现跨模块通信,耦合度更低。
- 公共契约层定义业务事件:
public record UserDisabledEvent(Guid UserId, DateTimeOffset DisabledTime); - Security模块仅需调用User模块的禁用用户接口,无需感知Product模块的存在
- User模块完成自身用户状态标记、UoW提交成功后,向内存总线发布
UserDisabledEvent - Product模块订阅
UserDisabledEvent,收到事件后执行自身模块内关联商品的禁用逻辑 - 为避免事务提交成功但事件丢失的问题,可结合Outbox模式:UoW提交事务时,将待发送的事件和业务变更一同写入数据库的Outbox表,事务提交后由后台任务逐次读取事件投递到内存总线。
后续拆分微服务时,仅需将内存总线的实现替换为RabbitMQ/Kafka等消息代理的实现,事件发布、订阅的业务代码完全不需要改动。
落地注意事项
- 禁止为了开发效率直接在Security模块注入UserDbContext、ProductDbContext直接修改数据,这就是规则中明确禁止的「跨模块DB写入」,会彻底打破模块边界,后续拆分微服务时会产生巨量重构工作。
- 模块对外暴露的契约必须是有完整业务语义的操作,不要暴露通用CRUD方法(比如不要对外暴露UpdateUser这类无业务含义的方法,要暴露DisableUser、ResetPassword这类明确的业务操作)。
- 单库阶段使用共享事务是合理选择,不需要为了追求模块的物理隔离强行引入分布式事务,等模块真的拆分到独立数据库时再替换一致性方案即可。
内容的提问来源于stack exchange,提问作者Guerrilla
相关产品推荐
相关产品推荐

