资讯动态

三层架构从理论到落地:.NET与SpringBoot最小闭环实战

发布时间:2026/10/17 5:01:01 来源:尧图企业网站定制
先问一句你接手过那种“改一个字段要翻三个文件还不敢随便动”的项目吗UI、SQL、业务判断全挤在一堆文件里光看代码就头大更别提交代。这种状态业内叫“烂代码”其实都算客气了。很多人觉得大厂标准三层架构是个很高深的东西真拆开看它就是靠“表现层、业务层、数据访问层”这一条线把烂代码从根上理顺。好消息是这套结构在.NET和SpringBoot里都能直接落地而且模板化程度极高不需要你背设计模式只要把目录划分、类职责和数据流向搞清楚1小时足够跑通一个最小闭环。这篇文章就带你把两个版本都过一遍适合刚入行的开发、写了两三年代码但没正经做过分层的老哥也适合准备重构个人项目的朋友。1. 三层架构到底在解决什么问题1.1 先看清“烂代码”到底烂在哪我一个很直观的感受很多项目不是功能写不出来是功能写出来之后没人敢改。你打开一个Controller好家伙里面又是拼SQL、又是解析Excel、又是调第三方接口还顺带把用户权限校验也做了。这不叫Controller这叫做“万能类”。这种代码的问题不在于“一个人能不能看懂”而在于它把变化的节奏全搅在一起了。举个简单的例子页面原来是表格展示现在要改成卡片展示这本身是表现层的事情。但如果Controller里直接查了数据库那加一个卡片字段你就得去动SQL、动数据模型、动Controller甚至还要考虑这段SQL被别的地方复用了吗这种“牵一发动全身”的痛苦才是三层架构要治理的核心问题——职责分离。表现层只负责接参数、回结果业务层只负责领域规则和流程编排数据访问层只负责跟数据库打交道。每一层都有自己的“地盘”改表现层不影响数据访问层改数据访问层不影响业务逻辑。这正是大厂团队里那种“看着很标准、却很难说出哪里好”的代码结构给人安全感的来源。1.2 三层架构的边界别把“三个包”当成三层架构很多人一看三层架构就以为在项目里建三个文件夹叫controller、service、mapper就算分层了。其实那只是目录分层真正的架构分层看的是依赖方向。我的理解是上层可以调用下层但下层绝对不允许反向调用上层。也就是说业务层可以调数据访问层数据访问层不能反过来调业务层表现层可以调业务层业务层不能在代码里写死在Controller里“return Json(...)”——虽然编译能过但你已经把表现层的逻辑反向耦合回来了。判断分层合不合格有个很笨但很有效的办法你拿掉表现层剩下的Service和Repository能不能独立跑通一个完整业务你换一套前端Controller全删了Service是不是还能复用如果能那才叫分层否则只是“分文件夹”。还有人会问三层架构和MVC是不是一回事MVC是站在表现层内部的再划分Model、View、Controller都是“界面展示”这一层的零件而三层架构是站在整个系统的维度把界面、业务、数据切成三段。两者不冲突甚至经常叠加用——Controller本身就是表现层的入口Model可以是DTO和实体的集合。这个话题后面我还会展开聊。1.3 为什么“大厂标准”往往都是最简单的这套市面上有DDD、有洋葱架构、有六边形架构、有整洁架构名词一个比一个唬人。但说实话大多数业务系统落地上用的最多的还是三层架构这个底子。哪怕团队对外宣称用了DDD你细看它的代码底层依然是“接口-实现-仓储”这条经典链路。这背后的原因很简单三层架构的边界最清晰、学习成本最低、替换成本也最低。DDD需要你有很强的领域建模能力六边形架构需要你设计端口和适配器这些对新手来说过于抽象而三层架构是“Controller收参数Service算逻辑Repository取数据”任何人照着画都能画出来。更关键的是三层架构给后续演进留了余地。等业务复杂度上来了你可以在Service里再拆出Domain层也可以把Repository换成分库分表的中间件。它是地基不是天花板。所以对绝大多数团队而言“大厂标准”不是玄学就是这套经过千锤百炼的落地骨架。2. .NET版落地实操从Controller到Repository的最小闭环2.1 照着抄一版可直接复制的目录结构我用一个最典型的分层方案项目里按“Api / Application / Domain / Infrastructure”四个类库来切。这不是唯一方案但它是很多一线团队在用的标准姿势而且和依赖注入配合得非常好。MySolution/ ├── src/ │ ├── MyProject.Api/ // 表现层Controller、Filter、DTO │ ├── MyProject.Application/ // 业务层Service接口、Service实现、DTO │ ├── MyProject.Domain/ // 领域层实体、仓储接口 │ └── MyProject.Infrastructure/ // 基础设施层仓储实现、DbContext └── MyProject.sln这里有个很多人踩过的坑让Api项目直接引用Domain同时Infrastructure也引用Domain但Api不允许直接引用Infrastructure。为什么因为Api的职责是“组织HTTP交互”它不该知道数据来自数据库还是Redis还是第三方API。它只需要拿到Application层的Service接口由Application层去协调Infrastructure里的实现。依赖关系大概是这样的Api - Application - Infrastructure实现 和 Api/Infrastructure - Domain契约。把仓储接口放到Domain里本质上就是“依赖倒置”的体现——上层依赖抽象而不是依赖具体实现。你要是把IUserRepository放在Infrastructure里那Application层就得引用Infrastructure耦合就重了。2.2 代码怎么写一个用户分页查询的完整贯穿下面我用一个“用户分页查询”的小功能把三层串起来。这类功能非常常见拿它入门最不烧脑。Controller只做三件事接参、调Service、返回结果。[ApiController] [Route(api/users)] public class UserController : ControllerBase { private readonly IUserService _userService; public UserController(IUserService userService) { _userService userService; } [HttpGet] public async TaskIActionResult GetPage(int pageIndex 1, int pageSize 10) { var result await _userService.GetPageAsync(pageIndex, pageSize); return Ok(result); } }注意看这个Controller里没有任何数据库相关代码也没有任何“如果用户名不为空就追加where条件”这种逻辑。它只负责“把事情交给Service拿到结果再还给前端”。你不写子查询、不写ORM语句也不new一个DbContext。Application层负责业务编排。接口放在这里实现也放在这里public interface IUserService { TaskPageResultUserDto GetPageAsync(int pageIndex, int pageSize); } public class UserService : IUserService { private readonly IUserRepository _userRepository; public UserService(IUserRepository userRepository) { _userRepository userRepository; } public async TaskPageResultUserDto GetPageAsync(int pageIndex, int pageSize) { var page await _userRepository.GetPageAsync(pageIndex, pageSize); var items page.Items.Select(x new UserDto { Id x.Id, UserName x.UserName, CreatedAt x.CreatedAt }).ToList(); return new PageResultUserDto { Items items, Total page.Total, PageIndex pageIndex, PageSize pageSize }; } }这段代码里有两个细节值得说道第一Service没有让实体直接往外传而是转成了UserDto这就避免了把数据库字段全暴露给前端第二它接收的是IUserRepository而不是UserRepository这意味着数据访问层的实现细节被完全挡在了业务层外面。Infrastructure层才是真正碰数据库的地方public interface IUserRepository { TaskPageResultUser GetPageAsync(int pageIndex, int pageSize); } public class UserRepository : IUserRepository { public async TaskPageResultUser GetPageAsync(int pageIndex, int pageSize) { // 这里是用 EF Core / Dapper 真正查询数据库的位置 // 模拟返回一页数据省略数据库访问代码 var list new ListUser(); return new PageResultUser { Items list, Total 0, PageIndex pageIndex, PageSize pageSize }; } }这里有个很多人纠结的问题仓储接口和实现到底分开放还是放一起我建议“接口放Domain实现放Infrastructure”。这样Application层只依赖Domain里的抽象等到你哪天要把EF Core换成Dapper或者从SQL Server切到PostgreSQL业务层一行都不用改。2.3 Program.cs里把依赖关系“登记”好. NET版的关键一步是在程序启动时把依赖注入关系注册好。这步漏了运行起来会直接报“Unable to resolve service”。builder.Services.AddScopedIUserService, UserService(); builder.Services.AddScopedIUserRepository, UserRepository();AddScoped的意思是“同一个请求范围内共用同一个实例”这对Web应用来说是默认且安全的选择。实际项目里可以在Infrastructure层的扩展方法里统一注册比如做一个AddInfrastructure(builder.Services)Controller和Application的注册也各归各的避免Program.cs太长变成另一个垃圾堆。我实际写过的项目里最实用的做法是把“每层自己注册自己的依赖”这件事做成约定。Application层注册自己的ServiceInfrastructure层注册自己的RepositoryProgram.cs只需要调用两行扩展方法整个注册逻辑一目了然新人接手也不用猜。3. SpringBoot版落地实操换语言不换思想3.1 包结构怎么切一眼就能看明白的分层目录Java侧和. NET侧套路完全一致只是术语和文件后缀变了。我习惯按包名来强制分层比目录更好使因为包名会直接出现在import语句里谁要是跨层调用看代码就知道。com.example.project ├── controller/ // 表现层 │ └── UserController.java ├── service/ // 业务层接口 │ └── UserService.java ├── service/impl/ // 业务层实现 │ └── UserServiceImpl.java ├── repository/ // 数据访问层 │ └── UserRepository.java ├── entity/ // 数据库实体 │ └── User.java └── dto/ // 数据传输对象 └── UserDto.java只要你建了这六个包代码往里面一摆分层意识就立住了一半。SpringBoot因为注解机制简化了很多事RestController、Service、Repository本身就带着层的语义只要别把Repository打在Controller上就行——我确实见过这样的魔幻代码。3.2 代码怎么写同样一个查询Java版全流程先看实体注意它只是数据库表的映射public class User { private Long id; private String userName; private LocalDateTime createdAt; // 省略 getter / setter实际项目中用 Lombok Data }Controller层只做HTTP交互RestController RequestMapping(/api/users) public class UserController { private final UserService userService; // 构造器注入Spring 4.3 之后推荐的做法 public UserController(UserService userService) { this.userService userService; } GetMapping public PageResultUserDto getPage( RequestParam(defaultValue 1) int pageIndex, RequestParam(defaultValue 10) int pageSize) { return userService.getPage(pageIndex, pageSize); } }这里再说一次用构造器注入不要用Autowired字段注入。字段注入写起来是省事但测试时想换一个mock实现那叫一个痛苦构造器注入则天经地义你要什么就给什么IDE一重构就知道哪里断了。Service接口和实现public interface UserService { PageResultUserDto getPage(int pageIndex, int pageSize); } Service public class UserServiceImpl implements UserService { private final UserRepository userRepository; public UserServiceImpl(UserRepository userRepository) { this.userRepository userRepository; } Override public PageResultUserDto getPage(int pageIndex, int pageSize) { PageResultUser entityPage userRepository.findPage(pageIndex, pageSize); ListUserDto items entityPage.getItems().stream() .map(u - new UserDto(u.getId(), u.getUserName(), u.getCreatedAt())) .collect(Collectors.toList()); return new PageResult(items, entityPage.getTotal(), pageIndex, pageSize); } }注意这里把实体转成DTO是刻意做的。你返回给前端的字段和数据库的字段很可能不一样——比如不能在JSON里把用户密码密文的字段带出去。DTO就是这道“防火墙”。Repository真正碰数据库Repository public class UserRepository { public PageResultUser findPage(int pageIndex, int pageSize) { // 用 MyBatis-Plus / Spring Data JPA 查询数据库这里省略具体语句 return null; } }实际项目里这个类会注入Mapper或JpaRepository。但在分层结构上Controller和Service永远面对的是“UserRepository”这个对象至于内部是用MyBatis还是JPA对上层完全透明。3.3 .NET和SpringBoot的“翻译对照表”学完其中一版另一版基本就是“换皮”职责.NETSpringBootHTTP入口ControllerRestController业务接口IUserServiceUserService接口业务实现UserService类Service UserServiceImpl数据接口IUserRepositoryUserRepository类数据实现UserRepository类Repository UserMapper/UserRepository依赖注入注册Program.cs里AddScoped组件扫描 构造器注入数据库实体Domain里的Entity类entity包里的实体拿着这张表你完全可以把. NET那套经验平移过来。喜欢哪个就看哪个重点是理解“接口隔离”和“依赖方向”语言只是形式。4. 容易翻车的五个落地细节4.1 依赖方向反了一切白搭我见过最严重的“伪分层”是Service里调用另一个Controller的方法。比如UserController里有一个GetUserName的方法CartController去调用它。这在三层架构里是大忌——你让表现层互相耦合了。正确的做法是把“取用户名”这个动作下沉到Service两个Controller都调Service。再比如Repository里拿着Session或者HttpContext之类的表现层对象这也是方向反了。数据访问层应该只关心“怎么取数据”不该关心“是谁在请求”。一旦你发现Repository里出现了HttpContext基本就是架构味道已经坏了。4.2 事务要放在Service层而不是Repository层很多新手以为事务是和数据访问相关的所以把[Transaction]注解打在Repository上。但仔细想想一个业务动作可能需要“先扣库存、再记账、再发消息”如果事务只包在某一个Repository方法里那另外两步失败时怎么办只有把事务放到Service层才能把多个数据操作包在同一个事务里。.NET里可以在Service方法上用事务特性或者用UnitOfWorkSpringBoot里直接Transactional加在Service实现方法上。这条经验我踩过真坑当年把事务放在Mapper上结果一个跨表更新的业务拆成两个Mapper方法后怎么都不对查了半天才反应过来是事务边界错了。4.3 实体、DTO、查询对象三样东西别混用实体Entity是数据库表的映射DTO是接口出入参的定义查询对象QueryObject是承载查询条件的。如果把三样混成一个类短期内写着爽后期数据库加个字段接口文档里就得跟着多一个字段隐私问题也容易失控。我实操中的做法是DTO尽量小只包含前端需要的字段实体永远不直接序列化返回查询条件多的时候单独建一个Query对象里面放pageIndex、pageSize和各种筛选字段。这样改DTO不影响数据库表改数据库表不影响接口文档各走各的。4.4 依赖注入是标配不是可选项不管是.NET的AddScoped还是SpringBoot的构造器注入都遵循一个更底层的道理不要自己去new依赖对象。你new出来的对象你是知道它是谁但以后想替换成mock、想加缓存代理、想让很多Service共享同一个实例就得大动干戈。依赖注入容器帮你管理对象的生命周期和引用关系这也是“大厂标准”看着高级却并不难学的关键点之一。在SpringBoot里宁可多写几行构造器也不要图省事用Autowired字段前面说过了。在. NET里Controller构造器里接收IUserService也已经是规范动作。依赖注入带来的另一个好处是测试友好——你可以在测试代码里new一个假的IUserRepository然后丢进UserService完全不需要起数据库。4.5 命名规范和分层必须同步进化三层架构不只是结构的规则也是命名的规则。Controller、Service、ServiceImpl、Repository、Mapper这一串后缀本身就是代码注释。反例是类名叫“UserHandler”“DbUtils”这种一看就是没想清楚层的位置架构重建的时候就容易找不到北。目录、类名、接口名、方法名最好保持同一套语言。用户查询列表就叫GetPageAsync或getPage用户创建就叫CreateAsync或create。统一命名看起来是小事但团队合作时价值极高——所有人不用看注释就知道新功能应该写在哪个文件里。5. 实际操作中最高频的坑和排错记录5.1 分层了但运行报错依赖注入没配对我接手过不少半路重构的项目最常见的报错是“Unable to resolve service”或“No qualifying bean of type”。原因基本就两类要么忘了在Program.cs里注册要么忘了加Repository/Service注解。结果就是标题写了三层架构代码也建了包但缺少“装配”这一步。排查思路很简单先确认接口和实现都有再看容器是否知道它们的关系。.NET去Program.cs找AddScoped/AddTransientSpringBoot去启动类所在的包下确认组件扫描范围涵盖了Controller、Service、Repository所在的包。还有一种隐蔽情况是SpringBoot的启动类在顶层包你的业务包却放在了顶层包外面的另一个目录扫描不到新加的Service就会启动即报错。5.2 代码分层了但Service变成了“上帝类”有人学了三层架构Controller确实瘦了但把所有逻辑都堆在Service里一个Service几百行全是if/else拆出来的私有方法十几个。这种“把烂代码从Controller搬到了Service”的做法只是换了口锅。真正的解法是业务层内部继续拆分。可以把不同业务拆成不同的Service比如订单相关OrderService、用户相关UserService也可以在一个Service内部用策略模式把多变的分支拆开。我实操中的底线是一个Service的主要业务方法尽量在15行以内超出就该思考是不是把子步骤抽成私有方法或者独立组件了。5.3 分页参数乱传接口直接崩分页这个功能看似简单问题一点不少。有人把pageIndex从0开始有人从1开始有人pageSize传10000直接把数据库拖崩。三层架构落地时建议把分页参数做成一个Query对象在Controller入口统一做合法性检查比如pageIndex必须大于0、pageSize必须在1到200之间。这样Service就不会收到一堆脏数据Repository也不用为“万一大前端传了个负数”这种事兜底。这里有个我个人的体会数据访问层的职责是“按条件取数”不是“校验参数”。校验参数这种业务规则应该放在Service层或者Controller入口。你要是把校验写在Repository里那将来换个调用入口比如消息队列消费校验逻辑还得再写一遍。6. 写在最后一点个人的真心话三层架构这套东西看起来像常识但真正的分水岭在于“你会不会在压力下依然遵守边界”。我刚工作的前两年写过无数个Controller直接拼SQL、Service里new仓储实例的反面教材。后来有一次重构一个报表模块在动手之前先花了大半天把目录重新划清楚、把依赖关系画明白结果真正写代码的时间比预想少了三分之一而且改起来特别有底气因为我知道哪里会受影响、哪里不会。如果你也准备动手我给一个最朴素的操作建议先选一个你最熟悉的框架把文章里的最小闭环完整敲一遍哪怕功能就是“查用户列表”。敲完之后别急着做新功能试着加一个“新增用户”的接口你会发现只需照葫芦画瓢。等两三个功能跑顺了三层架构的肌肉记忆就建立起来了。到那时候你再看那些动辄几千行、什么都在一个类里的代码会自然地感到抗拒——这大概就是“告别烂代码”真正的开始。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑