1. 四层平台的五层MVC:从概念到落地
做.NET开发这些年,我最怕听到的不是“需求又变了”,而是“你先搭个框架”。因为框架本身没有标准答案,不同团队对同样一句话的理解可能差出十万八千里。前几天有个朋友问我,想用VS里的C# ASP.NET MVC模板做一个能模拟真实人物关系、人员档案、组织架构的系统,到底该怎么分层?我听完他的描述,脑子里冒出来八个字:四层平台,五层MVC。
这不是教科书里的标准名词,而是我这几年在做业务系统时总结出的一套实践思路。所谓“四层平台”,是指把整个系统拆成表现层、应用服务层、领域层、基础设施层四层底座;所谓“五层MVC”,是指在真正处理Http请求的这层里,把View、Controller、ViewModel、Service、Repository这五个环节串成一条清晰的链路。这套架构想要解决的,就是标题里说的那件事:让代码里的“思维语言世界”,尽量贴近现实世界中的人物对象和业务规则。
先说一个直觉。传统ASP.NET MVC项目模板默认只有Models、Views、Controllers三个文件夹,我非常喜欢它的干净,但真正跑起业务来又总觉得缺口气。Controller要负责接收请求、做参数校验、调用业务逻辑、拼装Model、返回视图,Model还要同时承载数据库结构和页面表单数据。当系统只有十几个页面的时候,一切都很美好;当系统出现部门管理、岗位管理、考勤规则、薪酬科目这些乱七八糟的业务时,Controller和Model会迅速膨胀,代码开始绕成一团浆糊。
而“四层平台的五层MVC”能解决的核心问题,就是让每一个对象都有明确的归属地,让每一段代码都能回答“我到底该住在哪一层”。
1.1 为什么是“四层平台”而不是“三层架构”
很多文章都在讲三层架构:UI层、业务层、数据访问层。这套东西没错,但它太粗了。业务层往往会被塞进一大堆与业务无关的东西,比如缓存逻辑、消息推送、导入导出、审计日志。数据访问层又容易被数据库技术绑架,今天用EF Core,明天换Dapper,后天又可能想接一个第三方API作为数据源,于是业务层被迫跟着改。
我习惯把“业务层”再拆成应用服务层和领域层。应用服务层负责编排用例,也就是“创建员工”“调整部门”“计算考勤”这些业务入口;领域层负责沉淀核心业务规则,比如“工号不能重复”“离职员工不能再安排培训”。把这些规则放进独立的领域层后,不管Web框架怎么换、数据库怎么换,核心业务规则都稳如磐石。
这时候你会得到一个四层结构:
- 表现层(Web):MVC的View和Controller,只关心HTTP请求和响应。
- 应用服务层(Application):编排业务用例,不包含具体业务规则。
- 领域层(Domain):业务实体、规则、仓储接口。
- 基础设施层(Infrastructure):数据库访问、文件存储、邮件发送等。
这四层合在一起,才是完整的系统平台。MVC只是表现层里的一个开发模式,不是整个架构的全部。
1.2 五层MVC到底拆了哪五层
传统MVC里M是Model,V是View,C是Controller。在“五层MVC”里,我并没有推翻这三个核心概念,而是把Model拆成了更细的两块:ViewModel和Entity(领域实体)。再加上Service和Repository,就变成五层:
| 层次 | 原名 | 职责 | 常见位置 |
|---|---|---|---|
| 1 | View | 展示页面、收集用户输入 | Views文件夹下的cshtml |
| 2 | Controller | 接收请求、调用服务、返回结果 | Controllers文件夹 |
| 3 | ViewModel | 面向视图的数据结构 | ViewModels文件夹 |
| 4 | Entity / Domain Model | 面向业务规则的对象 | Domain项目 |
| 5 | Repository(含Service) | 数据访问与业务编排 | Application / Infrastructure项目 |
严格说,第五层包含了Service和Repository两个角色,但在请求回路上它们是一个整体:Controller调用Service,Service调用Repository,Repository操作数据库。如果非要抠字眼,把Service和Repository也算作两个独立层,那已经是“六层”了。我为了强调“MVC内部仍然有五个关键节点”,所以统称为五层MVC。
这种拆分最大的意义在于,页面上的数据结构和数据库里的数据模型不再是同一个东西。你可以在页面上看到一个“人员列表ViewModel”包含姓名、部门、入职日期,而领域实体里面还会有工号、身份证号、状态、内部备注。两者通过映射互相转换,谁都不需要迁就谁。
1.3 这套架构和普通MVC模板的本质区别
如果只是新建一个ASP.NET MVC项目直接开写,三天后Controller里就会出现类似这样的代码:从数据库查出结果,判断某个字段,再更新另一个表,同时发一封邮件。这些代码在没有分层时写起来很爽,但后续维护会非常痛苦。因为它把“HTTP请求处理的逻辑”和“业务规则”揉在了一起。
四层平台加五层MVC后的请求链路是这样: 浏览器发起请求 -> 路由命中Controller Action -> Controller解析参数并校验ViewModel -> 调用ApplicationService -> Service调用Domain层接口 -> Repository实现真正读写数据 -> 返回结果给Controller -> Controller返回View或Json。
这条链路的每一步职责单一,遇到问题时你不需要翻遍全部代码才能定位。普通MVC模板更像是给你一张白纸,而四层平台是给你一套“带隔断的收纳箱”。白纸灵活,但时间一长就乱;收纳箱一开始需要花点心思安装,但后面放东西、找东西都明确。
我个人理解标题里的“人智数智的思维语言世界”,其实是在说人的业务认知和系统的代码实现之间需要一种共同语言。四层平台的五层MVC,就是帮助建立这种共同语言的脚手架:领域层用业务语言,表现层用页面语言,基础设施层用技术语言,层与层之间通过接口和映射翻译。这样一来,人脑中的“真实人物对象世界”和被代码模拟出来的对象世界,才能逐步对齐。
2. 在Visual Studio里创建C# ASP.NET MVC解决方案
2.1 开发环境准备与版本选择
如果你要复现这套架构,我建议直接用Visual Studio 2022社区版,选择“ASP.NET Core Web应用(模型-视图-控制器)”模板。.NET 8目前是LTS版本,稳定性和生态都挺成熟,比.NET Framework 4.8更适合新项目。需要安装的工作负载包括ASP.NET和Web开发,以及.NET SDK。
有些朋友会纠结VS和VS Code怎么选。VS Code轻量,适合跨平台开发、写脚本、调整配置;但做完整的ASP.NET MVC项目,我更推荐Visual Studio,因为它对项目模板、调试器、数据库工具、发布流程的支持都更完整。热搜里经常会看到“无法与某个地址建立连接,未能下载VS Code服务器”这类报错,那多半是远程开发场景下的下载问题,和本文的架构没有直接关系,但也能看出VS Code在某些环节并不像本地VS那么省心。
创建项目时,先选好一个解决方案名称,例如MyCompany.PersonSystem。解决方案下建议按src和tests两个目录组织,src放源码,tests放单元测试项目。在src里创建四个项目:
src/MyCompany.PersonSystem.Web src/MyCompany.PersonSystem.Application src/MyCompany.PersonSystem.Domain src/MyCompany.PersonSystem.Infrastructure2.2 解决方案目录结构与项目引用关系
四个项目之间必须遵守单向依赖,这是整个架构的底线:
- Web引用Application、Domain、Infrastructure。
- Application引用Domain。
- Infrastructure引用Domain。
- Domain不引用任何项目。
为什么允许Web直接引用Infrastructure?因为在启动项目里,我们需要注册DbContext和仓储实现。但这不代表Web里的Controller可以越过分层直接调用DbContext。Controller只能面向Application层的接口编程。
依赖关系可以看这张引用矩阵:
| 项目 | 引用方向 | 说明 |
|---|---|---|
| Web | Application、Domain、Infrastructure | 表现层,只知道接口,不知道实现细节 |
| Application | Domain | 服务层,编排领域对象 |
| Infrastructure | Domain | 仓储实现、数据库上下文 |
| Domain | 无 | 定义实体和仓储接口 |
在实际代码里,Application层不会出现Microsoft.EntityFrameworkCore的引用,不会出现SqlConnection,也不会出现HttpContext;因为它天生就不依赖这些技术。这个约束写进代码Review清单里,比口头约定靠谱得多。
2.3 命名空间约定与项目模板选择
命名空间我建议按业务模块拆,而不是按技术类型硬堆。比如Domain项目里并不是只有Entities文件夹,而是按模块分:
Domain/ Employees/ Employee.cs IEmployeeRepository.cs Departments/ Department.cs IDepartmentRepository.cs Common/ EntityBase.csApplication项目也按模块组织,每个模块放一个接口和一个实现,接口放在Application.Contracts命名空间下,实现放在Application.Services命名空间下。有人会用类库项目单独隔离Contracts,但如果项目规模不大,接口和实现放同一个项目里的不同文件夹,已经足够清晰。
Web层则保留ASP.NET MVC的传统结构:Controllers、Views、ViewModels。ViewModels按Controller的Action对应关系命名,例如EmployeeCreateViewModel、EmployeeListViewModel,避免出现一个三百字段的全局Model。
3. 五层MVC各层职责与代码设计
3.1 View层:Razor视图只负责“说话”
Razor视图应该是整套系统里最简单、最“笨”的一层。它做的事情只有两件:从ViewModel里读取数据,把HTML渲染给浏览器;接收用户提交的表单数据,映射到ViewModel上。不要在cshtml里写数据库查询,不要在cshtml里判断用户角色后执行复杂逻辑,更不要让视图直接去操作领域的实体对象。
我见过很多视图被写成了“万能展示器”,页面里又是if又是switch,还要调用静态类方法去格式化日期。这种代码不是不能跑,而是每次改样式都可能引发功能回归。正确做法是尽量把逻辑放到Controller或Service里,视图只保留简单的遍历和展示。如果确实有通用展示逻辑,封装成HTML Helper或自定义TagHelper。
视图里还应坚持使用强类型模型:
@model EmployeeListViewModel <table> <tr> <th>工号</th> <th>姓名</th> <th>部门</th> <th>入职日期</th> </tr> @foreach (var item in Model.Employees) { <tr> <td>@item.EmployeeNo</td> <td>@item.Name</td> <td>@item.DepartmentName</td> <td>@item.HireDate.ToShortDateString()</td> </tr> } </table>写一个页面之前,先问自己一个问题:这个页面要展示什么ViewModel?这个ViewModel需要哪些字段?确定了这些,再去思考Controller返回什么,Service怎么拼装。
3.2 Controller层:让Action瘦成“皮包骨”
理想的Controller Action应该非常薄:接收参数、调用服务、返回结果。如果一个Action超过二十行,基本可以判定有逻辑漏到表现层了。Controller还要处理好ModelState的校验,及时返回错误信息,但不要自己在Controller里实现“统一社会信用代码校验”这种业务规则。
下面这段代码是我在人员管理系统中常用的Controller写法:
public class EmployeeController : Controller { private readonly IEmployeeAppService _employeeAppService; public EmployeeController(IEmployeeAppService employeeAppService) { _employeeAppService = employeeAppService; } public IActionResult Index() { var employees = _employeeAppService.GetEmployees(); var viewModel = new EmployeeListViewModel { Employees = employees }; return View(viewModel); } [HttpPost] public IActionResult Create(EmployeeCreateViewModel viewModel) { if (!ModelState.IsValid) { return View(viewModel); } _employeeAppService.CreateEmployee(viewModel); return RedirectToAction(nameof(Index)); } }这里看不到任何一行SQL操作,看不到任何业务规则判断。Controller存在的意义是理解HTTP语义,比如Get、Post、Redirect,而不是处理“人员对象世界”里的业务问题。如果你发现某个Action里需要new一个DbContext,那一定是架构抽象失效了。
3.3 ViewModel与Entity:两个“世界”的翻译官
在五层MVC里,Entity是真实人物对象的代码替身,ViewModel是页面需要的数据容器。这种分离非常关键,因为业务领域里的员工对象可能包含身份证、薪资、绩效等敏感字段,但列表页只需要显示姓名、工号、部门,详情页才需要展示更多字段。
我通常会把领域实体设计成私有set,所有修改都通过方法来完成。例如员工离职,不是直接让页面提交Status字段,而是调用Employee.Deactivate()方法。这样业务规则被封装在领域对象内部,而不是散落在服务里。
ViewModel则完全是普通public属性,用于数据双向绑定,不需要包含方法。例如:
public class EmployeeCreateViewModel { [Required] [Display(Name = "工号")] public string EmployeeNo { get; set; } [Required] [Display(Name = "姓名")] public string Name { get; set; } [Required] [Display(Name = "部门")] public int DepartmentId { get; set; } [Display(Name = "入职日期")] public DateTime HireDate { get; set; } [EmailAddress] [Display(Name = "邮箱")] public string Email { get; set; } }从Entity到ViewModel的映射,字段少的时候手写就好,字段多的时候用AutoMapper也能省不少事。但要注意,不能因为使用了自动映射就忽略字段差异。比如创建员工时,页面传来的DepartmentId是int,而部门是否存在需要Service去查领域模型验证,不能直接信任输入值。
3.4 Service层:业务用例的编排者
Service是五层MVC里最容易写歪的一层。有人会把所有方法都堆在一个大服务里,比如IEmployeeService里放着GetEmployees、CreateEmployee、UpdateEmployee、ExportExcel、SendWelcomeEmail、CalculateSalary……这样Service又变成了“大杂烩”。
我更推荐按用例划分服务,至少按业务模块拆分。IEmployeeAppService只围绕员工这个聚合根提供方法:GetEmployees、GetEmployeeDetail、CreateEmployee、UpdateEmployee、DeactivateEmployee。每个方法内部,先做参数校验,然后加载领域实体,调用领域规则,最后通过仓储持久化。
一个创建员工的服务方法大致是:
public void CreateEmployee(EmployeeCreateViewModel viewModel) { var department = _departmentRepository.GetById(viewModel.DepartmentId); if (department == null) { throw new BusinessException("部门不存在"); } var exists = _employeeRepository.ExistsByEmployeeNo(viewModel.EmployeeNo); if (exists) { throw new BusinessException("工号已存在"); } var employee = new Employee( viewModel.EmployeeNo, viewModel.Name, department.Name, viewModel.HireDate, viewModel.Email); _employeeRepository.Add(employee); _unitOfWork.SaveChanges(); }注意这里没有直接操作DbContext,也没有将Http请求对象传入。Service永远面向业务语言,才能做到跨技术栈复用。
3.5 Repository层:领域模型与数据库的隔离带
仓储接口定义在Domain层,实现放在Infrastructure层。接口的语义要贴近领域,而不是贴数据库。比如IEmployeeRepository.ExistsByEmployeeNo这个方法,比IRepository .Any(x => x.EmployeeNo == no)更清晰,也更安全,因为不会让调用方写出乱七八糟的Lambda表达式。
我的Repository层通常采用泛型基类加专用接口结合的方式:
public interface IRepository<T> where T : EntityBase { T GetById(int id); IReadOnlyList<T> GetAll(); void Add(T entity); void Update(T entity); void Remove(T entity); }但要注意,不要把所有复杂查询都塞进这个泛型接口。如果某个查询只属于员工模块,就放到IEmployeeRepository里;如果某个报表统计需要多表聚合,就单独建一个IEmployeeReportsRepository,或者使用专用的查询服务。仓储不是“万能数据工具箱”,它要控制数据访问的粒度,防止IQueryable泄漏到上层。
4. 实操记录:开发一个“人员档案管理系统”
4.1 领域模型:用C#类描述真实人物对象
下面我用一个具体例子,把整个开发流程串起来。假设我们正在做一个人员档案管理系统,第一个核心模块是员工管理。在Domain项目下创建Employee实体,让它成为“真实人物对象世界”在代码里的投影。
public enum EmployeeStatus { Active = 1, Inactive = 2 } public class Employee { public int Id { get; private set; } public string EmployeeNo { get; private set; } public string Name { get; private set; } public int DepartmentId { get; private set; } public DateTime HireDate { get; private set; } public string Email { get; private set; } public EmployeeStatus Status { get; private set; } protected Employee() { } public Employee(string employeeNo, string name, int departmentId, DateTime hireDate, string email) { SetCoreInfo(employeeNo, name, departmentId, hireDate, email); Status = EmployeeStatus.Active; } public void UpdateBaseInfo(string name, int departmentId, DateTime hireDate, string email) { SetCoreInfo(Name, name, departmentId, hireDate, email); } public void Deactivate() { if (Status == EmployeeStatus.Inactive) { throw new BusinessException("该员工已是离职状态"); } Status = EmployeeStatus.Inactive; } private void SetCoreInfo(string currentName, string name, int departmentId, DateTime hireDate, string email) { if (string.IsNullOrWhiteSpace(name)) { throw new BusinessException("员工姓名不能为空"); } Name = name; DepartmentId = departmentId; HireDate = hireDate; Email = email; } }注意属性是private set,外部代码不能随意把员工状态改成离职。所有关于员工状态的变化必须通过Deactivate方法,而方法里内部包含了对状态的校验。这就是领域规则的内聚性。
4.2 数据访问与实体映射
Infrastructure项目里创建ApplicationDbContext,并配置Employee的映射。使用EF Core的Fluent API,把Domain模型和数据库表之间的细微差异放在这一层处理。
public class ApplicationDbContext : DbContext { public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options) : base(options) { } public DbSet<Employee> Employees => Set<Employee>(); protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.Entity<Employee>(e => { e.HasKey(x => x.Id); e.Property(x => x.EmployeeNo).HasMaxLength(20).IsRequired(); e.Property(x => x.Name).HasMaxLength(50).IsRequired(); e.Property(x => x.Email).HasMaxLength(100); e.Property(x => x.Status).HasConversion<int>(); }); base.OnModelCreating(modelBuilder); } }生成数据库迁移时,需要在Package Manager Console里执行:
Add-Migration InitialCreate -Project MyCompany.PersonSystem.Infrastructure -StartupProject MyCompany.PersonSystem.Web Update-Database -Project MyCompany.PersonSystem.Infrastructure -StartupProject MyCompany.PersonSystem.Web为什么要指定Project和StartupProject?因为DbContext在Infrastructure项目里,而启动项目是Web。如果不指定,EF Core可能找不到连接字符串,或者把迁移文件生成到Web项目下,导致项目引用混乱。
4.3 仓储实现与依赖注入
在Domain项目里定义IEmployeeRepository:
public interface IEmployeeRepository : IRepository<Employee> { bool ExistsByEmployeeNo(string employeeNo); IReadOnlyList<Employee> GetActiveEmployees(); Employee GetByEmployeeNo(string employeeNo); }在Infrastructure项目里实现它:
public class EmployeeRepository : RepositoryBase<Employee>, IEmployeeRepository { private readonly ApplicationDbContext _context; public EmployeeRepository(ApplicationDbContext context) : base(context) { _context = context; } public bool ExistsByEmployeeNo(string employeeNo) { return _context.Employees.Any(x => x.EmployeeNo == employeeNo); } public IReadOnlyList<Employee> GetActiveEmployees() { return _context.Employees .Where(x => x.Status == EmployeeStatus.Active) .ToList(); } }在Program.cs里注册服务:
builder.Services.AddDbContext<ApplicationDbContext>(options => options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection"))); builder.Services.AddScoped<IEmployeeRepository, EmployeeRepository>(); builder.Services.AddScoped<IEmployeeAppService, EmployeeAppService>(); builder.Services.AddControllersWithViews();这里使用AddScoped而不是AddSingleton或AddTransient,是为了保证在一次请求内,所有Service和Repository共用同一个DbContext实例,避免多个实例同时跟踪同一实体造成冲突。
4.4 服务与控制器串联:从URL到View的完整链路
Application项目中的服务接口:
public interface IEmployeeAppService { IReadOnlyList<EmployeeListDto> GetEmployees(); EmployeeDetailDto GetEmployeeDetail(int id); void CreateEmployee(EmployeeCreateViewModel viewModel); void UpdateEmployee(EmployeeUpdateViewModel viewModel); void DeactivateEmployee(int id); }实现中的核心是CreateEmployee。前面已经列过代码,这里再补一个查询详情的例子:
public EmployeeDetailDto GetEmployeeDetail(int id) { var employee = _employeeRepository.GetById(id); if (employee == null) { throw new BusinessException("员工不存在"); } return new EmployeeDetailDto { Id = employee.Id, EmployeeNo = employee.EmployeeNo, Name = employee.Name, DepartmentId = employee.DepartmentId, HireDate = employee.HireDate, Email = employee.Email, Status = employee.Status }; }Controller拿到这个DTO后,可以直接塞进View Model并返回给视图。视图只是把数据展示出来,不需要关心这些数据是从哪个数据库表里查出来的。这样一个完整的“人-机-数”链路就打通了:真实世界里的人事数据被采集进数据库,经过领域层翻译成业务概念,再由应用服务组织成页面模型,最终呈现给操作人员。
5. 常见问题排查与优化实录
5.1 控制器越来越胖?先看Service是不是变成了“披萨饼”
很多团队从三层架构迁移到五层MVC时,都会经历一个错觉:控制器瘦下来了,Service胖起来了。如果一个Service方法里有大量分支判断、状态转换、数据拼接,说明它没有把领域规则下沉到实体里,而是用“过程化脚本”在操作领域对象。
我排查这类问题时有个笨办法:看Service方法里有没有大量if嵌套,有没有直接new出另一个实体的内部状态,有没有调用其他Service但其他Service又在调用当前Service。一旦出现循环依赖,就要考虑把公共领域逻辑抽到Domain层。
合理的Service方法应该是:先防御性判断,然后加载实体,调用实体方法,最后调用仓储保存。如果你在Service里写了很多线性的步骤,一定要反思那些步骤是属于调度,还是属于业务规则。调度留在Service,规则放Domain。
5.2 EF Core跟踪与多层传递的冲突
五层MVC里,Entity从Repository返回后会被Service使用,Service再返回DTO,Entity生命周期可能跨越多个层。如果同一个Id的实体被查询两次,或者一个被追踪、一个不被追踪,在调用SaveChanges时就会报错:System.InvalidOperationException: The instance of entity type 'Employee' cannot be tracked because another instance with the same key value is already being tracked.
解决思路有三种。第一,在仓储查询时统一使用AsNoTracking(),除非明确知道要更新实体再调用Update。第二,每次Service方法只在方法内部保存一次,不要把一个Entity传递给另一个Service并让另一个Service去保存。第三,如果业务确实复杂,使用UnitOfWork模式把多个仓储的SaveChanges统一到一个事务里,避免每调用一个仓储方法就SaveChanges一次。
我自己的习惯是:写操作开始前,用GetById获取实体并要求该仓储返回可追踪实体;读操作全部AsNoTracking。这样能最大程度减少冲突,也避免多一次无谓的数据库往返。
5.3 ViewModel映射坑位
手动映射ViewModel和Entity时,最常见的坑是把不该暴露的字段暴露出去。比如Employee实体里有Status,而更新员工基本信息的页面上并不需要让用户修改状态,结果因为用了AutoMapper,自动把Status也映射过来了,于是页面提交一个无效状态,把员工状态改坏了。
使用AutoMapper时一定要为每个映射单独指定Ignore规则,而不是无脑CreateMap。我建议在配置里增加一行统一声明:
MapperConfiguration config = new MapperConfiguration(cfg => { cfg.CreateMap<EmployeeCreateViewModel, Employee>() .ForMember(dest => dest.Status, opt => opt.Ignore()); });更稳妥的做法是让更新操作只通过Controller接收页面允许修改的字段,Service内部再根据业务规则改变真实实体的状态。页面权限和领域规则是两回事,不能混为一谈。
5.4 数据库迁移与部署环境冲突
我常在试验阶段遇到这个问题:本地使用LocalDB,数据都在;发布到服务器后忘记执行迁移,打开系统就报表不存在。或者在服务器上执行Update-Database,但服务器没装Visual Studio,也没有NuGet环境。
推荐的做法是在发布流程中自动执行迁移,但要有并发保护。例如在Program.cs里用临时环境变量控制,或者单独做一个数据库迁移脚本工具。最保险的方式是生成SQL脚本,在数据库发布阶段统一执行:
Script-Migration -Project MyCompany.PersonSystem.Infrastructure -StartupProject MyCompany.PersonSystem.Web -OutputFile migrate.sql然后把migrate.sql交给数据库管理员执行,避免一个Web应用运行时自动建表引发权限问题。这个习惯在做生产级别系统时尤其重要。
5.5 常见问题速查表
| 问题现象 | 可能原因 | 处理建议 |
|---|---|---|
| Controller Action超过20行 | 业务逻辑未下沉Service | 抽取Service方法,Controller只做调度 |
| 页面出现意外的敏感字段 | ViewModel映射到了实体全部字段 | 按页面定制ViewModel,映射时Ignore无关字段 |
| EF Core报同名实体重复跟踪 | 同一个Id被多次查询 | 查询统一AsNoTracking,更新时再追踪 |
| 数据库表未创建 | 忘记执行迁移 | 使用Update-Database或发布SQL脚本 |
| 部署后连接不上数据库 | 连接字符串仍指向LocalDB | 替换为生产数据库连接,并检查防火墙权限 |
| 修改实体保存无效 | private set导致值未被赋值 | 调用实体行为方法,不要直接给属性赋值 |
6. 一些我个人偏爱的小技巧和后续可以扩展的方向
6.1 用依赖注入容器管理生命周期
很多人只把依赖注入当成“在构造函数里拿对象”的工具,但生命周期才是最关键的部分。对于DbContext,必须使用Scoped;对于无状态的Service,可以用Scoped或者Transient;对于缓存类服务,可以用Singleton。如果误用Singleton注入DbContext,就会导致多请求共享同一个数据库上下文,出现数据错乱和并发问题。
我通常在项目里放一个CompositionRoot文件夹,集中管理依赖注册。Application和Infrastructure各自提供扩展方法,Web层在Program.cs里统一调用,例如:
builder.Services.AddApplicationServices(); builder.Services.AddInfrastructureServices(builder.Configuration);这样Web项目非常干净,其它项目也可以在单元测试中灵活替换实现。
6.2 从人员档案延伸到组织、权限、考勤
本文里的“人员档案管理系统”只是最小可行示例。当你理解了四层平台加五层MVC,就可以很自然地把组织架构、岗位管理、权限管理、考勤规则这些业务域一个个加进来。每个域内都使用独立的实体、仓储、服务、Controller和View,域与域之间通过应用服务协作,而不是直接操作对方的仓储。
例如员工和部门之间,员工实体保存一个DepartmentId,但在查询员工列表时需要展示部门名称。这个场景不必让Employee实体去依赖Department,而是在Application服务里同时加载两个仓储,把数据拼装成EmployeeListDto。领域模型之间尽量保持低耦合,应用服务负责“跨域翻译”。
另外,当系统复杂度进一步上升,你可以考虑引入CQRS(命令查询职责分离)或者事件驱动的消息通知。但不要一开始就上这些架构,先把基础的“四层平台、五层MVC”走稳,等发现确实存在并发、扩展、响应性能等问题时再演进。
6.3 最后分享一个真实踩坑:别把仓储当成万能数据工具箱
早期我实现IRepository 时,把分页、排序、多表查询、统计报表全塞进泛型接口,结果这个接口的方法数量是数据库表的十倍。每换一张表,就要为它实现一堆八竿子打不着的查询方法,代码复用变成了灾难。
后来我把仓储拆成了两类:面向聚合根的常规仓储,以及面向查询场景的专用查询服务。聚合根仓储只负责实体的加载与持久化,查询服务可以使用Dapper或EF Core的FromSqlRaw,返回业务需要的DTO。这样一来,核心领域模型不被报表查询污染,五层MVC的层次感才真正体现出来。
如果你也想实践这套架构,我的建议是从一个不超过三个模块的小项目开始,先把Controller、Service、Repository之间的依赖方向理顺,再逐步加入新的业务域。架构不是越复杂越好,而是要让每一段代码都能在“四层平台”上找到属于自己的位置,让“思维语言世界”和“真实人物对象世界”之间的映射变得自然、稳定、可维护。