
直接说结论三层架构没你想的那么玄但它确实是目前大厂里普及度最高、新人上手最快、也最容易“抄”对的一套代码组织方式。我见过太多项目一两年后没人敢动改一个字段要排查半小时调用链根子基本都在分层没做对。这篇文章不跟你扯理论直接给你 .NET 和 Spring Boot 两套能落地的写法把 Controller、Service、Repository 这三层的职责边界、依赖方向、事务位置、异常处理一次讲透。不管你是刚转行写业务还是被历史代码折磨得想重构这套东西都能让你少踩很多坑。1. 为什么你的代码总在“改一处坏三处”——三层架构到底解决什么问题1.1 烂代码的通病与三层架构的解法先描述一个场景。某天产品提了个需求把订单列表里金额小于100元的订单标记为“小额订单”。你心想这不简单吗打开 Controller 里那个getOrderList方法找到金额判断的 if加上一个标记字段完事。跑起来没问题上线后第三天运营说后台导出的 Excel 里“小额订单”列是空的。你查了半天发现导出功能压根没走这个接口它直接查了数据库再拼的报表。于是你改了两个地方。过了一周新来的同事在另一个接口里又写了一遍金额判断逻辑而且他把阈值写成了0.01因为没看到你那个魔数 100 从哪来的。这就是烂代码的典型症状同一份业务规则散落在多个地方改一处漏一处。三层架构解决的就是这个问题。它的核心思想不是把代码分成三个文件夹那么简单而是把“接收请求”“处理业务”“读写数据”这三种变化频率完全不同的行为拆开让每一层只做自己该做的事并且依赖方向是单向的。这套东西之所以能在大厂流行这么多年不是因为它新而是因为它是目前投入产出比最高的代码组织方式。DDD 那套更精细但对大部分业务系统来说过度设计微服务更时髦但你单体还没写利索就上微服务那是灾难。三层架构恰好卡在“够用”和“清晰”的平衡点上。1.2 三层架构不是“三个文件夹”是三条职责边界很多人误以为建了 Controller、Service、Repository 三个目录就算分层了。真正关键的是职责边界和依赖规则。表现层Controller只干三件事接收参数、校验参数格式、调用业务层。它不关心这个订单是不是“小额订单”它只知道把请求转给 Service然后把 Service 返回的结果包装成响应。业务层Service是核心。所有业务规则、流程编排、事务控制都在这层。订单金额阈值、库存扣减逻辑、状态流转判断这些必须集中在 Service 里。哪怕一个规则只有一处用到也要从 Controller 下沉到 Service因为你是从“将来会有第二处调用”的角度来设计的。数据层Repository只管 CRUD。查询订单列表、按 ID 查订单、更新订单状态这层的代码应该只跟数据库打交道不应该出现任何 if 判断里藏着“小于100”这种业务逻辑。依赖方向必须单向Controller 依赖 ServiceService 依赖 Repository谁都不能反向。如果出现 Repository 调 Service或者两个 Service 互相调那就是架构腐化的信号。注意我用 Controller / Service / Repository 这套叫法是当前主流的通用命名。在 .NET 里很多人习惯叫 Controller / Service / Repository在 Spring Boot 里也叫 Controller / Service / Repository或 Mapper。叫法一样思维模型也一样下文就直接用这套名字。2. .NET 版落地实操从空目录到可扩展分层骨架2.1 项目结构设计与依赖方向先说 .NET 这边。我用的是 .NET 8环境是 VS2022 或者 Rider 都行。创建解决方案的时候别图省事把所有类塞进一个项目里。我建议至少拆成三个项目Web放 Controllers、Filters、Program.cs、Application放 Services、DTOs、业务逻辑、Infrastructure放 Repository、DbContext、实体映射。有些人会把 Domain实体单独拆出去看你的团队规模如果就三五个人开发三个项目够了拆太多反而增加心智负担。依赖方向Web 引用 ApplicationApplication 引用 Infrastructure不对这里要小心。正确的做法是 Application 定义 Repository 接口Infrastructure 实现这个接口。也就是说 Application 不直接引用 Infrastructure而是靠依赖注入在启动时把实现注册进去。这样做的目的是让业务层不依赖具体的数据库技术。你可能觉得这有点绕但等你哪天要从 SqlServer 换到 PostgreSQL 的时候就会感谢这个设计。项目建好后解决方案结构长这样MyApp.sln ├── src │ ├── MyApp.Web # 表现层 │ ├── MyApp.Application # 业务层 │ └── MyApp.Infrastructure # 数据层2.2 实体、仓储、服务的核心代码示例先定义一个简单的订单实体。通常在 Infrastructure 项目里建 Entities 文件夹放数据库映射对象namespace MyApp.Infrastructure.Entities; public class Order { public long Id { get; set; } public string OrderNo { get; set; } string.Empty; public decimal Amount { get; set; } public int Status { get; set; } // 0-待支付 1-已支付 2-已取消 public DateTime CreateTime { get; set; } }然后是仓储接口。注意这个接口放在 Application 项目里因为它是业务层的抽象依赖namespace MyApp.Application.Repositories; public interface IOrderRepository { TaskOrder? GetByIdAsync(long id); TaskListOrder GetListAsync(int status, DateTime? start, DateTime? end); Task AddAsync(Order order); Task UpdateAsync(Order order); }仓储实现在 Infrastructure 项目里用 EF Core 实现namespace MyApp.Infrastructure.Repositories; public class OrderRepository : IOrderRepository { private readonly MyDbContext _db; public OrderRepository(MyDbContext db) { _db db; } public async TaskOrder? GetByIdAsync(long id) { return await _db.Orders.FindAsync(id); } public async TaskListOrder GetListAsync(int status, DateTime? start, DateTime? end) { var query _db.Orders.AsNoTracking().Where(o o.Status status); if (start.HasValue) query query.Where(o o.CreateTime start.Value); if (end.HasValue) query query.Where(o o.CreateTime end.Value); return await query.OrderByDescending(o o.CreateTime).ToListAsync(); } public async Task AddAsync(Order order) { await _db.Orders.AddAsync(order); } public async Task UpdateAsync(Order order) { _db.Orders.Update(order); } }这里要明确一点仓储接口放在 Application实现在 Infrastructure这个过程就是依赖倒置DIP。它不是面子工程是让你在单元测试的时候可以直接 mock 仓储接口不依赖真实数据库。2.3 控制器层怎么“瘦身”现在写 Service 层。注意看这层开始出现业务规则了。比如“标记小额订单”的逻辑namespace MyApp.Application.Services; public class OrderService { private readonly IOrderRepository _orderRepository; public OrderService(IOrderRepository orderRepository) { _orderRepository orderRepository; } public async Task MarkSmallAmountOrdersAsync(decimal threshold) { var orders await _orderRepository.GetListAsync(1, null, null); foreach (var order in orders) { if (order.Amount threshold) { // 这里才是业务规则该待的地方 order.Status 3; // 3-小额订单 await _orderRepository.UpdateAsync(order); } } } }Controller 层就非常薄了[ApiController] [Route(api/orders)] public class OrdersController : ControllerBase { private readonly OrderService _orderService; public OrdersController(OrderService orderService) { _orderService orderService; } [HttpPost(mark-small-amount)] public async TaskIActionResult MarkSmallAmount([FromBody] MarkSmallAmountRequest request) { await _orderService.MarkSmallAmountOrdersAsync(request.Threshold); return Ok(); } }你可能会问这个 Controller 里没写任何业务逻辑会不会太简单了会但这就是对的。如果哪天 MarkSmallAmount 这个动作不只被 HTTP 接口调用还被定时任务调用你直接在 Service 层复用就行Controller 根本不用动。提示这里示例为了演示刻意简化了事务处理。实际多写多更必须加事务尤其是 MarkSmallAmount 这种“查一批、改一批”的场景。EF Core 中可以封装工作单元UnitOfWork把多个仓储的 SaveChanges 统一管理。后面展开讲。3. Spring Boot 版对照落地同思路不同语言3.1 包结构与依赖注入方式对比再来看 Spring Boot 这边。别说你已经会 Java 就不看这节注意对照着理解两套框架背后相同的分层思想这比记住某个语法重要得多。Spring Boot 里推荐用包结构区分层次而不是拆多个 Maven 模块。对于中小项目一个 module 里建 controller / service / repository / entity / dto 五个包就够了。示例如下com.example.order ├── controller │ └── OrderController.java ├── service │ ├── OrderService.java │ └── impl │ └── OrderServiceImpl.java ├── repository │ └── OrderRepository.java ├── entity │ └── Order.java └── dto └── MarkSmallAmountRequest.java注意一个争议点Spring Boot 里 service 接口和 impl 类到底要不要分离我的建议是对外提供复用能力的时候要分离比如同一个 Service 被多个 Controller 或定时任务调度时如果只是给一个 Controller 用可以先去接口化等有第二个实现再拆。很多团队一上来就强制所有 Service 都要有接口和 impl这是过度设计代码量翻倍收益为零。依赖注入方式.NET 用构造函数注入Spring Boot 现在也推荐构造函数注入。不要用字段Autowired原因是字段注入让依赖关系隐蔽不利于单元测试。构造注入可以让你在测试时直接new OrderServiceImpl(repositoryMock)。3.2 Service 层接口与实现分离的意义Spring Boot 的 Repository 层一般用 Spring Data JPA接口直接继承JpaRepositorypublic interface OrderRepository extends JpaRepositoryOrder, Long { Query(select o from Order o where o.status :status and (:start is null or o.createTime :start) and (:end is null or o.createTime :end)) ListOrder findByStatusAndTimeRange(Param(status) int status, Param(start) LocalDateTime start, Param(end) LocalDateTime end); }Service 实现类里写业务逻辑Service Transactional public class OrderServiceImpl implements OrderService { private final OrderRepository orderRepository; public OrderServiceImpl(OrderRepository orderRepository) { this.orderRepository orderRepository; } Override public void markSmallAmountOrders(BigDecimal threshold) { ListOrder orders orderRepository.findByStatusAndTimeRange(1, null, null); for (Order order : orders) { if (order.getAmount().compareTo(threshold) 0) { order.setStatus(3); orderRepository.save(order); } } } }注意这里Transactional的放置位置。类级别的Transactional表示该类的所有 public 方法都走事务代理。放在哪一层必须放在 Service 实现类上绝对不能放在 Controller 上也尽量不要直接标在 Repository 上。原因是业务层才是事务边界的定义者一个事务应该涵盖整个业务操作而不仅仅是某个数据库操作。提示Spring Data JPA 的save方法比较坑——如果实体是托管状态在持久化上下文里save不一定要执行 update框架会自动脏检查。如果实体是游离状态save可能是 insert 也可能合并。在实际开发中先findById返回的是托管实体直接改属性方法结束事务提交时自动落库不需要显式save。我上面示例里加save是为了让新手更直观地看到写操作发生的位置实际可以去掉。3.3 双版本通用校验事务与异常处理再来说一个两套框架都会遇到的问题校验逻辑放哪。很多新手喜欢在 Controller 里写参数校验比如判断 threshold 是否大于0然后返回错误码。这没错但要注意区分“参数格式校验”和“业务规则校验”。格式校验可以放在 Controller 或 DTO 上比如NotNull、Min(1)。业务规则校验要下沉到 Service比如“订单已支付不能取消”——这是状态规则跟 HTTP 请求格式无关放 Controller 里会导致你以后写定时任务还要重复校验。.NET 端可以用 DataAnnotations 或 FluentValidation 做 DTO 校验Spring Boot 端用 Jakarta Validation 的注解。统一思路DTO 上只放格式校验注解业务规则校验在 Service 方法里主动判断并抛对应异常。两端的异常处理也有套路。.NET 里用中间件或 Filter 统一捕获异常转成统一的响应结构。Spring Boot 里用RestControllerAdvice统一处理。目的都是让 Controller 的方法签名保持干净不散落 try-catch。统一响应结构在分层的 Controller 里很容易做到比如 .NET 里用ApiResponseTSpring Boot 里用ResultT里面放 code、message、data。4. 实战中那些坑分层架构最常见的5个错误4.1 在 Controller 里写业务代码这是最普遍的问题没有之一。比如在 Controller 里判断库存扣减、拼接订单号、计算价格折扣。短时间看省事不用新建 Service 方法。但只要你写了第二个接口同样的逻辑就面临复制粘贴的选择。三个地方复制了就是坏味道的开始。判断标准很简单这段代码能不能被“非 HTTP”入口复用如果定时任务、消息队列、单元测试也想调用那它就必须在 Service 层。我见过一个项目Controller 方法居然有500行里面一半是 SQL 拼接。后来他们重构把 Controller 里的业务代码下沉到 ServiceController 瘦身到只剩路由和参数绑定。结果是之前不敢动的老模块新同事也能快速上手改了。4.2 实体直接暴露给前端这两个框架里都有实体对象但实体是数据库映射结构JSON 是接口协议它俩经常不一致。比如 Order 实体里有CreateTime前端 label 叫“下单时间”实体里有Status是 int前端要显示“已支付”这串文字。你要是直接把实体返回给前端就会出现要么前端拿到一堆不需要的字段要么后端为了配合前端不断加冗余字段。更隐蔽的问题是实体字段变更会影响接口协议。一旦数据库加了InternalRemark内部备注字段你不小心把它序列化出去潜在风险就埋下了。正确做法是建 DTO。Controller 返回 DTO接收参数也是 DTO实体只在 Service 和 Repository 之间传递。4.3 事务注解放错位置Spring Boot 的Transactional放在 Controller 上或者 .NET 里把事务逻辑写在 Controller 的 Action 里都是典型的错误。事务是业务属性不是接口属性。同一个 Service 方法可能被多个 Controller 调用每个 Controller 各开一个事务那事务边界就乱了。正确的位置只有一个Service 的实现方法上。.NET 里事务常用 UnitOfWork TransactionScope或者在仓储层封装SaveChangesAsync。示例里 MarkSmallAmount 这种先查后改的场景必须保证“查、改、提交”在一个事务里否则中途抛异常会有部分数据半更新的风险。4.4 循环依赖分层架构最常见的设计问题之一OrderService 依赖 UserServiceUserService 又依赖 OrderService。两个 Service 互相调实际上说明职责边界可能划错了。解决办法是先看依赖是不是单向的。比如查询订单时需要用户信息那应该是 OrderService 调 UserRepository而不是调 UserService。换句话说跨 Service 调用要谨慎跨层调用 Repository 是正常操作。如果两个 Service 真的都得互相调才能完成操作考虑把共同逻辑下沉到一个更底层的服务或者引入领域服务专门编排跨领域流程。Spring Boot 3 中循环依赖默认不允许启动就报错这是好事。.NET 里构造注入的循环依赖同样在解析依赖图时就抛异常。遇到就拆别图省事把依赖注入改成属性注入来绕开检查那是埋雷。4.5 过度分层分层是工具不是目的。有些项目硬造五层Controller、Service、Manager、Gateway、Repository每层只写了一行透传代码改一个字段要动四个文件。这是典型的为了“架构感”而分层名副其实的过度设计。判断标准是你这一层有没有实质逻辑如果 Manager 的方法跟 Repository 方法长得一模一样没有任何业务编排、没有数据转换那这个 Manager 就是多余的。保持三层在需要的地方加辅助层不要预先造一堆空壳层。5. 从“能跑”到“能维护”落地检查清单与后续演进5.1 1小时上手路线图如果你之前没正经用三层架构开发过建议按这个路子来能省不少弯路前20分钟搭骨架。.NET 用户创建 Web Application Infrastructure 三个项目配好依赖注入Spring Boot 用户建好包结构写一个最简单的 Order 实体和对应的 Controller / Service / Repository先把链路跑通。中间30分钟实现一个真实业务操作。比如我上面举例的“标记小额订单”。注意把业务规则写到 Service 里Repository 只做数据查询更新Controller 只接收请求返回响应。写完这个你就掌握了三层边界。最后10分钟自查优化。检查一下有没有把业务逻辑漏在 Controller 里有没有直接把实体序列化出去事务到底标在哪个方法上——三个检查点过一遍比你写一百行代码都有用。5.2 代码审查自查清单想让这套架构在团队里长期稳定运转光靠一个人自觉是不够的。我整理了一个简易版代码审查清单每次 PR 都对照一下Controller 方法里有 if 判断业务条件吗Controller 是否直接返回了实体类Service 里是否存在两个不相关的业务操作合在一个事务里Repository 里有没有出现业务规则判断是否有 Service 之间互相调用形成循环依赖新增字段时实体、DTO、接口协议是否改动混乱任何一个问题回答“是”都需要说明理由。说不清楚就改。这套检查做完你会发现代码的“烂味”能被扑灭很多。这个清单不是一次性的建议每两个月回头看一遍。架构腐化是渐进发生的如果每次 CR 都把这几个点盯住程序的可维护性会维持在一个比较稳的水平。再有做微服务改造、上 DDD 这类后续演进你也是在一个相对健康的地基上动土心理压力小得多。6. 我踩过的坑再叮嘱一句上面讲了不少框架层面的东西最后再补两个容易忽略的细节都是实战里见过的。第一个是依赖注入的生命周期问题。.NET 的 DbContext 默认是 ScopedSpring Boot 的 Repository 底层也绑定了持久化上下文。如果你在 Repository 上贸然加 Scope 为 Singleton 的缓存或者静态字段很容易出现数据不一致。只要是分层架构数据库相关的依赖一定要跟着请求的生命周期走别自作聪明做全局共享。第二个是异步方法和事务的一个坑。.NET 的 EF Core 里如果业务方法里自己开了Task.Run那么在另外一个线程里操作 DbContext 会直接抛异常因为 DbContext 不是线程安全的。Spring Boot 的Transactional要生效必须通过代理对象调用也就是从外部调 Service 接口而不是在同类内部this直接调。这些细节你遇到了去搜一下基本上都是这个原因。三层架构这东西你说它简单它确实简单到看到就会写你说它难难的是在每一行代码里都守住那条边界线。一个小规则在 Controller 里出现当时看没什么三个月后它可能已经复制到七八个地方了。守住分层不是为了给谁看是为了让你自己三个月后改代码的时候不用骂当初的自己。别嫌麻烦把上面的步骤实测一遍比看十篇文章都强。