做过几年后端,面试候选人的时候我常问一个问题:Order这个类,在你的项目里到底代表什么?大部分人会愣一下,然后说“就是订单表映射出来的实体啊”。再追问一句:“那它的状态流转、金额校验这些业务规则放哪里?”答案往往就变成了Service层。这个对话几乎每天都能在不同团队里重演一遍,背后的根源就是把entity、model、domain这三个词混为一谈。今天我把它们彻底掰开揉碎讲清楚。
这三者不是同一层次的东西,也不是可以互相替换的称呼。它们分别对应了存储视角的数据结构、业务视角的对象模型和问题空间的边界划分。搞不清这个区别,代码短期内能跑,但一旦业务复杂起来,改需求就是一场灾难。这篇文章我会用同一个订单系统案例贯穿始终,从概念起源讲到代码落地,再结合我真实踩过的坑,把这几个词一次性说透。
1. 先搞清楚:这三个词为什么会让人头大
1.1 每个词在不同的技术语境下含义完全不同
先说一个扎心的事实:这三个词在软件开发的不同流派里,指向的东西本来就不同,甚至同一个词在不同框架里的含义都南辕北辙。
先说domain。这个词在两套语境里出现频率最高:一套是领域驱动设计(DDD),它指的是“业务领域的边界与知识”,比如订单系统里涉及下单、支付、库存这些业务领域;另一套是网络领域,它指的是“域名”,比如example.com就是一个domain。如果你在搜索框里敲domain is disabled、url not in domain list之类的内容,看到的报错往往跟网络域名、跨域访问有关,跟这篇文章要讲的业务领域没有任何关系。这个词的“一词多义”问题,本身就给不少新人制造了第一层认知障碍。
再看entity。它的歧义同样不小。在ORM 框架(比如 JPA、Hibernate、Django ORM、EF Core)的语境下,Entity 被翻译成“实体类”,通常与数据库表一一对应;而在DDD的语境下,Entity 特指“有唯一标识、有生命周期、状态可变的领域对象”。这两者名字一样,职责却大不一样——前者关心的是怎样把一行行数据库记录映射成 Java/Python 对象,后者关心的是怎样在代码里表达业务规则。
最后是model。这个词大概是计算机领域被用得最滥的单词之一。在 MVC 架构里,Model 是负责数据和业务规则的“模型层”;在 ORM 语境下,Model 等同于数据库表映射,比如 Django 里的 Model 就是个典型;在机器学习领域,Model 是训练好的神经网络参数;在偏微软系的团队里,Model 还经常被当成 DTO(数据传输对象)来用。同一个词,到了不同团队嘴里,意思可能完全相反。
三个词,每一个都自带多重含义,叠加在一起,不混乱才奇怪。
1.2 三者的混用会带来什么实际后果
概念混乱不只是讨论问题时费口水,它会直接污染代码质量。我见过最典型的“病案”是这样一种项目:数据库里有一张order表,团队用 ORM 生成一个OrderEntity,然后所有业务代码——包括状态判断、金额计算、优惠策略——全部写在OrderService里。这个OrderEntity从数据库层一路被传到控制层,再直接交给前端渲染。开发同学会告诉你:“这就是我们的 entity,也是我们的 model,domain包?我们没建过这个包。”
这套写法在项目初期确实没什么问题,订单表就那三五个字段,Service 也就几十行逻辑。但业务一旦膨胀,比如订单要支持拆单、退款、改价、多级审批,Service 里的方法会从几个膨胀到几十个,每个方法里先查表、再改字段、再存回去,代码里到处是if (order.getStatus() == 1)这种魔法数字判断。到这一步,基本就进坑了:因为没有真正的领域模型去承载业务规则,所有规则都散落在流程代码里,改一个需求要同时改 Service、Entity、Controller 三层,测试更是无从下手。
这就是我为什么坚持写这篇文章:entity、model、domain的区分,不是学术洁癖,而是直接决定你项目在业务复杂到一定程度之后还能不能健康演进的关键。
2. domain:业务世界的边界与语言
2.1 领域不是代码,是问题空间的边界
先讲domain。在 Eric Evans 那本《领域驱动设计》里,domain的定义其实有点抽象:它是“组织所从事的业务活动及其涉及的知识、规则和流程”。说白了,domain回答的是“你在这个系统里到底要解决什么业务问题”,而不是“你用什么技术方案解决”。
一个电商系统的 domain 包括:怎么定义一张订单、订单状态怎么流转、退款有什么限制条件、库存扣减的规则是什么。这些规则属于问题空间——它们是业务本身的规律,不是你写代码之前拍脑袋定的,而是业务专家脑子里的既有知识。Domain 建模做得好的团队,代码里的名词和业务专家口中的名词是一一对应的。业务方说“已支付订单才能申请退款”,代码里就应该有一个Order对象,它知道自己的状态是PAID,并且能根据状态判断“允不允许退款”。
用一个生活化的类比:如果你要盖一栋房子,domain是“你打算盖一个几口人住的房子、每个房间的用途是什么、生活动线怎么走”。这跟“用钢筋混凝土还是轻钢龙骨”没有关系,也跟“水管怎么走、电线怎么排”没有关系。很多团队恰恰反过来了,一开始就讨论数据库表怎么建、接口怎么设计,把“这房子到底给谁住”这个最根本的问题抛在脑后。
2.2 限界上下文:一个 domain 不够,要拆成多个
DDD 里还有个关键概念叫限界上下文(Bounded Context)。一个大型业务系统往往不是单一领域,而是多个子领域互相协作。同样一个“订单”,在“下单上下文”里,它关心的是商品、金额、收货地址;在“仓储上下文”里,它关心的是哪些商品需要拣货、出库;在“财务上下文”里,它关心的是应收多少钱、什么时候到账。
在同一个系统里,把所有这些关注点都塞进一个Order对象里,这个类就会变成一个大泥球。正确的做法是先划清边界——每个限界上下文有自己独立的领域模型和数据库,上下文之间通过接口或消息通信,而不是直接共享一张表。这个“划分领域边界”的动作,就是建模阶段最重要的工作。
现在你应该能理解为什么domain不是一个代码包名那么简单了。domain本身是一种边界意识:它告诉你哪些问题在范围内、哪些在范围外,以及边界内的语言和规则是什么。
2.3 领域层的正确姿势:不依赖任何外部框架
落到代码上,一个健康的domain层长这样:
com.example.order ├── domain │ ├── model │ │ ├── Order.java │ │ ├── OrderItem.java │ │ ├── OrderStatus.java │ │ └── Money.java │ ├── service │ │ └── OrderDomainService.java │ └── repository │ └── OrderRepository.java └── infrastructure ├── persistence │ └── OrderRecord.java └── repository └── OrderRepositoryImpl.java注意观察domain包里有什么、没有什么。里面有描述业务的实体(Order、OrderItem)、值对象(Money)、枚举(OrderStatus)、领域服务(OrderDomainService)、仓库接口(OrderRepository)。它没有任何 Spring 注解、没有 JPA 注解、没有 JSON 序列化注解、没有继承什么框架基类。这一层是纯 Java/Python,不依赖任何外部框架,连数据库都感知不到。
为什么这么强调?因为domain层的职责是表达业务规则,而业务规则是系统里最稳定、最需要被精心维护的东西。如果让领域层依赖了 ORM、依赖了 Spring Boot 3 的注解、依赖了某个具体消息队列的 SDK,那么框架升级、换数据库版本、换消息中间件,都会波及到核心业务逻辑。把最不稳定的技术细节和最稳定的业务规则耦合在一起,是架构上最亏本的一笔交易。
3. entity:有身份、有生命周期的业务对象
3.1 实体不等于“有主键的表记录”
在 DDD 语境下,entity的定义十分明确:一个拥有唯一标识、在生命周期内状态不断变化、并且可以被追踪的对象。
这句话有几个要点。第一,实体必须有唯一标识。这个标识一旦赋予,就不会改变。比如一个订单的订单号NO.202501010001,从下单到完成到归档,它始终是同一个订单,靠的就是这个标识;第二,实体是可变的——一条订单从CREATED变成PAID、再变成SHIPPED,状态在变,但它还是同一个实体;第三,实体可以被追踪,因为它在系统里有连续的生命周期,你可以问它“你现在的状态是什么”。
与实体相对的是值对象(Value Object):没有标识、不可变、靠属性值来判定相等。比如订单里的Money(金额),100 块就是 100 块,两个金额只要数值相等就是同一个东西,不需要给它分配一个 ID。再比如Address(收货地址),结构相同就算相等。实体和值对象的区分,是 DDD 战术建模的入门基本功。
很多人容易把“数据库表里有主键”等同于“实体有身份”,这是一个深坑。数据库主键是物理层面的存在,为了满足关系数据库唯一性约束而存在;实体标识是业务层面的存在,为了回答“你是谁”而存在。两者多数情况下可以对应,但完全可以脱钩——比如用雪花算法生成的分布式 ID 作为数据库主键,和业务上使用的订单号,就经常是两套体系。
3.2 贫血实体和充血实体的那张“天壤之别”
实体怎么设计,业界有两种典型风格,差距非常大。
贫血模型下的实体,本质是一个字段容器:一堆private属性和一堆getter/setter,没有任何业务行为。状态判断写在 Service 里,金额计算也写在 Service 里。这种实体看起来简洁,但业务规则全跑到了外部。网上有个经典说法叫“贫血模型是反模式”,因为它把面向对象的封装彻底抛弃了。
充血模型下的实体,把自身状态相关的业务规则内聚到自己身上。订单要取消?order.cancel()自己判断当前状态允不允许取消,而不是 Service 里写if (order.getStatus().canCancel()) { ... }。这种实体的方法名直接翻译业务语言,读代码就像读业务规则说明书。
我个人的实践经验是:不用极端充血,但至少要保证“跟单个实体自身状态强相关的不变量和状态流转”放在实体内部。比如订单的取消、确认、完成,这些只跟订单自身状态有关系的行为,必须做成order.cancel()这样的方法。至于“计算整单优惠价”这种需要读一堆配置、可能还要调外部服务的能力,放在领域服务OrderDomainService里更合适。这样既不会把实体弄成上帝类,也不会把实体降级成一个纯粹的 getter/setter 容器。
3.3 ORM 里的 Entity 和领域里的 Entity 不是一回事
这是全篇最容易踩坑、也最需要重点理解的部分。在你用 JPA 或者 Hibernate 的时候,@Entity注解标注的类是持久化实体,它关心的是数据库表怎么映射成对象;而在 DDD 语境下,domain 包里的Order是领域实体,它关心的是业务规则怎么表达。
举一个具体例子。持久化实体OrderEntity需要有一个version字段配合乐观锁,还需要有一个created_by字段记录创建人,这些字段数据库表必须有;但领域实体Order的业务方法根本用不到version,它也未必关心created_by是谁。如果让领域实体直接复用持久化实体,就会出现这样的情况:领域方法处理完业务后,顺手把version改了,你都不知道是业务逻辑改的还是乐观锁机制改的;又或者你为了数据库约束方便,在领域实体里加了一个tenantId字段,结果业务代码里到处都要传这个跟业务无关的字段。
所以我推荐的做法是:持久化对象和领域对象分离,通过一个 Assembly/Converter 做互相转换。持久化对象只负责把一行数据库记录映射成内存对象;领域实体只负责业务规则。转换逻辑放在基础设施层,不污染领域层。至于转换那点性能开销,在绝大部分业务场景下完全可以忽略。
4. model:一个被用滥到无法独立判断含义的词
4.1 Model 的本义:MVC 里的那一层
说到model,最绕不开的是 MVC(Model-View-Controller)架构。在这个架构里,Model 指的不是某个具体的类,而是负责数据处理和业务规则的那一个层。Controller 接收请求并把请求转给 Model,Model 处理完业务后把结果交给 View 展示。设计意图上,Model 层是应用的核心,View 和 Controller 反而只是它的外壳。
但现实是,框架把 Model 这个词“偷走”了。以 Django 为例,models.py里的每个类都继承自django.db.models.Model,这个类既承担了数据表映射的职责,也被倡导用来放置业务逻辑。Ruby on Rails 更是如此,ActiveRecord 模式直接把数据访问和业务逻辑塞进同一个 Model。Spring Data JPA 虽然没有用 Model 这个词,但它背后的 Repository 加 Entity 的组合,在语义上又被很多人称为 Model。
这样一来,MVC 的“Model = 业务逻辑层”的本义,在实际工程里慢慢被窄化成了“Model = 数据表映射类”。你很难全怪框架,因为简单业务下这套模式真的够用。但一旦业务复杂,Model 类既映射数据库表又要承载业务行为,就会面临一个两难:要么为了塞进业务逻辑让 Model 变成一个啥都干的上帝类,要么为了保持模型纯净把所有逻辑又挪到 Service 里,Model 又空壳化。这就是第 3.2 节描述的贫血模型困境的另一个入口。
4.2 数据模型、领域模型、视图模型:名字相似,职责完全不同
要厘清model这个词,最实用的办法就是把 model 前面的限定词找出来,看它到底在说哪个“模型”。
第一个是数据模型(Data Model 或 Persistence Model)。它对应数据库表,javax.persistence 注解的一大堆字段,只管持久化,不管业务。第二个是领域模型(Domain Model)。它由一组实体、值对象、聚合组成,是第 2 节和第 3 节讲的 domain 和 entity 的合体。第三个是视图模型(View Model)或者叫 DTO(Data Transfer Object)。它服务于接口层,决定返回给前端什么字段、什么结构,不关心数据库,也不关心业务规则,只关心传输和展示。
很多团队在项目里只分两个包:entity和vo。前者直接对应数据库表,后者直接对应接口返回值。业务逻辑全部堆在 Service 里,所谓domain也只是 Service 里的一个文件夹,里面塞了一堆*Manager类。这种简化在业务简单的阶段没问题,但与此同时你也把领域建模的工作省掉了——一旦业务复杂起来,Service 就是那个最先爆掉的地方。
4.3 什么时候 Model 可以是 Entity?什么时候必须分开?
有一种情况,Model 和 Entity 可以是同一个类:项目足够简单,表就是业务对象,业务对象就是表,没有复杂的规则,没有多态,没有状态机。这时候你强行拆出领域模型层,反而显得过度设计,代码文件数量翻倍,维护成本上升。
但一旦出现以下信号,就必须考虑分离:
- 一个业务对象对应多张表(比如订单和订单明细),或者一个 Model 类要聚合来自不同表的数据;
- 业务对象在生命周期里有明确的状态流转,而且不同状态下的行为不同;
- 出现了值对象、枚举等不足以用“表字段”直接表达的业务概念;
- 你开始往 Model 里写
@JsonIgnore、@TableField这类注解,同时又在同一个类里写业务方法; - 团队约定接口层不能直接暴露数据库实体,但你又没想好该暴露什么。
碰到以上任何一条,都是从“一个 Model 走天下”切换到“领域对象与持久化对象分离”的信号灯。别等代码烂到改不动了再动,那时候重构成本已经是写代码成本的十倍了。
5. 用一个订单系统案例把三者彻底串起来
5.1 同一个订单,在三个层面的不同长相
前面讲了那么多理论,这里用一个贯穿始终的订单案例,把数据、模型、领域三个层面对应关系完整铺开。
| 层面 | 类名示例 | 核心职责 | 关键特征 |
|---|---|---|---|
| 领域层(Domain) | Order、OrderItem、OrderStatus、Money | 表达订单业务规则:状态流转、金额计算、取消约束 | 有业务方法、依赖领域服务、不感知数据库 |
| 持久化层(Entity/Data Model) | OrderRecord、OrderItemRecord | 映射数据库表结构,负责读写 | 有 ORM 注解、字段对应列、无业务逻辑 |
| 应用/接口层(Model/DTO) | OrderDTO、CreateOrderCommand | 定义接口入参出参,与前端约定 | 干净字段结构、可序列化、可带校验注解 |
这三层之间通过显式的转换代码打通。领域层Order接到一个“取消订单”的指令,正常流程是:Controller 收到请求,转成CancelOrderCommand,应用服务查询OrderRepository得到Order,调用order.cancel(),如果返回成功,再通过转换器把Order转成OrderRecord存库,同时把OrderDTO返回给前端。
整个过程里,每一层的类都只做自己该做的事。数据库字段变化只影响OrderRecord;接口结构调整只影响OrderDTO;业务规则的修改只影响Order。三个类虽然名字上都带着 order,但改动彼此之间的涟漪被切开了。
5.2 依赖方向永远是从外向内,不能让领域层反过来依赖数据模型
分层架构里最核心的一条铁律是依赖方向:外层可以依赖内层,内层绝对不能依赖外层。
在这个案例里,依赖链是这样排的:接口层(Controller/DTO)依赖应用层(Application Service),应用层依赖领域层(Domain),领域层只依赖它自己,而基础设施层(Persistence/Repository 实现)去实现领域层定义的仓库接口。
很多项目反着来:Controller 直接操作OrderRecord,业务写在 Service 里,Order只是一个空壳甚至根本不存在。一旦数据库表加了字段,Service 的代码和 Controller 的逻辑也要跟着改;更糟糕的是,业务逻辑跟 ORM 注解搅在一起,导致单元测试还要启动 Spring 容器,才能测一个纯计算逻辑。这种耦合是团队效率的大敌。
正确做法是:OrderRecord再丑再乱,它也出不了基础设施层;Order再干净,它也绝不 import 任何一个数据库相关类。有这个约束在,你才可以在不启动数据库的情况下,对Order.cancel()写单元测试,直接构造一个 Order 对象、设置状态、调用方法、断言结果,全过程几毫秒就完成。这才是分层真正的价值。
5.3 一段代码演示贫血与充血的差异
先看贫血写法的典型模式:
// 贫血模型:Order 只是数据的容器 public class Order { private String id; private String status; // "CREATED", "PAID", "CANCELLED" private BigDecimal amount; // getter/setter 省略 } @Service public class OrderService { public void cancel(String orderId) { Order order = orderRepository.findById(orderId); // 业务规则散落在 Service 里 if (!"CREATED".equals(order.getStatus())) { throw new IllegalStateException("只有待支付订单可以取消"); } order.setStatus("CANCELLED"); orderRepository.save(order); } }再看充血写法的典型模式:
// 充血模型:Order 自己知道自己的状态规则 public class Order { private String id; private OrderStatus status; private Money amount; public void cancel() { if (status != OrderStatus.CREATED) { throw new IllegalStateException("只有待支付订单可以取消"); } this.status = OrderStatus.CANCELLED; } } @Service public class OrderService { public void cancel(String orderId) { Order order = orderRepository.findById(orderId); order.cancel(); // Service 不关心具体规则 orderRepository.save(order); } }对比下来能看得很清楚:贫血版本里,cancel的规则是在 Service 里拼出来的,以后再出一个“已支付订单可以取消但需要审核”的逻辑,Service 方法会越来越长;充血版本里,规则写在Order自己身上,OrderService只是协调仓库和实体,以后加规则只在Order内部扩展。代码的可读性、可测试性,高下立判。
6. 真实项目中的命名规范与落地建议
6.1 一套能直接拿来用的命名规范
概念讲清楚了,落到团队协作上,最难的反而是“类名到底叫什么”。我根据自己实践经验,整理了一套适合大多数后端团队的命名约定,供参考。
| 场景 | 建议类名 | 所在包/目录 |
|---|---|---|
| 领域实体/聚合根 | Order、Customer | domain.model |
| 领域值对象 | Money、Address | domain.model |
| 数据库映射对象 | OrderRecord、OrderPO | infrastructure.persistence |
| 接口出参入参 | OrderDTO、CancelOrderCommand | application.dto或interfaces.dto |
| 仓库接口 | OrderRepository | domain.repository |
| 仓库实现 | OrderRepositoryImpl | infrastructure.persistence |
这套约定的核心思路是:Entity这个词不要放进类名。因为 ORM 领域里Entity已经有特定含义,再拿来当类名后缀容易造成歧义。数据库映射对象统一叫Record或PO(Persistent Object),领域对象直接用业务名词。这样团队里一说Order,大家都知道指的是领域实体;一说OrderRecord,就知道是数据库表映射,不会再有鸡同鸭讲的场面。
6.2 我和团队踩过的三个典型坑
第一个坑是JPA 实体直接当领域实体用。早期图省事,直接在OrderEntity上写了业务方法,结果为了兼容 ORM 的懒加载和代理机制,又不得不给实体加一堆@Transactional注解,业务逻辑和事务边界全部糊在一起。后来下决心拆分,光是改调用链就花了两周。
第二个坑是领域服务变成“上帝 Service”。一开始确实建了 domain 包,但大家习惯还没改过来,还是把所有逻辑全部堆在OrderService里,Order实体依然是纯 getter/setter。后来通过一个约定强制扭转:Service 类只做编排,不允许写业务规则。凡是涉及状态判断、金额计算的逻辑,一律下沉到实体或领域服务。坚持了一个季度,代码质量明显好转。
第三个坑是命名不统一导致的沟通内耗。有人叫UserDO、有人叫UserEntity、还有人直接叫UserModel,讨论需求时为了确认“你用 UserModel 指代的是表还是领域对象”,每次都要多说三句话。后来统一按 6.1 的规范,争议基本消失,代码审查效率也显著提高。
6.3 给不同规模项目的实用建议
如果你在维护一个 CRUD 为主的中后台项目,业务不算复杂,强行上 DDD 全套战术设计只会让人觉得你在炫技。这种情况下,用一个XXXModel或XXXEntity同时承担数据映射和简单业务规则,完全可以接受。但哪怕不拆层,也建议把业务规则从 Controller/Service 里往 Model 类内部挪,至少让职责边界清晰一些。
如果你的项目已经进入业务深水区,比如订单、支付、风控这类领域复杂性高、规则变化频繁的系统,那就别犹豫,尽早按“领域层+持久化层+应用层”的标准分层来搭骨架,领域对象与数据模型分离。前期多写的转换代码,会在后期每次需求变更时加倍还给你。
如果你只是写一个个人项目或者做技术原型,最实用的建议就是:先别管什么 DDD、实体、领域模型,怎么快怎么来,等代码开始让你痛苦了,再回头按这篇文章的思路重构。过早的抽象和过迟的抽象一样有害,关键在于你要知道“正确的架构长什么样”,以及“你当前离它有多远”。
最后再分享一个我自己很受用的判断标准:下次改需求的时候,留意一下你最先打开的文件放在哪个层。如果改一条业务规则,你发现要同时动数据库映射类和接口 DTO,那就说明你的分层设计已经失真了。把这三个词想清楚,很多架构上的纠结都会迎刃而解。