Entity、Model、Domain 到底有什么区别?一文讲透概念与落地分层
最近帮一个老项目做架构梳理打开代码仓库那一刻我人有点麻一个订单模块里同时躺着Order、OrderModel、OrderEntity、OrderDomain四个类功能高度重叠团队里每个人对这四个类的边界解释都不同。问了一圈最后争论焦点全落在标题这三个词上——entity、model 和 domain 到底有什么区别这个问题几乎每个技术群都会隔三差五冒出来但真正能讲透的不多。原因很简单这三个词在不同框架、不同时代、不同架构风格里有完全不同的语义而大多数项目又恰好同时继承了多个时代的命名习惯。有人从 MyBatis 转过来觉得 entity 就是数据库表对应的那个类有人从 Rails 转过来觉得 model 就是带业务逻辑的数据类有人看完 DDD 的书觉得 domain 才是重中之重entity 和 model 都是小喽啰。谁都没错但凑到一个团队里就变成鸡同鸭讲。这篇文章我想把自己工作中对这三者的理解完整梳理一遍覆盖三层架构、领域驱动设计和 ORM 框架下的常见解释也会给出能直接落地的分层和命名建议。适合正在做架构设计、写业务代码、或者正在维护一堆命名混乱老系统的同学参考。1. 为什么这三个词会被混用历史语境与认知错位1.1 三层架构时代“Model”成了所有东西的代名词先把时间轴拉回到早期 Web 开发。在 J2EE 和三层架构流行的年代业务系统普遍被切成表现层、业务逻辑层、数据访问层每一层都要传递数据那这些数据放哪最常见的做法就是塞进一个个 JavaBean / POJO 里。那时候命名远没有现在讲究很多人直接就叫XxxModel、XxxBean甚至就叫Xxx。MVC 模式进来之后这个名字变得更乱了。MVC 里的 M 是 Model本意是“数据和业务规则”但到 Spring MVC 这一代Model接口、ModelAndView这些概念几乎被简化成了“往视图塞数据的容器”。于是 Model 这个词在 Web 开发者的认知里开始分裂一种理解是业务对象另一种理解是视图要用的数据结构。我见过不少老项目model包下面同时躺着数据库映射类、请求参数类、响应结果类一个包装下全世界的对象。为什么因为大家都叫 Model新来的同事不知道往哪里放干脆也往里面丢。久而久之Model 就成了整个项目中信息量最低的后缀——它只说明“这是一个类”完全不说明这个类属于哪一层、承担什么职责。1.2 DDD 流行后Entity 和 Domain 被重新定义如果说 Model 的混乱是历史遗留问题Entity 和 Domain 的混乱就是 DDD 普及过程中产生的。Eric Evans 的《领域驱动设计》出版后entity、value object、aggregate、bounded context 这些词被赋予了非常严格的定义。很多人看完书热血沸腾回去第一件事就是建一个domain包然后把原来的XxxModel改成XxxEntity觉得这样代码就有领域味道了。但这里有个致命误区DDD 里的 entity 重点在于“身份连续”和“业务行为”不是改个后缀就能实现的。很多团队把数据库表映射类从model搬到domain类里面依然只有 getter/setter业务逻辑还是全堆在 service 里。名字从 Model 换成 Entity再换成 Domain代码腐化的速度一点没变慢。这也解释了为什么这三个词在今天特别容易踩坑它们既有历史包袱又有新定义的冲击两套语义同时在代码库里出现不混才奇怪。1.3 不同技术栈给同一个词的不同默认解释再叠加一个维度团队成员的技术背景。同样一个“Model”在 Java/MyBatis、Java/JPA、C#/EF Core、Python/SQLAlchemy、Ruby on Rails、前端 TypeScript 里的默认含义几乎都不一样。技术栈/框架常见命名习惯“Model”的默认含义Java MyBatisentity / po / do / bean数据库表映射对象Java JPA/HibernateEntity持久化实体可承载少量逻辑C# EF CoreModel / Entity数据库实体DbContext 的 DbSetPython SQLAlchemyModelORM 模型声明式 BaseRuby on RailsModelMVC 中的 Model数据与业务规则TypeScript / 前端interface / type / model前后端数据结构契约这张表其实在说一件事如果你面试时问“Model 是什么”正确答案取决于面试官是从哪个技术栈过来的。而在一个混合背景的团队里这种默认含义的差异会直接反映到代码上——有人觉得 entity 就该放在 domain 包里有人觉得 entity 是天经地义的数据库对象有人觉得 model 包才是正统。理解了这种认知错位你在看下文分析时就会更有体感我下面说的不是“唯一正确”的定义而是在一个复杂业务系统里最不容易出问题的分工方式。2. Domain业务问题空间与解决方案空间的边界2.1 领域不是一层代码而是业务知识的组织方式很多人把 domain 理解成“一层代码”比如domain包或者Domain Service。这种理解不能说错但容易把问题想窄了。领域Domain本质上是对业务问题空间的一种界定它回答的是“我们在解决什么行业、什么场景、什么规则下的问题”——电商、保险、物流、医疗、制造这些都是不同的问题空间。领域模型Domain Model是对这个空间中核心概念和业务规则的表达。比如在电商场景里“订单”“库存”“支付”“优惠”是核心概念“一个订单必须属于一个用户”“下单时校验库存”“已支付订单才能发货”是核心规则。这些东西组织在一起就构成领域模型。所以在代码结构上domain 层不应该只是放几个实体类它应该尽可能承载业务规则。同一套业务规则不应该在 Controller 和 DAO 里各写一遍而是收敛到 domain 层的对象和方法里。我见过最好的项目domain 层代码读起来就像一本业务手册最差的项目domain 层只剩一堆空壳。2.2 领域层里到底该放什么不该放什么以我常用的后端分层为准一个相对干净的 domain 层应该包含这些内容聚合根Aggregate Root和实体Entity值对象Value Object领域服务Domain Service的接口必要时包含实现仓储Repository接口领域事件Domain Event领域异常比如OrderAlreadyPaidException而不该出现在 domain 层的有Controller、HTTP 相关的 Request/ResponseDAO、Mapper、EntityManager、数据库连接第三方 SDK、HTTP 客户端、消息中间件客户端DTO、VO 这类传输载体配置文件和配置对象判断标准其实很简单domain 层不能依赖 infrastructure 的东西。你写代码的时候如果能在一个领域类里 import 到Mapper、Session、RestTemplate这个领域的纯度就已经破了。我在真实项目里见得最多的症状是在 domain 实体里直接注入 Mapper 去查数据或者在 domain 服务里直接拼装 HTTP 请求。这种代码拆不拆层已经无所谓了因为依赖方向一旦倒转所谓“领域层”就只是个好看的包名。2.3 子域、限界上下文与“domain 对象”的正确理解还有一层误解藏在“domain 对象”这个词里。很多人觉得 domain 对象就是实体其实领域里除了实体还有值对象、领域服务、领域事件这些统称领域对象。把 domain 和 entity 画等号等于把一整个工具箱缩成了扳手。更要命的是“全公司共享同一个 User 对象”这种想法。DDD 里有个概念叫限界上下文Bounded Context意思是同一个业务概念在不同上下文里有不同的含义和生命周期。比如“用户”在认证上下文里有用户名和密码在 CRM 上下文里有客户等级和联系方式在物流上下文里可能只是收货地址和电话。把这些揉成一个“全能的 User”几乎所有上下文都要被迫面对一堆自己用不到的字段。这就引出一个极其重要的工作方法划分 domain 边界之前先跟业务人员对齐概念边界。代码结构是业务共识的投影如果团队对“用户是什么”都没有一致答案domain 包怎么建都是空中楼阁。3. Entity唯一标识、生命周期与业务行为3.1 DDD 语境下的 Entity靠“身份连续”而非字段相等在 DDD 的定义里实体有三个核心特征唯一标识identity、生命周期lifecycle、可变性mutability。唯一标识最好理解一个订单有订单号一个人有用户 ID一个保单有保单号。两个订单哪怕字段内容完全一样只要订单号不同它们在系统里就不是同一个订单这就是身份的区别。生命周期稍微抽象一点一个实体从创建、状态变更到最后归档甚至删除这条轨迹必须连续可追踪。比如订单从“已创建”到“已支付”到“已发货”再到“已完成”中间每一步的状态都跟同一个身份绑定。即使中途数据库字段结构变了订单号还是那个订单号。可变性则是对比出来的。DDD 里有一类对象叫值对象Value Object比如金额Money{amount, currency}、地址、时间段它们没有身份只要所有属性值相同就被视为同一个对象而且通常是不可变的。而实体是可变的——一个订单的状态从 PENDING 变成 PAID它还是同一个订单。很多新手犯的错误是把系统中所有类都当实体处理给每个类塞一个 ID。实际上像“价格计算结果”“坐标点位”“统计数据”这种没有连续身份意义的概念就不该硬造实体。判断方法很简单这个对象“是不是同一个东西”这件事对你的业务有意义吗3.2 持久化实体不等于领域实体现在到了最容易踩雷的地方。JPA 和 MyBatis 里都有所谓的 Entity——Entity注解的类、MyBatis 的 resultMap 对应的 POJO。这些对象的本质是数据库表结构的内存映射它们解决的是“数据库行如何变成 Java 对象”的问题不解决“订单这个业务概念如何建模”的问题。大多数项目里这两者是混淆的。大家直接把OrderEntity数据库行映射对象从 DAO 层一路传到 Service再传到 Controller最后序列化返回给前端。短期看很省事长期看业务逻辑会被表结构牵着走——主外键、级联更新、字段冗余这些数据库概念会顺着实体类渗到业务代码里最后 service 里全是entity.getOrderItems()这种基于外键关系拼出来的逻辑。举一个典型场景数据库有order_main和order_item两张表对应OrderMainEntity和OrderItemEntity。但在领域模型里一笔订单是一个聚合根它内部拥有OrderItem列表下单、支付、发货都通过聚合根触发不能绕过根节点直接改子项。如果直接用表映射对象当领域实体你很难约束这种聚合关系因为任何人都可以orderItemMapper.update()单独改一笔订单的子项状态。所以 DDD 实践中我强烈建议把领域实体和持久化实体分开领域实体放 domain 层持久化实体放 infrastructure 层中间的转换由专门的映射逻辑完成。字段完全一样也别偷懒分开放带来的边界收益比那点重复代码值钱得多。3.3 什么时候该用 Entity什么时候不该用按我的经验需要建模成实体的对象通常具备以下一个或多个特征有独立生命周期且需要在生命周期内保持身份连续会被多方引用且引用方依赖的是身份而非属性值有复杂的业务规则需要对象自身承载反过来以下情况就不应该建模成实体纯计算结果的载体比如一次聚合查询的统计结果用值对象或普通 DTO 就好配置信息、枚举字典普通数据类足够跨系统的一次性数据交换结构DTO 更合适代码评审的时候我常问大家一个问题如果这个对象的两个实例属性值一模一样你能说它们“是同一个东西”吗如果你发现这个问题的答案是无所谓的那就别给它加 ID别让它成为 Entity。实体不是身份象征滥用反而是设计的重量。4. Model最被滥用的词承载了所有结构4.1 Model 的几重面孔数据模型、对象模型、视图模型Model 这个词本身没有问题它的意思是“对某类事物的抽象建模”。问题在于几乎每一层都在建模所以每一层都有自己的 Model。数据库设计师说 Model通常指数据模型也就是 ER 图、表结构、字段约束后端开发者说 Model有时候指 ORM 模型有时候指领域模型前端开发者说 Model往往指的是接口返回的数据结构、页面状态的结构做机器学习的同学说 Model那又是另一套东西了。一个词承载这么多含义必然导致沟通成本。我见过前端同事拿着后端接口文档问“这个 OrderModel 对应的是数据库表吗”后端同事愣了一下说不是前端又问“那它为什么叫 Model”后端自己也说不清楚因为这个名字是从祖传代码里抄来的。所以如果你决定使用 Model 后缀至少要让它能清楚表达层次比如它不是“Model”而是“OrderViewModel”“CreateOrderModel”“OrderQueryModel”。单纯一个 Model 等于什么都没说。4.2 贫血模型与充血模型的分歧源头一聊到 Model就绕不开贫血模型Anemic Domain Model和充血模型。Martin Fowler 批评过贫血模型对象里只有数据和 getter/setter业务逻辑全跑到 Service 层这会让 Service 变成一个什么都干的上帝类。但我对这个问题保持比较务实的态度。贫血模型不是绝对的坏很多 CRUD 密集型项目业务逻辑很薄硬去充血反而尴尬。真正的问题在于业务规则没有归属。以订单取消为例。贫血模型的写法是写一个CancelOrderService里面判断状态、改状态还可能要回滚库存、发事件。一个 Service 还撑得住但当一个订单有取消、改价、拆分、锁定等十几个操作时这些规则全堆在 Service 里Service 就会膨胀成几千行的怪物。充血模型的写法是把状态校验、状态流转封装在Order实体内部Service 只负责调用和协调外部依赖。比如order.cancel()内部完成状态校验和事件发布Service 不再需要知道“已支付才能取消”这种规则。我的建议是不要为了追随 DDD 而强行充血而要在编写业务规则时问自己——这条规则放在哪最不容易被遗漏和绕过如果答案不明确至少不要让一个 Service 堆积超过一屏的业务规则。4.3 Model 命名泛滥带来的实际编码问题Model 泛滥不是风格问题它会带来非常实际的编码问题。首先是类名失去信息量。OrderModel、UserModel、ProductModel一眼看去你根本分不清哪个是请求参数哪个是数据库映射哪个是返回给前端的视图对象。新人接手时只能靠猜猜错就得看实现。其次是归属感缺失。既然 Model 哪都可以放那新需求加一个字段时就有多条路可以走。我见过一个项目同一个“用户名”字段在UserModel、UserEntity、UserVO三个类里各加了一遍最后维护成本直接爆炸。然后是模型转换的爆炸。类一多彼此之间要转换如果每个转换都手写在 Service 里Service 又膨胀一次如果用自动映射工具字段略有不一致就会在运行时出诡异问题。Model 类数量越多这种转换维护成本就越高。所以在写代码前我建议给每个“Model”戴上明确的帽子——PO、DO、DTO、VO、Command、Query、Response随便你选哪套但一定要让类名自解释。Model 本身可以是帽子但必须加上前缀限定它具体是哪一层、干什么用的 Model。5. 三者之间的协作关系与落地边界5.1 一套适合多数后端项目的分层方案概念说再多最终都要落到项目结构上。我日常工作里比较推荐的分层是这样的interfaces 层Controller、请求/响应对象application 层应用服务、事务编排、DTO、Command/Query 对象domain 层领域实体、值对象、领域服务、仓储接口、领域事件infrastructure 层Mapper/Repository 实现、PO/DO 持久化对象、外部服务适配依赖方向是单向的interfaces 依赖 applicationapplication 依赖 domaininfrastructure 实现 domain 定义的接口。domain 是最内层不依赖任何外部东西。这套不是唯一的答案。小型项目完全可以简化成 application infrastructure 两层甚至单层也能跑。但如果你需要处理复杂业务规则这套分层能让 domain、entity、model 各司其职不会互相污染。5.2 一个完整的订单创建流程看三者的位置代码比概念直观。假设要实现“用户创建一个订单”的功能理想的位置划分应该是这样interfaces 层接收 HTTP 请求// interfaces/web/OrderController.java RestController public class OrderController { private final OrderApplicationService orderAppService; PostMapping(/orders) public OrderResponse createOrder(RequestBody CreateOrderRequest request) { return orderAppService.createOrder(request.toCommand()); } }application 层处理用例逻辑和事务边界// application/OrderApplicationService.java Service Transactional public class OrderApplicationService { private final OrderRepository orderRepository; private final ProductRepository productRepository; public OrderResponse createOrder(CreateOrderCommand command) { Product product productRepository.findById(command.productId()); Money total product.calculatePrice(command.quantity()); Order order Order.create(command.customerId(), product, command.quantity(), total); orderRepository.save(order); return OrderResponse.from(order); } }domain 层承载业务规则// domain/order/Order.java public class Order { private OrderId id; private CustomerId customerId; private OrderStatus status; private ListOrderItem items; private Money totalAmount; private Order(...) { ... } public static Order create(CustomerId customerId, Product product, int quantity, Money total) { if (quantity 0) { throw new InvalidOrderQuantityException(); } // 业务规则订单创建时是待支付状态 Order order new Order(...); order.status OrderStatus.PENDING_PAYMENT; order.items List.of(OrderItem.create(product, quantity)); return order; } public void cancel() { if (this.status ! OrderStatus.PENDING_PAYMENT) { throw new OrderCannotBeCancelledException(); } this.status OrderStatus.CANCELLED; // 领域事件、库存回滚等逻辑可以在这里触发 } }infrastructure 层负责持久化和映射// infrastructure/persistence/OrderPO.java Table(name orders) public class OrderPO { private Long id; private Long customerId; private String status; private BigDecimal totalAmount; } // infrastructure/persistence/OrderRepositoryImpl.java Repository public class OrderRepositoryImpl implements OrderRepository { private final OrderMapper orderMapper; private final OrderItemMapper orderItemMapper; Override public Order findById(OrderId id) { OrderPO orderPO orderMapper.selectById(id.value()); // 将持久化模型转换为领域实体 return orderPO.toDomain(); } Override public void save(Order order) { OrderPO orderPO OrderPO.fromDomain(order); orderMapper.insertOrder(orderPO); // 子项单独保存 } }看到这里的重点了吗OrderPO是持久化模型Order是领域实体它们可以长得像但完全不冲突。转换逻辑放在infrastructure内部进行domain 层和 application 层感知不到OrderPO的存在。5.3 Entity 与 Model 的转换映射、防腐层与边界争议既然要区分就要有转换。关于转换路径我的建议是所有跨层传递的数据结构都有意识地进行转换而不是让一个类同时充当多层角色。至于用 MapStruct 还是手写 setter我的原则是字段完全一致的简单映射可以用工具但字段有差异、有合并拆分、需要做规则校验的映射手写更稳妥。手写映射虽然枯燥但它是唯一能让你看清楚边界的地方有业务规则的转换逻辑不应该藏在黑盒工具里。另外要提防腐层Anti-Corruption LayerACL。当你的系统调用外部系统时外部返回的数据结构本质上也是外部系统的“Model”它不应该直接渗透进你的 domain 层。举个例子CRM 接口返回CrmCustomerDto你在 application 层把它翻译成自己的Customer领域对象再传给 domain 层使用。这样即使外部字段定义大变你的核心业务规则也不会被动摇。用一个表格把三者放在一起做最终对比维度Domain 领域对象Entity 实体Model 模型/数据类核心使命表达业务概念和业务规则维持身份连续、承载生命周期状态变化描述某一层的结构抽象典型文件Order、Money、OrderItem、AreaServiceOrder领域实体或 OrderPO/OrderEntity持久化实体CreateOrderCommand、OrderDTO、OrderView所在层domain 层领域实体在 domain持久化实体在 infrastructure各层皆有因此必须加限定前缀行为有业务行为是规则归属地领域实体有行为持久化实体一般没有一般没有业务行为生命周期业务过程内创建、变更、归档与业务生命周期一致随一次请求/一次转换而生用完即弃对外泄露风险不应该出 domain避免规则逃逸持久化实体不该出 infrastructure用于跨层传输越靠近外层越具体6. 命名规范与代码评审中的实操建议6.1 团队代码里最常见的三类冲突第一类冲突是“Entity 到底是领域实体还是数据库实体”。一个团队里往往两种用法都存在于是每次评审某个XxxEntity的字段都会有人问这是领域对象还是表结构这玩意儿能直接传给前端吗解法是统一约定领域实体不带后缀直接叫Order数据库映射对象统一叫OrderPO或OrderDO不用 Entity 后缀。如果你一定要保留Entity注解的类名带 Entity那就明确所有 Entity 都是 infrastructure 层的东西domain 层出现的只能是纯粹的业务对象。第二类冲突是 domain 包里出现 DTO 甚至 Request 对象。有些人经验不足把接口入参直接当成业务对象往下传导致 domain 方法签名里全是XXXRequest。这会让领域层感知到接口层的内容边界直接破功。解法是应用层做一次翻译CreateOrderRequest→CreateOrderCommand→ domain 层的Order.create(...)。入参结构变了领域层不需要跟着动。第三类冲突是全项目叫XxxModel没有层次概念。这种老项目太多了连作者自己都说不清 Model 和 Entity 的区别。解法不是一刀切改掉全部类名而是先划边界再渐进改名我下面会细说。6.2 一套防呆的命名对照表我给自己团队定的命名对照如下实测下来评审时争议少了很多XxxPO/XxxDO持久化对象对应数据库表结构放 infrastructureXxxEntity如果你习惯这后缀仅用于 ORM 注解类仍是持久化对象Xxx业务名词本身领域实体/聚合根放 domainXxxValue或动词短语值对象比如MoneyValue放 domainXxxDomainService领域服务放 domainXxxRepository仓储接口定义在 domainXxxRepositoryImpl仓储实现放 infrastructureXxxApplicationService应用服务放 applicationXxxCommand/XxxQuery应用层输入对象XxxResponse/XxxDTO/XxxView应用层输出对象这套命名不是标准答案但它的核心思想很关键类名必须自带层次归属让人一眼就能判断该放哪个包、能不能跨层引用。命名不一致才是问题的根源至于具体用 PO、DO 还是 DAO团队内部统一就好。6.3 老代码渐进式改造思路如果一个老项目里已经到处是OrderModel别急着全局重命名。大范围重命名会让 diff 爆炸评审没法做业务也没法验证。我建议按下面的顺序渐进式改造先固定依赖方向给现有类分类明确哪些属于 domain哪些属于 persistence哪些属于 interface。先不拆包但通过评审和口头约定把规则定下来。把领域实体中的持久化字段拆出去创建 PO 类在 repository 内部做转换。这一步做完domain 类就不再直接对应表结构。把数据访问依赖移出 domain 层domain 内只留 Repository 接口。所有 Mapper/Session 引用下沉到 infrastructure。最后才是重命名和挪包把OrderModel按归属拆成Orderdomain和OrderPOinfrastructure把OrderVO改成OrderResponse之类。每一步都要保证可编译、可测试、可回归。宁可慢一点也不要指望一个晚上把命名问题修完。代码重构最怕的不是慢而是半途而废——改到一半停下系统的状态可能比原来更糟。7. 从实际项目中总结的几条经验概念混乱比代码烂更可怕。代码烂还能靠重构拉回来概念混乱会让团队完全无法达成共识新需求来的时候每个人都按自己的想法放代码最后变成一个谁都不敢动的泥潭。所以每次新团队成立或者老项目梳理我都会建议在 README 或者 docs/adr 里维护一份词汇表写清楚这几个词在项目里的确切含义评审的时候就拿术语表说话而不是凭个人喜好论证。另一个体会是不要因为看了 DDD 的书就强制自己非得把项目拆成 domain、entity、model 三层。我见过很多小项目用两层结构维护得非常好业务规则薄直接 application infrastructure 甚至单层都没问题。为什么要分离是因为你有足够复杂的业务规则需要领域对象来承载。没有复杂业务还硬拆只是徒增维护成本得到一堆空转的抽象。最后说一个非常实用的腐烂信号当你发现 Service 类开始超过五百行当你在 domain 包里看到了 Mapper 引用当你不知道一个新字段该加到Order、OrderPO还是OrderDTO上的时候说明边界已经破了。这时候就该停下来把本文这套思路重新盘一遍——把实体放回它的层把模型戴好它的帽子让领域回归领域。就我自己的经验每次这么梳理完代码都能清爽很长时间。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →