CleanArchitecture 模板的 ADR-001:在 Application 层直接使用 EF Core,而不是引入仓储层
【免费下载链接】CleanArchitectureClean Architecture Solution Template for ASP.NET Core项目地址: https://gitcode.com/GitHub_Trending/cle/CleanArchitecture
本篇技术指南以本仓库(Clean Architecture Solution Template for ASP.NET Core)的 ADR-001 决策记录 为骨架,系统讲解该模板为何让 Application 层通过自有的IApplicationDbContext接口直接操作 EF Core,而不是在两者之间再插入一层仓储(Repository)。读完本文,你将理解"依赖倒置与零框架引用"的区别、仓储模式在非 DDD 场景下的真实成本,以及该模板如何在命令/查询处理器、功能测试与 Aspire 编排中落地这一决策,并能据此判断自己的项目是否适合沿用这条默认路径。
ADR-001 决策概览
| 条目 | 内容 |
|---|---|
| 文档 | docs/decisions/ADR-001-Use-EFCore-In-Application-Layer.md |
| 状态 | Accepted(已采纳) |
| 日期 | 2024-02-29 |
| 核心结论 | Application 层定义IApplicationDbContext(暴露DbSet<T>),命令/查询处理器直接使用它;Infrastructure 层实现该接口;不在两者之间引入仓储层 |
这一决策的适用前提是:模板的默认路线不预设 DDD。它回答了一个在 Clean Architecture 实践中反复出现的问题——Application 层到底该不该接触 ORM?该不该用仓储把 EF Core 藏起来?
背景:依赖方向与 ORM 的位置
Clean Architecture 的核心约束是"内层不依赖外层"。ORM 这类框架通常被归入 Infrastructure 层,于是出现一个设计岔路口:
- 方案 A:Application 层定义一个自己拥有的接口,直接通过该接口访问 EF Core;
- 方案 B:在 Application 与 EF Core 之间放一层仓储(Repository),由仓储封装所有数据访问细节。
模板选择了方案 A。原因在 ADR 的 Rationale 部分给出,下面结合源码逐一展开。
决策落地:IApplicationDbContext与ApplicationDbContext
接口由 Application 层定义并拥有
接口定义在 Application 层,见 IApplicationDbContext.cs:
public interface IApplicationDbContext { DbSet<TodoList> TodoLists { get; } DbSet<TodoItem> TodoItems { get; } Task<int> SaveChangesAsync(CancellationToken cancellationToken); }它只暴露了模板中真实存在的两个聚合入口DbSet<TodoList>、DbSet<TodoItem>以及持久化提交方法SaveChangesAsync,没有任何数据库提供程序(provider)相关的类型。
实现位于 Infrastructure 层
ApplicationDbContext.cs 在 Infrastructure 层实现该接口:
public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IApplicationDbContext { public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options) : base(options) { } public DbSet<TodoList> TodoLists => Set<TodoList>(); public DbSet<TodoItem> TodoItems => Set<TodoItem>(); protected override void OnModelCreating(ModelBuilder builder) { base.OnModelCreating(builder); builder.ApplyConfigurationsFromAssembly(Assembly.GetExecutingAssembly()); } }实现细节全部停留在 Infrastructure:它继承IdentityDbContext<ApplicationUser>以集成 ASP.NET Core Identity,实体配置通过ApplyConfigurationsFromAssembly从 Configurations 目录 自动装载,数据库提供程序的选择(SQLite / SQL Server / PostgreSQL)则由 Infrastructure/DependencyInjection.cs 中的编译期开关决定。
注册:把具体 DbContext 以接口身份注入
依赖注入的关键一步在 Infrastructure/DependencyInjection.cs:
builder.Services.AddDbContext<ApplicationDbContext>((sp, options) => { options.AddInterceptors(sp.GetServices<ISaveChangesInterceptor>()); #if (UsePostgreSQL) options.UseNpgsql(connectionString); #elif (UseSqlServer) options.UseSqlServer(connectionString); #else options.UseSqlite(connectionString); #endif ... }); builder.Services.AddScoped<IApplicationDbContext>(provider => provider.GetRequiredService<ApplicationDbContext>());ApplicationDbContext作为具体类型注册并完成数据库配置,随后以IApplicationDbContext的身份按 Scoped 生命周期对外暴露。这样 Application 层只认接口,所有数据库相关的装配都被隔离在 Infrastructure 层内部。
数据访问链路
ADR 明确给出了从处理器到数据库的完整链路:
handler -> IApplicationDbContext -> ApplicationDbContext以 CreateTodoItem.cs 的命令处理器为例,它构造TodoItem实体后直接调用_context.TodoItems.Add(...)与_context.SaveChangesAsync(...),全程没有经过任何仓储方法。查询侧同样直接面对 EF Core 的完整表达能力,例如 GetTodos.cs 中链式使用AsNoTracking()、AutoMapper 的ProjectTo<TodoListDto>()、OrderBy与ToListAsync完成投影排序。这正是 ADR 在 Consequences 中强调的"全表达力可用"——投影、Include、原生 SQL、编译查询都不需要绕过仓储接口去迁就抽象。
理由一:依赖倒置已经满足,而非"零框架引用"
这是整个 ADR 最容易被误解的地方。Application 层确实持有对 EF Core 抽象类型(DbSet<T>、IQueryable<T>)的编译期引用:
- GlobalUsings.cs 中声明了
global using Microsoft.EntityFrameworkCore; - Application.csproj 中引用了
Microsoft.EntityFrameworkCore包
但 ADR 明确指出:引用一个程序集(assembly)不等于依赖一个具体实现。依赖倒置关心的是"依赖箭头"的方向,而不是追求内层对框架引用的绝对为零。真实方向是:
IApplicationDbContext定义在 Application 层(内层);ApplicationDbContext在 Infrastructure 层实现该接口(外层实现内层契约);- Application 层不知道
ApplicationDbContext、不知道数据库提供程序、不引用任何 Infrastructure 类型。
因此箭头从 Infrastructure 指向 Application,依赖倒置成立。
理由二:仓储在此场景下只是"加了间接层,没有有意义的抽象"
ADR 对方案 B 的批评非常具体:如果为了隐藏 EF Core 而在 Application 层定义仓储接口、在 Infrastructure 层实现,那么:
- 耦合并未消失,只是被隐藏——仓储实现内部仍然使用 EF Core;
- ORM 语义会从接口里"漏出来"——"加载哪些关联实体、如何过滤、如何投影"这类意图最终会以
GetByIdWithItems、GetActiveOrderedByName之类的自定义方法形态出现在仓储接口上,本质上是对 EF Core 查询模式的复刻,却失去了 EF Core 的语法与工具链支持; - 代价是真实的——多了一层需要设计、实现、并随需求演进而持续同步的间接层,可发现性(discoverability)反而下降。
结论是:IApplicationDbContext直接暴露DbSet<T>的做法,比仓储更诚实地反映了实际发生的事情。
理由三:功能测试倾向针对真实数据库
ADR 明确采纳了"针对真实数据库做功能测试"的策略,并指出这是 Microsoft EF Core 官方推荐的测试策略 所提倡的方向(原文链接,本文不展开外部内容)。真实数据库能暴露内存假实现与 mock 仓储无法发现的问题:provider 特有的查询行为、索引冲突、事务语义、约束强制。
模板的测试工程完整落地了这条策略:
- 测试基础设施(TestApp.cs、WebApiFactory.cs)通过 Aspire 编排拉起真实数据库;
- DatabaseResetter.cs 使用Respawn在用例之间快速清空数据,按编译开关分别选择
NpgsqlConnection/SqlConnection/SqliteConnection,与生产环境的 provider 保持同构; - 功能测试直接通过
TestApp.SendAsync(...)走完整的命令/查询管线,例如 CreateTodoItemTests.cs 会真实落库并断言CreatedBy、Created、LastModified等审计字段,这些断言依赖 AuditableEntityInterceptor 在SaveChanges时写入,而它只能被真实持久化链路触发。
与此同时,纯领域逻辑与 Application 层校验的单元测试(如 Application.UnitTests 与 Domain.UnitTests)完全不需要数据库,两类测试的分工边界清晰。
拦截器与 SaveChanges 的配合
值得注意:正因为处理器直接调用IApplicationDbContext.SaveChangesAsync,Infrastructure 层注册的ISaveChangesInterceptor(见 DependencyInjection.cs)才能统一介入每次提交。例如 DispatchDomainEventsInterceptor.cs 在SavingChangesAsync阶段从ChangeTracker中收集领域事件并经IMediator.Publish派发,随后才真正提交。这验证了 ADR 的一个隐含收益:移除仓储层之后,EF Core 自身的拦截器机制直接成为横切逻辑(审计、领域事件)的落点,无需在仓储方法里手写重复样板。
理由四:ORM 可替换性是 YAGNI
"用接口包住 ORM,将来可以随时换实现"是最常见的反驳。ADR 的回应是:绿地项目一旦选用 EF Core,实践中几乎不会更换 ORM。为一个几乎不发生的场景,让整个 Application 层围绕它做抽象,是用当下的真实复杂度去解决一个理论问题。对这句话的落地印证同样在 Application.csproj:Application 层仅引用Microsoft.EntityFrameworkCore这一个数据访问包,而数据库 provider(Npgsql、SqlServer、Sqlite)全部出现在 Infrastructure 层,切换数据库根本不需要触碰 Application 代码——这也说明"框架引用"与"具体实现依赖"在分层设计里是两回事。
后果评估:更简单与更困难
ADR 对采纳决策的后果做了坦诚的权衡:
更简单(Easier):
- 处理器可以直接使用 EF Core 的完整表达力——投影、Include、原生 SQL、编译查询,无需变通或透过仓储接口泄露意图;
- 不存在需要设计、实现、并随处理器需求演化而持续同步的仓储层;
- 数据访问路径直观:
handler -> IApplicationDbContext -> ApplicationDbContext,可追踪性强。
更困难(Harder):
- 功能测试必须跑在真实数据库上,没有轻量替代品(模板用 Aspire + Respawn 承担这一成本,见上文);
- 若日后真要更换 ORM,需要改动 Application 处理器而不仅是 Infrastructure。鉴于这种情况在实践中极为罕见,这是一个被接受的取舍。
边界:什么时候这个决策不适用
ADR 明确划出了适用范围——默认模板不预设 DDD,本决策只适用于这条默认路线。如果你在 Clean Architecture 之上叠加 DDD,仓储模式反而是正确选择,但理由与"持久化抽象"无关:
- DDD 中的仓储是领域概念:定义在领域层,以聚合(Aggregate)为粒度,方法如
GetById、Save、FindByCustomer使用领域语言而非 EF Core 语言表达; - Infrastructure 层负责实现这些领域接口;
- 这与"为了把 DbContext 藏起来而在 Application 层包一层仓储"有本质区别——后者正是本 ADR 反对的做法。
本仓库的配套决策 ADR-003-MediatR-Contracts-In-Domain 与本决策一同勾勒出"模板默认风格"的边界。判断是否切换路线,关键看你的项目是否需要"富领域模型、聚合边界、领域服务、在领域内部维护不变量"——如果答案是否定的,直接使用IApplicationDbContext就是更简洁的选择;如果是肯定的,请把仓储放回领域层。
小结
ADR-001 的实质是三点:
- 依赖倒置看方向,不看引用清单:Application 层可以引用 EF Core 抽象,只要数据库 provider 与具体 DbContext 都留在 Infrastructure,依赖箭头就始终指向内层;
- 仓储不是免费的:在没有 DDD 的前提下,它只是把 EF Core 的语义换一种形态再暴露一遍,多一层间接、少一分诚实;
- 真实数据库测试是配套前提:既然 Application 直接面对 EF Core,功能测试就必须用 Aspire 拉起真实数据库、用 Respawn 管理状态,模板的测试工程已把这套成本内置。
对于模板用户而言,这意味着:添加新的命令/查询处理器时,只需注入IApplicationDbContext并直接编写 EF Core 查询,不需要创建任何仓储接口;只有当你的项目真正进入 DDD 路线时,才需要把仓储作为领域概念引入,并停止在处理器中直接使用IApplicationDbContext。
【免费下载链接】CleanArchitectureClean Architecture Solution Template for ASP.NET Core项目地址: https://gitcode.com/GitHub_Trending/cle/CleanArchitecture
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考