☰
四层平台五层MVC:ASP.NET Core业务系统架构实践
2026/11/2 16:37:09 网站建设 项目流程

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,就变成五层:

层次原名职责常见位置
1View展示页面、收集用户输入Views文件夹下的cshtml
2Controller接收请求、调用服务、返回结果Controllers文件夹
3ViewModel面向视图的数据结构ViewModels文件夹
4Entity / Domain Model面向业务规则的对象Domain项目
5Repository(含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.Infrastructure

2.2 解决方案目录结构与项目引用关系

四个项目之间必须遵守单向依赖,这是整个架构的底线:

  • Web引用Application、Domain、Infrastructure。
  • Application引用Domain。
  • Infrastructure引用Domain。
  • Domain不引用任何项目。

为什么允许Web直接引用Infrastructure?因为在启动项目里,我们需要注册DbContext和仓储实现。但这不代表Web里的Controller可以越过分层直接调用DbContext。Controller只能面向Application层的接口编程。

依赖关系可以看这张引用矩阵:

项目引用方向说明
WebApplication、Domain、Infrastructure表现层,只知道接口,不知道实现细节
ApplicationDomain服务层,编排领域对象
InfrastructureDomain仓储实现、数据库上下文
Domain无定义实体和仓储接口

在实际代码里,Application层不会出现Microsoft.EntityFrameworkCore的引用,不会出现SqlConnection,也不会出现HttpContext;因为它天生就不依赖这些技术。这个约束写进代码Review清单里,比口头约定靠谱得多。

2.3 命名空间约定与项目模板选择

命名空间我建议按业务模块拆,而不是按技术类型硬堆。比如Domain项目里并不是只有Entities文件夹,而是按模块分:

Domain/ Employees/ Employee.cs IEmployeeRepository.cs Departments/ Department.cs IDepartmentRepository.cs Common/ EntityBase.cs

Application项目也按模块组织,每个模块放一个接口和一个实现,接口放在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之间的依赖方向理顺,再逐步加入新的业务域。架构不是越复杂越好,而是要让每一段代码都能在“四层平台”上找到属于自己的位置,让“思维语言世界”和“真实人物对象世界”之间的映射变得自然、稳定、可维护。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询