从三层架构到清晰边界:一套更适合复杂 Java 服务的分层方法
很多 Java 后端项目都会从三层架构起步:
text
Controller -> Service -> Repository
这种结构简单、直接、容易上手。 Controller 负责接收请求,Service 负责处理业务,Repository 负责访问数据库。
在简单 CRUD 项目里,这套结构非常高效。一个接口从 Controller 到 Service 再到 Repository,链路清楚,开发成本低,新人也容易理解。
随着业务规模扩大,三层架构的边界会逐渐显得粗糙。尤其在多入口、多模块、多外部依赖、复杂状态流转的系统中,传统三层结构很容易把过多职责压到 Service 层,最终形成难维护的"大服务类"。
这篇文章介绍一套更适合 Java/Spring 后端服务的分层方式。它不追求复杂概念,重点解决几个实际问题:
业务规则放在哪里? Controller 应该做多少事情? Service 膨胀后怎么拆? 数据库、缓存、RPC、MQ 的技术细节如何隔离? HTTP、消息、事件等多个入口如何复用同一套业务逻辑? 多模块之间如何协作,才能避免互相依赖内部实现?
一、传统三层架构的常见问题
三层架构的优点很明显:结构简单,开发快,适合轻量业务。
它的问题通常不会在项目早期暴露。真正的麻烦会出现在业务持续增长之后。
1. Service 容易变成"大杂烩"
在传统三层架构中,Service 往往承担最多职责。
一个典型的 UserService 可能同时做这些事情:
参数校验; 权限判断; 事务控制; 状态流转; 数据库读写; 缓存刷新; RPC 调用; MQ 发送; DTO 转换; 异常处理; 审计日志。
刚开始这些逻辑放在一起还算方便。随着功能变多,Service 会越来越长,方法越来越多,分支越来越复杂。
最后它变成一个"上帝类":什么都知道,什么都能做,任何修改都可能影响其他逻辑。
这种代码最明显的表现是: 开发新功能时,只能继续往 Service 里加方法; 修改老功能时,很难判断某段逻辑是否被其他入口复用; 代码冲突频繁,测试也难写。
2. 业务规则容易散落
简单项目里,把业务规则写在 Service 中问题不大。
复杂项目里,一个业务动作可能来自多个入口:
页面点击按钮; 后台任务触发; MQ 消息触发; 领域事件触发; 内部模块调用。
如果没有清晰的边界,同一条业务规则很容易散落在多个地方。
例如订单支付规则可能出现在:
Controller 参数处理里; Service 的 if 判断里; 消息消费逻辑里; Repository 查询条件里; 某个定时任务里。
一旦这些地方各自演进,就会出现规则不一致。
HTTP 接口拒绝了某种状态,消息消费却允许; 页面操作做了权限判断,后台任务绕过了判断; A 模块认为某个状态可修改,B 模块认为不可修改。
这类问题排查成本很高,因为系统中没有一个明确位置表达"真正的业务规则"。
3. 技术细节容易污染业务代码
传统 Service 经常直接依赖各种技术组件:
java
JpaRepository
RedisTemplate
FeignClient
KafkaTemplate
RabbitTemplate
EntityManager
这种写法短期很方便,代码也少。
长期看,业务流程会和具体技术绑定在一起。
例如:
持久化方案想从 JPA 换成 MyBatis; 远程调用想统一加超时、重试、熔断; 消息发送想改成事务事件发布; 缓存逻辑想统一封装失效策略; 数据库异常想转换成稳定业务错误语义。
如果 Service 里到处都是具体 SDK 和基础设施类型,替换或治理的成本会非常高。
业务代码会被技术细节拖住,架构演进也会变慢。
4. 多入口复用困难
传统三层架构通常默认入口来自 Controller。
现实系统中,入口经常不止 HTTP。
比如"创建用户"这个动作,可能来自:
管理后台 HTTP 请求; 导入任务; MQ 消息; 内部模块调用; 测试工具或运维脚本。
如果没有专门的入站适配层,不同入口容易出现两种问题。
第一种问题:每个入口各写一套业务逻辑。 这样会导致重复代码和规则漂移。
第二种问题:其他入口直接借用 Controller 方法。 这样会导致协议层和业务层耦合。
更好的方式是:所有入口都只负责适配输入,然后委托同一个应用用例。
5. 多模块之间容易互相越界
单模块项目中,三层架构还比较容易维持。
到了大型多模块工程,边界问题会明显放大。
常见错误包括:
模块 A 直接调用模块 B 的 Repository; 模块 A 直接使用模块 B 的 Entity; 模块 A 直接 JOIN 模块 B 的业务表; 模块 A 直接依赖模块 B 的 adapter 实现; 模块 A 把模块 B 的内部 DTO 当成自己的业务模型。
这些写法一开始很省事,后面会让模块边界失效。
模块之间从"通过契约协作"变成"互相读取内部实现"。 一旦被依赖模块要重构,调用方也会被迫跟着改。
二、新分层方案的核心思路
为了让复杂度有清晰归属,可以把传统三层进一步拆成下面几类职责:
text
adapter.in -> application -> domain
adapter.out -> application.port.out
configure -> 只做装配
对应目录可以这样组织:
text
com.yourorg.<business_domain>
├── domain
│ ├── model
│ ├── service
│ ├── event
│ ├── rule
│ └── error
├── application
│ ├── usecase
│ ├── query
│ ├── port
│ │ ├── in
│ │ └── out
│ ├── dto
│ ├── assembler
│ └── error
├── adapter
│ ├── in
│ │ ├── web
│ │ ├── event
│ │ ├── message
│ │ └── internal
│ └── out
│ ├── persistence
│ ├── cache
│ ├── rpc
│ ├── mq
│ └── security
└── configure
这套结构的核心价值很简单:
入口适配有固定位置; 业务流程有固定位置; 业务规则有固定位置; 外部技术实现有固定位置; Bean 装配有固定位置。
每种复杂度都能找到自己的家。
三、各层分别负责什么
1. domain:放业务规则和不变量
domain 是领域层,负责表达核心业务规则。
例如:
订单只有在 CREATED 状态下才能支付; 冻结租户不能创建新资源; 用户组不能删除最后一个管理员; 优惠券过期后不能继续核销。
这些规则应该放在领域模型或领域服务中。
示例:
java
public void pay(long now) {
if (this.status != OrderStatus.CREATED) {
throw new DomainException("Only CREATED order can be paid");
}
this.status = OrderStatus.PAID;
this.paidAt = now;
}
domain 层应该尽量保持纯粹。
它不直接访问数据库; 不直接调用 RPC; 不直接发送 MQ; 不读取 HTTP 请求对象; 不读取 SecurityContextHolder; 不依赖 Spring MVC、Redis、Kafka 等技术细节。
领域层只回答一个问题:业务规则是否成立。
2. application:编排业务流程
application 是应用层,负责组织一次完整业务操作。
它通常分成两类:
text
application.usecase 写入用例
application.query 查询用例
写入用例负责状态变化,通常带事务。
java
@Service
@RequiredArgsConstructor
public class CreateOrderUseCase {
private final OrderRepository orderRepository;
@Transactional
public CreateOrderResult create(CreateOrderRequest request) {
Order order = Order.create(request.customerId(), request.items());
Order saved = orderRepository.save(order);
return new CreateOrderResult(saved.getId());
}
}
查询用例负责只读查询。
java
@Service
@RequiredArgsConstructor
public class UserQueryService {
private final UserRepository userRepository;
public PageInfo<UserListItem> page(Paging paging, UserQueryRequest request) {
return userRepository.page(paging, request);
}
}
应用层可以调用领域对象完成规则判断,也可以调用出站端口访问外部资源。
它负责流程,不负责具体技术实现。
3. application.port.out:定义外部能力边界
应用层访问数据库、缓存、RPC、MQ 时,不直接依赖具体实现。
它先定义一个端口接口:
java
public interface OrderRepository {
Order save(Order order);
}
这个接口表达的是业务需要的能力:保存订单。
应用层不关心底层用 JPA、MyBatis、MongoDB,还是其他技术。
这样可以让业务流程和技术实现解耦。
4. adapter.out:实现外部技术适配
adapter.out 负责实现 application.port.out 定义的接口。
例如 JPA 实现:
java
@Component
@RequiredArgsConstructor
class JpaOrderRepositoryAdapter implements OrderRepository {
private final SpringDataOrderRepository repository;
@Override
public Order save(Order order) {
return repository.save(order);
}
}
数据库、缓存、RPC、MQ、安全能力都可以放在这里。
这一层的价值是封装技术细节。
应用层只看到业务端口,具体 SDK、异常转换、协议适配、驱动细节都留在出站适配器中。
5. adapter.in:统一管理所有入口
adapter.in 是入站适配器,负责接收外部输入。
常见入口包括:
text
adapter.in.web HTTP Controller
adapter.in.message MQ 消费
adapter.in.event 事件监听
adapter.in.internal 内部契约实现
Controller 示例:
java
@RestController
@RequiredArgsConstructor
class OrderController {
private final CreateOrderUseCase createOrderUseCase;
@PostMapping("/orders")
public CreateOrderResult create(@RequestBody CreateOrderRequest request) {
return createOrderUseCase.create(request);
}
}
这一层主要做:
协议解析; 参数校验; 上下文提取; 权限入口语义解析; 调用 application; 返回协议对象。
它不写核心业务规则,也不直接访问数据库适配器。
6. configure:只负责装配
configure 负责 Bean 装配、配置绑定、实现选择、开关启停、框架集成。
例如:
根据配置选择某个 Adapter 实现; 绑定 @ConfigurationProperties; 注册运行时 hints; 组织 Spring Bean。
业务流程不放在这里。
如果某段逻辑依赖 tenant、user、status 等业务语义,它应该回到 application 或 domain。
四、依赖方向是这套架构的关键
目录结构只是外在形式,依赖方向才是核心约束。
推荐依赖方向:
text
adapter.in -> application -> domain
adapter.out -> application.port.out
configure -> wiring only
几条规则需要严格遵守:
domain 不依赖 application、adapter、configure。 application 不依赖 adapter.out 的具体实现。 adapter.in 不绕过 application 直接调用 adapter.out。 adapter.out 实现 application.port.out。 configure 只做装配,不写业务分支。
这样可以避免代码形成网状依赖。
请求从入口进入,经过应用层,调用领域规则,再通过端口访问外部资源。 整个方向清晰,修改影响范围也更容易控制。
五、这套架构相比三层架构的优势
1. Service 膨胀得到缓解
传统 Service 中混杂的职责被拆开:
入口协议放到 adapter.in; 流程编排放到 application.usecase; 业务规则放到 domain; 外部技术实现放到 adapter.out; 装配选择放到 configure。
每一类代码都有明确位置,Service 大杂烩的问题会明显减少。
2. 业务规则更加集中
状态流转、不变量校验、领域决策放到 domain。
多个入口调用同一个 usecase,再由 usecase 调用同一套领域规则。
这样可以减少复制逻辑,降低规则漂移风险。
3. 技术实现更容易替换
应用层只依赖端口接口。
数据库实现、缓存实现、RPC 实现、消息实现都可以在 adapter.out 中替换。
当技术选型变化时,核心业务流程受到的影响更小。
4. 多入口复用更自然
HTTP、MQ、事件、内部契约都属于入站适配器。
它们只做输入转换和委托调用,核心流程统一放在 application。
这样新增入口时,可以复用已有业务用例。
5. 多模块协作更可控
跨模块稳定协作走 internal-api; 状态传播走 event; 外部开放能力走 openapi; 业务实现留在各自领域模块。
模块之间通过契约协作,减少直接依赖 Entity、Repository、adapter 实现的情况。
6. 测试更容易分层
不同层可以采用不同测试策略:
domain 做纯单元测试,验证业务规则; application.usecase mock port.out,验证流程编排; adapter.out 做集成测试,验证数据库、缓存、RPC、MQ 适配; adapter.in 做接口测试,验证协议、参数和错误模型。
测试目标更聚焦,问题定位也更快。
六、简单 CRUD 可以保持轻量
这套架构并不要求所有功能都拆得很细。
普通 CRUD 可以使用最小结构。
以"创建用户"为例,通常只需要:
text
domain.model.User
application.dto.CreateUserRequest
application.dto.CreateUserResult
application.port.out.UserRepository
application.usecase.CreateUserUseCase
adapter.out.persistence.JpaUserRepositoryAdapter
adapter.in.web.UserController
这些内容已经可以形成完整闭环:
Controller 接收请求; UseCase 编排流程; Domain 表达规则; Repository 端口定义保存能力; Persistence Adapter 实现数据库写入。
很多情况下,可以先不抽 application.port.in; 字段完全一致时,可以复用 application DTO; 转换很简单时,可以先不引入 Assembler; 简单查询可以直接返回 application 查询 DTO。
分层架构应该帮助团队控制复杂度,不能变成制造样板代码的工具。
七、什么时候需要升级设计
随着业务变复杂,可以逐步引入更完整的结构。
出现以下情况时,建议升级:
同一个能力有多个入口,比如 Web、消息、内部契约; 某个能力需要被其他模块长期稳定调用; 业务出现复杂状态机或多个不变量联动; 一个用例需要编排多个外部系统; DTO 和领域模型差异持续扩大; 某个类不断膨胀,团队修改冲突频繁; 跨模块调用开始出现网状依赖风险。
这时可以增加:
application.port.in 作为稳定输入契约; application.assembler 处理模型转换; adapter.in.web 专属 Web DTO; *-internal-api 作为跨模块内部契约; *-event 作为跨模块事件契约; *-openapi 承接公共外部 HTTP 契约实现。
原则很简单: 简单场景保持轻量,复杂场景及时收敛边界。
八、新功能落地示例
假设要新增"创建订单"功能,可以按下面顺序落地。
第一步,定义领域模型:
text
domain.model.Order
订单状态、创建规则、支付规则、取消规则都可以放在这里。
第二步,定义应用层 DTO:
text
application.dto.CreateOrderRequest
application.dto.CreateOrderResult
第三步,定义出站端口:
text
application.port.out.OrderRepository
第四步,实现写入用例:
text
application.usecase.CreateOrderUseCase
第五步,实现数据库适配:
text
adapter.out.persistence.JpaOrderRepositoryAdapter
第六步,提供 HTTP 入口:
text
adapter.in.web.OrderController
完整链路如下:
text
adapter.in.web
-> application.usecase
-> domain
-> application.port.out
-> adapter.out.persistence
这条链路既能支持 HTTP 请求,也能支持后续消息入口或内部调用入口复用同一个业务用例。
九、常见错误写法
1. Controller 中写业务规则
错误表现:
Controller 里判断订单状态、用户权限、资源归属。
推荐做法:
Controller 只做协议适配和参数校验。 状态流转放到 domain。 流程编排放到 application。
2. UseCase 直接依赖 JPA Repository
错误表现:
java
private final SpringDataUserRepository repository;
推荐做法:
UseCase 依赖 application.port.out.UserRepository。 JPA Repository 留在 adapter.out.persistence。
3. domain 直接调用数据库或 MQ
错误表现:
领域对象里注入 Repository、Client、MQ Template。
推荐做法:
domain 只表达业务规则。 外部资源由 application 通过 port.out 协调。
4. adapter.out 偷偷读取用户和租户上下文
错误表现:
持久化适配器中读取 SecurityContextHolder,然后自行拼租户过滤条件。
推荐做法:
入口层解析用户和租户。 应用层组织数据范围。 持久化层只执行传入条件。
5. configure 中写业务分支
错误表现:
配置类根据用户、租户、状态决定业务流程。
推荐做法:
configure 只做 Bean 装配和技术选择。 业务决策进入 application 或 domain。
十、落地检查清单
新增或改造一个功能后,可以用下面的问题自查:
业务规则是否集中在 domain? 写入流程是否位于 application.usecase? 查询逻辑是否位于 application.query? Controller 是否只调用 application? application 是否通过 port.out 访问外部资源? adapter.in 是否没有直接调用 adapter.out? 事务边界是否放在 usecase? 用户、租户、权限是否由入口解析并显式传入? 持久化层是否没有自行读取请求上下文? 跨模块调用是否通过契约完成? 关键领域规则是否有单元测试? 出站适配是否有必要的集成测试? CI 是否能阻断反向依赖?
这些问题可以帮助团队持续维护架构边界。
结语:让复杂度各归其位
传统三层架构适合简单 CRUD。项目规模扩大后,它容易让 Service 承担过多职责,进而引发规则散落、技术污染、多入口重复、多模块越界等问题。
更细的分层可以把这些复杂度拆开:
domain 管业务规则; application 管流程编排; adapter.in 管入口适配; adapter.out 管外部技术; configure 管装配连接。
这套结构的目标很朴素: 让开发者知道代码该放哪里,让修改影响范围更可控,让复杂系统在长期演进中保持清晰边界。
简单功能按最小结构落地。 复杂功能逐步升级边界。 团队真正需要的架构,应该同时兼顾开发效率和长期可维护性。