状态的演进:从混沌单体到边界清晰的模块化格局
核心观点 :状态管理的本质并非"置于何处",而是 "谁拥有写入权限、状态如何跨越边界流动、以及以何种契约触发变更" 。在此框架下,启动项(Composition Root) 是规则的声明者,DI 容器 是生命周期的执行者,而 业务边界 则决定了状态的封装粒度。
讨论边界界定:本文所指的"状态"特指运行时内存中的成员变量、静态字段或集合容器。它不等同于方法参数或数据库持久化记录,但包含持久化数据在内存中的变更快照(如对象关系映射框架中的变更追踪器)。
一、小型项目(单进程单体):主权在启动项,一切尽在眼前
在简单的控制台应用或微型 Web API 中,Program.cs 直接负责所有对象的创建(new)。此时没有容器,也无模块边界,状态管理直观而透明。
csharp
class Program
{
static void Main()
{
var config = new Config(); // 根状态
var logger = new Logger(config); // 依赖传递
var service = new Service(logger); // 链式传递
service.Run();
}
}
状态图谱(平坦结构)
text
┌─────────────────────────────────────┐
│ 启动项 (Program) │
│ 持有:Config、LoggerFactory │
│ 主权:声明权 + 创建权 + 销毁权 │
└──────────────┬──────────────────────┘
│ 构造函数传递(显式参数)
▼
┌─────────────────────────────────────┐
│ 业务类 (Service) │
│ 持有:仅局部临时变量 │
│ 主权:0%(全部从外部传入) │
└─────────────────────────────────────┘
规律与特征 :启动项是唯一的"状态工厂"。所有状态的生命周期与进程完全同步,无线程安全顾虑(单线程或简单锁机制)。此时,"治理"尚未成为问题,一切尽在掌控。状态更新机制为手动重新 new 或直接修改对象属性。
二、中型项目(分层架构):引入 DI 容器,职责分离
随着系统规模增长,我们引入分层架构(Controller → Service → Repository)并采用依赖注入(DI)容器来管理对象的创建与生命周期。此时,主权开始分化:
- 声明主权:仍归启动项(负责注册类型映射与生命周期策略)。
- 执行主权(创建、缓存、释放):移交给了 DI 容器。
csharp
// 启动项 ------ 状态规则的"宪法"(框架无关的注册示例)
var container = new DiContainer();
container.Register<IConfiguration, Configuration>(Lifetime.Singleton);
container.Register<IRequestContext, RequestContext>(Lifetime.Scoped);
container.Register<IOrderService, OrderService>(Lifetime.Scoped);
container.Register<IOrderRepository, OrderRepository>(Lifetime.Scoped);
这里不依赖任何特定 DI 框架(如 ASP.NET Core 内置容器、Autofac、Unity 等),而是采用抽象的注册与生命周期概念。启动项只需声明"何种类型在何种生命周期下由容器提供",容器则负责按此规则创建、缓存和释放实例。
澄清关键概念
对象关系映射(ORM)框架中的变更追踪器 (如 EF Core 的 ChangeTracker)维护的是持久化数据在内存中的"工作单元"快照,保存了实体对象的原始值与当前值的差异。它充当内存状态与磁盘记录之间的双向映射缓冲,其本身并非数据库中的持久化记录。
状态图谱(单向依赖链)
text
┌─────────────────────────────────────────────────────────┐
│ 启动项 (Composition Root) │
│ 声明:IConfiguration(Singleton)、各Service(Scoped)│
│ 执行权:交由 DI 容器管理 │
└────────────────────────┬────────────────────────────────┘
│ 容器依据注册表创建实例
▼
┌─────────────────────────────────────────────────────────┐
│ DI 容器 (IServiceProvider) │
│ 持有:Singleton/Scoped/Transient 对象池 │
│ 行使:实例的创建、缓存、释放 │
└─────────────────────────────────────────────────────────┘
│ │ │
▼ ▼ ▼
┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐
│ Controller层 │ │ Service层 │ │ Repository层 │
│ 不持有业务状态 │ │ 持有业务逻辑 │ │ 持有 ORM上下文 │
│ 仅接收请求参数 │ │ 依赖Repository │ │ (变更追踪器) │
└──────────────────┘ └──────────────────┘ └──────────────────┘
分层架构特征
- 状态沿单向依赖链传递(Controller → Service → Repository)。
Scoped状态的生命周期通常与一个请求或会话绑定(具体由容器策略定义)。- 配置变更可通过选项模式(如
IOptionsMonitor的抽象等价物)实现动态刷新,无需重启容器。
适用判断:当业务域单一,修改订单逻辑不影响库存逻辑(或系统中根本没有库存域)时,无边界分层架构足以应对。所有状态由 DI 统一管理,分层传递即可,无需引入额外的模块边界。
三、引入模块边界的临界点:并非依赖数量,而是依赖归属域
一个常见的误区是将"Service 构造函数超过 5 个"视为模块化缺失的警报。真正的病因与解法应重新审视:
| 症状 | 真正的病因 | 正确的解法 |
|---|---|---|
| 单个 Service 依赖超过 5 个 Repository | 类职责过重(违反单一职责原则) | 使用外观模式(Facade) 聚合内部逻辑,或采用中介者模式(Mediator) 将命令/查询拆解到多个 Handler 中。这与模块化无关。 |
同时依赖 IInventoryService、IUserService、IPaymentService,且它们各自独立变更 |
业务边界不清------一个类横跨了多个业务域 | 引入模块化。将订单、库存、用户拆分为物理或逻辑隔离的模块。 |
| 多个 Service 之间相互循环调用 | 依赖图混乱------分层架构被打破 | 识别聚合根(Aggregate Root),将网状调用收敛为星型调用;若无法解决,则需要纵向切分模块。 |
临界点的真正标志 :不在于"依赖的数量",而在于 "依赖的归属域"。当修改一行业务逻辑,被迫同时修改订单、库存、用户等多个域的核心代码时,模块边界便势在必行。
一个更直接的检测信号:如果你发现修改一个业务域的代码,导致另一个业务域的测试大面积失败,这是耦合过重的明确信号------模块化已从"可选项"变为"刚需"。此时必须用模块边界将变更隔离,确保各业务域能够独立演进。
四、大型项目(模块化架构):纵向切分与横向分层共存
核心认知 :模块化并非分层的"替代品",而是在分层之上叠加了纵向的业务边界。分层是横向切分 (技术职责:表现/业务/数据),模块化是纵向切分(业务领域:订单/库存/用户)。在大型项目中,每个模块内部依然保持着严格的分层架构。
text
┌─────────────────────────────────────────────────────────────────────────┐
│ 启动项 (Composition Root) │
│ 声明:所有模块的入口服务、跨模块共享状态(全局配置、日志工厂) │
└──────┬──────────────────────┬──────────────────────┬───────────────────┘
│ │ │
▼ ▼ ▼
┌──────────────────────┐ ┌──────────────────────┐ ┌──────────────────────┐
│ 【订单模块】 │ │ 【库存模块】 │ │ 【用户模块】 │
│ ────────────────── │ │ ────────────────── │ │ ────────────────── │
│ 公开接口(契约) │ │ 公开接口(契约) │ │ 公开接口(契约) │
│ IOrderFacade │ │ IInventoryFacade │ │ IUserFacade │
│ ────────────────── │ │ ────────────────── │ │ ────────────────── │
│ 【内部横向分层】 │ │ 【内部横向分层】 │ │ 【内部横向分层】 │
│ Controller → │ │ Controller → │ │ Controller → │
│ OrderService → │ │ StockService → │ │ ProfileService → │
│ OrderRepository │ │ StockRepository │ │ UserRepository │
│ ────────────────── │ │ ────────────────── │ │ ────────────────── │
│ 私有状态(封装) │ │ 私有状态(封装) │ │ 私有状态(封装) │
│ OrderCache │ │ StockBuffer │ │ SessionStore │
│ (写入权仅限本模块) │ │ (写入权仅限本模块) │ │ (写入权仅限本模块) │
└──────────┬───────────┘ └──────────┬───────────┘ └──────────┬───────────┘
│ │ │
└───────────────┬─────────┴─────────┬───────────────┘
│ │
┌──────────▼──────────┐ ┌──────▼──────┐
│ 同步命令(强一致) │ │ 领域事件 │
│ 调用接口 + 锁 │ │(最终一致)│
└─────────────────────┘ └─────────────┘
模块化架构核心特征
- 主权实体:每个模块对外仅暴露接口,内部私有状态(缓存、队列、连接池)完全隐藏,外界无法直接读写。
- 分层内聚:模块内部的 Service 和 Repository 遵循分层原则,但被模块边界包裹,不向外泄露。
- 交互模式:跨模块状态变更并非必须走事件,而是取决于业务需要强一致性还是最终一致性。
适用判断
- 强依赖场景 :当存在多个业务域,且彼此依赖极强(如财务总账与明细账,需要事务强一致)时,应采用模块化(逻辑边界)。在同一进程内划分程序集,模块间通过同步接口交互,保证事务的 ACID 特性。
- 弱依赖场景 :当存在多个业务域,但彼此依赖弱(如订单与用户积分,允许秒级延迟)时,应采用模块化(物理或逻辑边界)。模块间优先走领域事件,各自维护私有缓存,允许最终一致性。
五、跨模块交互的两种契约模式
模式 A:同步命令 + 内存锁(强一致性)
场景:秒杀扣库存。订单创建必须与库存扣减同步成功或失败。
机制 :订单模块调用库存模块的公开接口 TryReserveStock。库存模块内部使用并发集合 + 信号量(SemaphoreSlim)进行原子扣减,并返回布尔值。
分析:两个模块共享了"库存数量"这一状态,但库存模块通过公开接口暴露原子操作,外界无法直接触碰其私有字段。封装性未被破坏,同时保证了强一致性。
csharp
// 强一致场景:同步调用 + 模块内私有锁
public class OrderFacade {
private readonly IInventoryFacade _inventory;
public Result PlaceOrder(OrderDto dto) {
if (!_inventory.TryReserveStock(dto.Sku, dto.Qty))
return Result.Fail("库存不足");
_orderRepo.Save(order); // 同一事务
return Result.Ok();
}
}
模式 B:异步事件 + 本地副本(最终一致性)
场景:订单支付成功后,为用户增加积分或刷新报表缓存。这些操作允许秒级延迟。
机制 :订单模块发布 OrderPaidEvent,用户模块和报表模块各自订阅。收到事件后,各模块自行更新本地的私有缓存。
分析:发布事件的一方无需知道谁在监听,也无需关心监听者内部状态如何变更,实现了高度解耦。
总结 :共享内存并非禁忌,禁忌是 "跨模块直接引用对方的内脏(私有字段)"。只要通过契约化的接口(同步或异步)进行交互,封装性就得以保持。
六、模块化架构的设计基石
基石一:模块的私有状态,连"看一眼"都不允许
csharp
// ❌ 错误:跨模块直接访问私有字段
var buffer = inventoryModule._stockBuffer; // 编译错误
// ✅ 正确:通过公开方法交互
var result = inventoryModule.TryReserveStock(productId, quantity);
基石二:模块间禁止共享"可变状态"的同一引用
csharp
// ❌ 错误:两个模块持有同一个 Dictionary 引用,互相污染
public class OrderModule {
public OrderModule(ConcurrentDictionary<Guid, Order> sharedCache) { ... }
}
public class InventoryModule {
public InventoryModule(ConcurrentDictionary<Guid, Order> sharedCache) { ... }
}
// ✅ 正确:每个模块持有自己的副本,通过事件或同步回调同步变更
基石三:跨模块交互模式由"一致性"决定,不由教条决定
- 需要 ACID 强一致 → 走同步命令调用(内部可加锁),业务失败则整体回滚。
- 允许 最终一致(秒级延迟可接受) → 走异步领域事件,降低耦合。
七、一张图谱看清全局
text
┌─────────────────────────────────────────────────────────────────────────┐
│ 第1层:启动根(规则声明者) │
│ 职责:注册所有模块入口、声明全局配置、日志工厂等 │
│ 持有:仅注册表(类型映射与生命周期策略),不持有业务实例 │
└────────────────────────────────┬────────────────────────────────────┘
│ 将执行权委托给容器
▼
┌─────────────────────────────────────────────────────────────────────────┐
│ 第2层:DI 容器(生命周期执行者) │
│ 持有:Singleton/Scoped/Transient 对象池 │
│ 职责:依据注册表创建、缓存、释放状态实例 │
└───────────┬─────────────────────────────────┬──────────────────────────┘
│ │
▼ ▼
┌───────────────────────────────┐ ┌─────────────────────────────────────┐
│ 第3a层:分层架构(横向切分) │ │ 第3b层:模块边界(纵向切分) │
│ Controller → Service → Repo │ │ OrderModule │ InventoryModule │
│ 适用于单模块内部或无边界 │ │ 每个模块拥有私有状态封装 │
│ 单体应用 │ │ 模块内部依然保持分层 │
└───────────────┬───────────────┘ └───────────────┬─────────────────────┘
│ │
└──────────────交互契约──────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────┐
│ 第4层:跨模块交互契约层 │
│ 同步契约(接口调用 + 锁) ←── 强一致性场景 │
│ 异步契约(领域事件) ←── 最终一致性场景 │
└─────────────────────────────────────────────────────────────────────────┘
架构决策的终极判断标准 :架构选择与团队人数无关,只与 业务逻辑的变更耦合度 有关。判断的核心指标是 "修改一处业务意图,需要被动修改多少个其他业务域的代码"。当这个数字持续大于 0 时,模块边界就该出现在架构蓝图上了。
结语
分层与模块化,并非阶梯上的前后级,而是网格中的横纵轴。横向分层 让代码职责清晰,纵向模块让变更风险隔离。优秀的架构师,应在这个二维网格中,找到最贴合业务本质的切割线,而非盲目追随某种模式的"演进"。