经常有刚入门的朋友来问我:架构文章里的“表现层、业务层、数据层”到底是什么意思?我也见过很多人把分层当成一种流程——照着目录建几个文件夹就算完事,真正写代码时还是习惯性全堆在一起。所以我打算用最直白的方式把后端分层这件事讲透。这篇内容适合刚会写增删改查、但看不懂后端项目结构的初学者,也适合被“三层架构”“MVC”这些名词绕晕的人。后端分层说到底就是一件事:把不同职责的代码分开房间住,让它们之间只通过约定好的接口往来,而不是互相扒窗户、互相牵绊。
为什么必须这么做?最直接的原因是“改动”。一个没分层的项目,功能少的时候很爽,功能一多就变成改一行牵连一片。比如登录页想加一个字段,你可能要跑到处理 HTTP 请求的函数里找到那个字段,再去数据库查询的代码里改 SQL;如果这段逻辑还被另一个接口复用,那又是一番连锁补丁。分层之后,每个改动的落点变得清晰:页面字段变了改接口层,查询规则变了改数据层,业务上加了限制改业务层,各改各的,别人也不担心你顺手动了不该动的代码。
1. 后端为什么要“分层”?先搞懂它到底解决哪个痛点
1.1 分层到底是什么意思:从一个餐厅讲起
我通常会用餐厅来打比方。后端项目就像一家餐厅:前厅负责接待客人、记录需求,后厨负责把菜做出来,采购负责找货源、备食材。如果让一个服务员既点菜又炒菜还去批发市场砍价,小摊能撑一阵子,一旦客流量上来,必然乱套。后端系统也一样,所有代码堆在一起也能跑,但功能一变多、需求一改,不拆层就是给自己找麻烦。
这里说的“层”,本质是按职责把代码切成若干块,每块只关心自己那一摊事,块与块之间通过事先定义好的接口互相配合。所谓三层架构,通常就是指接口层、业务层、数据层这三大块;在具体的后端项目里,它们往往叫 Controller/Service/DAO,或者 API/Service/Repository,名字不同,分工逻辑相同。这个方案之所以被反复使用,是经过很多项目验证的:它能让代码可维护、让新需求落点明确、让多人协作不互相踩踏。
1.2 不分层会怎么样:那些看起来很乱的后端项目
我接手过一些没有分层的遗留模块,过程非常痛苦。一个处理用户下单的函数,从参数校验开始,到拼 SQL、操作数据库、计算库存、返回提示语,全在一个方法里。往里加新需求时要睁大眼睛分辨:这段是干什么的、那段是干什么的、能不能动、动了会不会影响另一个调用方。等终于改完,测试又要费半天劲,因为整个函数依赖数据库和网络环境,本地根本没法脱离服务跑。
不分层还会让复用变成一句空话。同一个“查询用户订单”的逻辑,可能在一个 HTTP 接口里写一遍,在定时任务里又复制一遍,在后台管理页面再复制一遍。一旦查询口径改了,比如要加上“只显示未取消订单”的条件,就得把所有复制过的地方都找出来一起改。漏掉一个,数据口径就变得不一致,线上排查很麻烦。分层把这种公共逻辑收拢到业务层后,所有入口都走同一个方法,改一处就是改全局。
1.3 看懂分层之后,你能得到什么
把分层概念应用到实际项目里,你能得到几个非常具体的好处。第一,改需求时知道去哪改,不再大海捞针;第二,写代码时有明确的“该放哪个文件”的直觉,不会犹豫;第三,接口层、数据层的代码可以被替换,比如换数据库、换返回格式,不会逼着业务逻辑跟着翻车;第四,方便写单元测试,业务规则脱离 HTTP 和数据库后,本地就能验证。这篇文章后面会用一张表给出代码归属判断清单,还会从零走通一个查询接口的完整链路。这些都是可以直接抄作业的东西,不搞花架子。
2. 后端三层架构到底各管什么(附目录结构和调用顺序)
2.1 最外层的“门面”:接口层只做接收入口的事
后端最外层通常叫 Controller、API 层,有些项目也叫路由层。它的工作说起来很简单:接收请求、解析参数、凑好返回结构,完事。但很多新手的第一个分层问题就出在这里,太爱在 Controller 里写业务。
举一个典型的反例:用户下单接口,有人在 Controller 里判断“如果余额不足就返回某段提示”。当时看着没问题,结果后来消息队列里也要走扣款逻辑,那段余额判断只能复制一份过去。判断余额是业务规则,就应该住在业务层,而不是被绑在某个 HTTP 入口上。接口层要做到“薄”,它只是调用方的翻译官,不应该知道“余额怎么算”“订单状态怎么流转”。
接口层的价值在于隔离。今天项目用 HTTP 给网页提供接口,明天可能要接小程序,小程序传参格式不一样,但只要业务层方法没变,你只需要在接口层多写一个适配转换,同一套业务逻辑就能服务多个入口。薄薄的接口层才能承担这种多入口适配,如果接口层太厚,每个新入口就等于复制一遍逻辑。
2.2 中间的“大脑”:业务逻辑层管的是规则与流程
接口层往里抛请求,真正做决策的是中间的 Service 层。这一层关心“规则”和“流程”:下单时要先查库存、再扣用户余额、最后生成订单记录;退款时要校验订单状态、计算退款金额、回写库存。这些一连串动作是业务的灵魂,所以它必须和接口、数据解耦。
为什么不能把流程写在接口层或数据层?因为这两处都太“贴身”了。接口层贴着 HTTP 协议,数据层贴着数据库表结构,一旦把业务规则塞进去,规则就会被渠道和存储细节绑架。同样一条退款规则,一个接口里需要,定时任务里也需要,如果规则写在接口层,定时任务来调就很别扭;如果写在数据层,存储过程的写法会进一步把逻辑变成一串难维护的 SQL。业务层独立出来后,规则成为可以被任意入口调动的方法。换个说法,业务层才是后端真正的核心资产。
实操中还要注意,Service 的代码最好能脱离框架单测。它不直接接收 HttpRequest,也不直接操作数据库连接,所有外部依赖都通过参数或接口传入。这样你在本地写一段简单测试代码,构造几个假数据,就能验证“库存不足时是不是抛了异常”,不需要真的起服务、连数据库。
2.3 最底层的“仓库”:数据访问层只管读写
最下层是数据层,常见叫法有 DAO、Repository。它的职责很单一:负责跟数据库打交道,把数据读出来、写回去。这一层是唯一允许出现 SQL 的地方,接口层和业务层如果出现大段 SQL,基本可以断定分层不干净。
为什么要把数据操作单独拆出来?最大的好处是可替换性。项目刚开始用本地数据库,后来数据量大了换成别的数据库,SQL 语法有差异。如果 SQL 散落在各处,迁移那天就是加班日。但所有 SQL 都收在一层,且这层对上层暴露的是类似“按用户查订单”“插入新记录”这样的方法,换存储方案时基本只动这一层。
很多新手会纠结:数据层要不要再包一层 Repository?我的建议是别急着加壳,先让 Service 能通过 DAO 完成读写,遇到查询口径复杂、需要复用的场景再考虑更细的分层。给一个典型目录结构作为参考:
project/ ├── api/ # 接口层:路由、参数解析、响应组装 │ └── user_api.py ├── service/ # 业务层:业务流程、规则判断、事务边界 │ └── user_service.py ├── dao/ # 数据层:SQL、ORM操作、数据库事务辅助 │ └── user_dao.py ├── models/ # 数据模型/DTO定义 └── main.py # 启动入口、依赖组装上面的调用方向通常是从上到下:接口层拿到参数后调用业务层,业务层再调用数据层。反向关系尽量避免,比如数据层直接去取 HTTP 请求参数,或者接口层跳过业务层直接查数据库,都会让依赖关系乱成一团。守住单向链,后面排查问题会非常轻松。
3. MVC 和多层架构怎么选?新手别一上来就过度设计
3.1 三层架构和 MVC 不是一回事,别被名词绕晕
很多教程把 MVC 跟三层架构混着讲,初学者很容易被搞昏。MVC 全称是 Model-View-Controller,它更偏展示层的组织方式。View 负责用户界面,Controller 负责接收用户操作并调用 Model,Model 负责携带数据和业务规则。在三层架构视角里,Controller 就像接口层的一部分,Model 通常包含了业务和数据两层的内容。
两者讨论的维度不一样。MVC 解决的是“界面操作与逻辑怎么分离”,三层架构解决的是“后端代码按职责怎么纵向切分”,实际项目完全可以两个都用。大多数 Web 后端项目的目录是 Controller/Service/DAO,这就是用 Controller 充当接口层,用 Service 承担业务层,用 DAO 承担数据层,同时它也顺应了 MVC 的表达习惯。与其纠结名词,不如记住最终目标:职责清晰,依赖单向,改动可控。
3.2 新手最常见的毛病:为了“先进”而过度分层
我见过不少项目,为了显得架构规范,把代码拆到五六个模块。团队只有两三个人,改一个字段要跳跃三层代码,问起来却说“这是分层清晰”。这其实是本末倒置。分层的初衷是觉得复杂度太高,才用职责拆分来降低认知负担。如果项目只有两个接口,表也就两三张,继续硬套多层架构反而让简单的事变得沉重。
我的经验是先守住最小分层:Controller 管入口,Service 管业务,DAO 管数据。在此基础上出现两个特征再考虑进阶拆层。第一,Service 变得臃肿,一段流程里既有权限判断又调外部接口又做复杂查询组装,可以拆出领域服务和基础设施服务。第二,发现某些代码在多个层间重复,比如多个 Service 都要解析同一个文件格式,可以抽成独立工具模块。“按需拆”这三个字,比任何架构先进性的说法都实在。
3.3 选层时看好两个信号:变更频率和变化方向
怎么判断一个项目该分多细,行业里其实没有黄金标准,但有两个信号可以参考。第一个是变更频率。前端交互变动频繁,所以 Controller 经常改动,但只要业务层稳定,改坏的范围就可控;数据库表结构变化频繁,DAO 改动多,但上层只依赖方法签名,不会跟着 SQL 语法一起震。第二个是变化方向。如果项目未来会有多个客户端入口,那接口层一定要薄,预留适配空间;如果未来会更换数据库或接入多类存储,那数据层一定要收口。
语言说回来,别人的项目分七层,不等于你的项目也要分七层。菜的咸淡,终究要自己尝。分层的粒度应当跟着项目的实际复杂度走,而不是跟着概念的新鲜度走。
4. 从零搭一个分层 Demo:请求到数据库的完整链路
4.1 先说需求:查一个用户的历史订单
光讲概念容易飘,我拿一个最常见的例子走一遍完整链路。假设前端需要展示某个用户的最近订单列表,接口设计成 GET /user/123/orders,要求返回该用户最近二十条订单。在不分层的写法里,你很可能在一个路由函数里完成参数校验、SQL 查询、结果格式化。功能确实能跑,但过半个月再看,这个函数已经变成“面条代码”,里面什么都有,什么都改不得。分层要解决的,就是让这段简单需求从一开始就长成它应该有的样子。
这个需求看着小,但已经足够拆出三层要做的事。接口层需要判断用户 ID 格式是否合法、把订单对象转成前端能用的 JSON;业务层需要确认这个用户存在、判定“最近”的时间范围;数据层则负责真正执行那条订单查询 SQL。每一层各安其分,后面维护时才会舒服。
4.2 各层代码怎么组织,直接照着写
我以类 Python 的伪代码为例,重点在于结构,不依赖特定框架。接口层长这样:
# api/user_api.py @app.route("/user/<user_id>/orders") def get_user_orders(user_id): if not user_id or not user_id.isdigit(): return {"error": "invalid user id"}, 400 try: orders = OrderService().list_recent_orders(user_id, limit=20) except UserNotFoundError: return {"error": "user not found"}, 404 return {"data": [order.to_dict() for order in orders]}, 200请注意,Controller 里做的是格式校验和异常翻译,没有去判断“用户是否存在”以及“怎么查订单”,这些判断会交给 Service。业务层再往下:
# service/order_service.py class OrderService: def __init__(self, user_dao, order_dao): self.user_dao = user_dao self.order_dao = order_dao def list_recent_orders(self, user_id, limit=20): if not self.user_dao.exists(user_id): raise UserNotFoundError() return self.order_dao.find_recent_by_user(user_id, limit)Service 不拼 SQL,不组装 HTTP 返回,只做“查之前校验用户存在、按规则取最近订单”这件事。构造函数里传入数据访问对象是关键,这样测试时可以传假的 DAO,不需要启动数据库。数据层继续:
# dao/order_dao.py class OrderDao: def __init__(self, db): self.db = db def find_recent_by_user(self, user_id, limit=20): sql = "SELECT * FROM orders WHERE user_id = ? ORDER BY created_at DESC LIMIT ?" return self.db.query(sql, user_id, limit)看到没有,这是一个简单但分层完整的例子。Controller 只面向 HTTP,Service 只面向业务规则,DAO 只面向 SQL。哪怕以后把订单查询逻辑改走缓存,或者换一个存储方案,改动范围大概率集中在 DAO 层,接口层和业务层可以稳坐不动。
4.3 请求从浏览器到数据库再返回,链路一遍走通
把上面三个代码片段串联起来看,一次请求的完整旅程是:
- 前端发送 GET /user/123/orders,框架先找到对应的路由函数。
- Controller 检查 user_id 是不是一个数字,不合法直接返回 400,流程到此为止。
- Controller 调用 OrderService 的 list_recent_orders 方法。
- Service 先通过 user_dao.exists 判断用户是否存在,不存在就抛异常,异常被 Controller 捕获并翻译成 404。
- Service 调用 order_dao.find_recent_by_user 查询数据库。
- DAO 执行 SQL,把查询结果包装成订单对象返回。
- 结果逐层回传,Service 原样交回给 Controller,Controller 把订单对象转成 JSON 响应。
从这段链路可以看出层的边界在哪里。第 2 步和第 7 步属于接口层,第 4 步和第 5 步属于业务层,第 6 步属于数据层。如果以后想加缓存,在 Service 和 DAO 之间加也好,在 DAO 内部加也好,都有明确落点;如果想把接口改成 REST 风格,只需要改 Controller 的返回格式,其它两层不必关心。
5. 分层最容易踩的 5 个坑,我替你先踩了一遍
5.1 所有逻辑都堆在 Controller,第一个反弹就来了
新手很容易把 Controller 当成“总装车间”,代码一鼓脑全塞进去。读写数据库、拼 SQL、算价格,还返回各种提示语,Controller 越写越长,后续需求就越不敢动,因为你知道一改可能崩一个接口。我处理过类似的代码,里面有大量验证订单支付状态的逻辑,被三个接口各写了一遍。后来支付渠道调整,复制粘贴的代码没同步更新,线上出了问题排查了很久才找到根因。所以当你发现同样逻辑出现在两个及以上接口时,就应该把它提进 Service。
5.2 接口层和 DAO 绕开 Service 偷偷牵手
有些项目为了少写几行,Controller 会跳过 Service 直接调用 DAO,理由是“这个查询很简单,不需要业务规则”。短期省事,长期埋雷。之后如果这个查询需要加“只能看到自己下单的数据”这类权限限制,你会看到所有调用方都要补丁式补判断,而不是在一个 Service 方法里改一次。依赖单向性很重要:Controller 只能找 Service,Service 只能找 DAO。看起来约束多了,实际是帮未来的你省时间。
5.3 为了“灵活”疯狂加壳,项目反而没人看得懂
与“全堆在一起”相反的另一种病态是无限拆层。有的项目为了让代码看起来有企业风,把 Service 再拆成接口、实现、基础服务、抽象服务,但项目里就三五个类。最后新同事接手,每个字段的调用链绕五六层,点半天跳转才找到真正实现。我想说,分层是为降低复杂度服务的,如果拆完之后“看懂一次改动需要看更多文件”,这个分层方案就要重新审视。架构评审时优先给“让人容易看懂”的方案投票,别给“看起来高大上”的方案投票。
5.4 把事务放在 DAO 里,一个业务用例被拆成多个事务
事务边界是分层初学者最容易忽略的点。有些同学习惯把事务加在写库方法上,比如一个 DAO 的 save_order 方法、另一个 DAO 的 reduce_stock 方法,各自开启事务。实际下单过程要连续调用这两个方法,如果扣库存成功但保存订单失败,因为两个事务相互独立,库存就被悄悄扣掉了。正确做法是把事务边界放在业务层,让 Service 里的一个完整方法成为一个事务单元,要么全部成功,要么全部回滚。数据层不需要关心自己在哪个事务里,它只需要执行写操作。
5.5 模型不区分,所有层共用一张表结构
最后一个容易踩的坑是模型设计。很多同学只在 DAO 层定义一份数据模型,Controller、Service 全都直接面向数据库表字段,于是返回给前端的 JSON 变成“SELECT *”的复制品,很多不该暴露的字段也漏了出去,比如用户表的密码哈希。改善方向是把模型分开看待:数据层可以有自己的实体模型,接口层根据需求定义返回用的 DTO,业务层在两者之间做转换。小项目不强求每个接口都建 DTO,但至少要把“数据库字段”和“展示字段”当成可以不同的两件事。
排查分层问题时,我一般会顺着调用链走一遍。从入口开始,问一句:每个类收到的参数和返回的结果,是不是它这个职位该碰的东西?如果发现某个 Controller 里在拼 SQL、某个 DAO 里多了一段参数判断,那大概率就是职责越界。抓错位置往往比写错代码更耗时间,这也是分层存在的核心意义。
6. 新手照抄清单:什么代码该放哪一层
6.1 一张表搞定代码归属
我把上面这些经验整理成一张判断清单,写代码的时候照着归类就行。
| 代码场景 | 该放的层 | 简单理由 |
|---|---|---|
| 接收请求、解析路由参数、返回 JSON | 接口层 | 只负责与调用方打交道 |
| 参数格式校验(比如 ID 必须是数字) | 接口层 | 是输入输出边界的事 |
| 用户权限、余额判断、订单状态流转 | 业务层 | 属于业务规则,需要复用 |
| 一个用例里多次读写数据库的事务边界 | 业务层 | 保证一个业务流程原子完成 |
| 拼 SQL、连接数据库、执行查询更新 | 数据层 | 唯一允许碰数据库的地方 |
| 日期格式化、金额精度、状态枚举转换 | 业务层或工具类 | 不属于接口协议,也不属于存储逻辑 |
| 日志记录、异常捕获和统一错误体 | 接口层或框架中间件 | 面向调用方提供反馈 |
这张表不是铁律,但它能帮你避免大部分“代码该放哪”的选择困难。应用时记住一个原则:让每层代码只依赖下一层暴露的方法,不要跨层引用,更不要让上层直接操作底层资源。这条链保持下去,越到项目后期,分层的收益越明显。
6.2 如何把这个清单用进下一个项目
建议新人在练手项目里刻意规范一下。哪怕是几十行的小接口,也强制按 Controller/Service/DAO 写一遍,跑通了再回头看以前写过的“面条代码”。这种对比带来的真实感受,比任何架构书籍都直观。
我自己的体会是,很多人不是学不会分层,而是没经历一次被代码坑过的过程。以前我也觉得多设计一层是技术含量,后来被一个又绕又难改的项目教育之后,才真正理解“哪层出问题,改动越小,架构就越舒服”。所以如果条件允许,你完全可以把这份清单直接带到团队项目里,从下一个新接口开始,把职责边界划清楚。祝踩坑愉快,拆完这套例子后,后端项目的结构对你来说就不再是黑话了。