☰
三层架构实战:Controller、Service、Repository职责与事务边界详解
2026/10/17 12:37:07 网站建设 项目流程

直接说结论:三层架构没你想的那么玄,但它确实是目前大厂里普及度最高、新人上手最快、也最容易“抄”对的一套代码组织方式。我见过太多项目,一两年后没人敢动,改一个字段要排查半小时调用链,根子基本都在分层没做对。这篇文章不跟你扯理论,直接给你 .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 依赖 Service,Service 依赖 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 引用 Application,Application 引用 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 { Task<Order?> GetByIdAsync(long id); Task<List<Order>> 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 Task<Order?> GetByIdAsync(long id) { return await _db.Orders.FindAsync(id); } public async Task<List<Order>> 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 Task<IActionResult> 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,接口直接继承JpaRepository:

public interface OrderRepository extends JpaRepository<Order, 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)") List<Order> 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) { List<Order> 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 里用ApiResponse<T>,Spring Boot 里用Result<T>,里面放 code、message、data。

4. 实战中那些坑:分层架构最常见的5个错误

4.1 在 Controller 里写业务代码

这是最普遍的问题,没有之一。比如在 Controller 里判断库存扣减、拼接订单号、计算价格折扣。短时间看省事,不用新建 Service 方法。但只要你写了第二个接口,同样的逻辑就面临复制粘贴的选择。三个地方复制了,就是坏味道的开始。

判断标准很简单:这段代码能不能被“非 HTTP”入口复用?如果定时任务、消息队列、单元测试也想调用,那它就必须在 Service 层。

我见过一个项目,Controller 方法居然有500行,里面一半是 SQL 拼接。后来他们重构,把 Controller 里的业务代码下沉到 Service,Controller 瘦身到只剩路由和参数绑定。结果是之前不敢动的老模块,新同事也能快速上手改了。

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 依赖 UserService,UserService 又依赖 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 默认是 Scoped,Spring Boot 的 Repository 底层也绑定了持久化上下文。如果你在 Repository 上贸然加 Scope 为 Singleton 的缓存或者静态字段,很容易出现数据不一致。只要是分层架构,数据库相关的依赖一定要跟着请求的生命周期走,别自作聪明做全局共享。

第二个是异步方法和事务的一个坑。.NET 的 EF Core 里,如果业务方法里自己开了Task.Run,那么在另外一个线程里操作 DbContext 会直接抛异常,因为 DbContext 不是线程安全的。Spring Boot 的@Transactional要生效,必须通过代理对象调用,也就是从外部调 Service 接口,而不是在同类内部this直接调。这些细节你遇到了,去搜一下基本上都是这个原因。

三层架构这东西,你说它简单,它确实简单到看到就会写;你说它难,难的是在每一行代码里都守住那条边界线。一个小规则在 Controller 里出现,当时看没什么,三个月后它可能已经复制到七八个地方了。守住分层,不是为了给谁看,是为了让你自己三个月后改代码的时候不用骂当初的自己。别嫌麻烦,把上面的步骤实测一遍,比看十篇文章都强。

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

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

立即咨询