OOP 与 充血/贫血模型
背景:
公司的业务集中在电商领域,基于 shopify 进行电商网站的开发。于是,在空闲时间,我计划使用 java spring 去写自己的电商后台(因为一直以来我们都是依赖于shopify做一些扩展,类似于产品,订单等都是在shopify平台上,实际的后端业务很少)。希望借此提升电商领域的业务理解和开发技术上的设计能力。在实操过程中,我慢慢发现了以往的问题(实际上因为我使用的是所谓的贫血模型的方式,所有的业务逻辑都堆在service中,大量的重复性代码,导致service非常臃肿,我实在是看不下去了,也写不下去了;以前都是简单的业务,倒也不觉得,一旦有较为复杂的业务,代码量稍微多一点就很难维护和修改。因为之前都是调用shopify的接口,这些问题相当于都被别人处理了,把这些都堆在一个叫做 ShopifyService的服务中即可,这个服务就专门用来调用接口),再读了好几篇博文再加之ai的帮助,写下这篇blog记录下。
原来我之前一直用的是贫血模型,刚毕业就来了这里,在学校的时候一直听老教授们告诉我们 面向过程/面向对象 设计。 那时候我只是知道学C的时候是面向过程,学java的是面向对象,C++是都可以。 毕业了来到公司 以为前辈们写的方式(spring boot项目)就是真正的 OOP . 现在理解了 贫血模型根本就是违反了OOP的基本原则:对象应该同时有数据和行为。和OOP背道而驰【但是贫血模型中只有数据,所有业务逻辑在service中,OOP变成了事务脚本】。这里不是完全否定贫血模型的意思。但是显然这是更简单的一种方式,当然不能只会简单的方式。
这里引用一句(也是我从其他博主那里看到的): 曾经Spring的作者Rod Johnson也承认,Spring 不过是在沿袭 EJB2 时代的“事务脚本”,也就是面向过程编程。
这里直接让AI给我总结下充血/贫血模型的区别:
| 比较维度 | 贫血模型(Anemic Model) | 充血模型(Rich Model) |
|---|---|---|
| 定义 | 只有数据(字段),没有业务行为的方法 | 同时包含数据和与之相关的业务行为 |
| 职责划分 | 数据结构与业务逻辑分离,逻辑通常在 Service 层 | 对象自身封装业务逻辑,职责内聚 |
| 面向原则 | 更接近过程式编程,OOP 不彻底 | 体现面向对象的封装、聚合、职责 |
| 代码结构 | Entity 只含字段和 getter/setter | Entity 除字段外包含验证、状态变更等方法 |
| 测试方便性 | 单元测试主要写在 service 层 | 每个对象方法可单测,聚焦职责 |
| 演化灵活性 | 修改业务逻辑需要修改 service 层 | 对象演化更自然,改动集中 |
| 常见用途场景 | CRUD 型应用、事务脚本架构 | 复杂业务、DDD(领域驱动设计) |
| 开发初期门槛 | 较低,容易理解 | 高,需理解建模思想 |
| 维护成本 | 随逻辑复杂增加,service 层容易变得臃肿 | 随对象划分,逻辑分布清晰,长远更易维护 |
| 示例(加入购物车) | Cart 类只有字段,加入逻辑写在 CartService | Cart.addItem(Product) 封装加入逻辑 |
可见 其实真正的面向对象设计起来的门槛更高,我自己实操下来也是深有体会。
直入主题:如何使用充血模型进行开发(使用购物车这个模型的例子解释)
在我实际的操作中,我发现。充血模型的设计过程大不相同。
过去,我拿到设计稿,理解了业务逻辑之后。先分析需要哪些接口,每个接口有哪些数据,然后可能会建表,表一建完,直接就是无脑一条龙, 从Controller层到Service层再到DAO(或是其他的什么 Repository),最后打到数据库。一条线下来,只要牵住一头一尾,头是controller的接口,尾巴是数据库的SQL, 中间是猪的肚子 service。 没错 这就是我们 crud coder, 有些博主说这是 面向SQL编程,反正要么是一个复杂的service,要么是一个复杂的SQL.
现在,为了设计充血模型,在定义 interface 就耗费了我大量的时间,
- 购物车的唯一id 应该是用户id ,每个用户有唯一的购物车对象,因此想到后续的实现应该有 customer id 数据
- 购物车中所有的商品行组成一个 List
- 为了安全 设计了购物车的签名和验签方法
- 为了前端的显示 应该避免一些不可暴露的字段给用户 设计了 view 方法给前端输出 VO
- 购物车中的商品应该有选中和取消选中的功能
- 购物车的商品应该可以删除和修改(修改包含数量和商品的型号(选择不同的变体))后来我直接把修改型号的改为删除了该商品再添加 就变成了 updateQuantity 和 delete
- 添加商品到购物车的方法 add
- 设计了缓存和持久化的方法 cache save
- 如果购物车中还支持不同的店铺进行分类的话 可能还要改 这里所有的商品行是直接铺下来
/** * 代表一个用户的购物车实体 */ public interface Cart { void setCustomerId(Long id); Long getCustomerId(); void setItems(List<CartItem> items); List<CartItem> getItems(); /** * validate cart data * @return true -> safe */ Boolean validate(); /** * sign * @return sign */ String sign(); /** * show cart to customer * @return CartVo */ CartVo view(); /** * check one item of cart * @param id cart item id * @param checked status: choose or not */ void check(String id, Boolean checked); /** * delete * @param id cart item id */ void delete(String id); /** * update quantity * @param id cart item id * @param quantity add or minus item quantity */ void updateQuantity(String id, Integer quantity); /** * add product to cart use variant id * @param id variant id * @param quantity quantity * @param product Product */ void add(Long id, Integer quantity, Product product); /** * refresh cache */ void cache(); /** * save */ void save(); }
这样应该算是一个充血模型了,这样服务层就可以直接调用这些方法来完成业务逻辑了。
如果能设计一个合理的充血模型,那么最后service将会变得很清晰,用别的博主的话来说 “服务层很薄——所有重要的业务逻辑都写在领域层” 这里的领域层就是指模型层,比如我们这里的购物车领域的业务实体。
下面是购物车服务的接口和实现,从实现中可以看到为什么服务层中变薄了很多。
这样服务层中就只剩下一组和业务相关的方法,非常清晰。
public interface CartService { /** * 查询用户的购物车 * @param userId 用户 id * @return CartVo 购物车整体描述(验签) */ Result<CartVo> getCartView(Long userId); /** * 加购单个商品一定数量 * @param userId 用户 id * @param productId 产品 id * @param variantId 产品变体 id * @param quantity 加购数量 * @return CartVo 购物车整体描述(验签) */ Result<CartVo> addToCart(Long userId, Long productId, Long variantId, Integer quantity); /** * 更新购物车项的数量 * @param userId 用户 id * @param cartItemId 购物车项 id * @param quantity 修改的目标数量 * @param variantId 修改的目标变体 * @return CartVo 购物车整体描述(验签) */ Result<CartVo> updateCart(Long userId, String cartItemId , Integer quantity, Long productId, Long variantId); /** * 删除购物车项 * @param userId 用户 id * @param cartItemId 购物车项 id * @return */ Result<CartVo> deleteCart(Long userId, String cartItemId); /** * 选中或者取消选中 CartItem * @param userId 用户 id * @param cartItemId 购物车项 id * @param checked true 选中,false 取消 * @return CartVo 购物车整体描述(验签) */ Result<CartVo> checkCartItem(Long userId, String cartItemId, Boolean checked ); /** * 根据购物车的选中状态去生成订单 * @param userId 用户 id * @param clientAmount 金额只做验证,防止计算最终价格与用户所见不一致 * @param sign 购物车签名 * @return OrderPreviewVo 订单预览 */ Result<OrderPreviewVo> genOrder(Long userId, BigDecimal clientAmount, String sign); }
@Service public class CartServiceImpl implements CartService { private final CartRepository cartRepository; private final ProductRepository productRepository; public CartServiceImpl( CartRepository cartRepository, ProductRepository productRepository) { this.cartRepository = cartRepository; this.productRepository = productRepository; } @Override public Result<CartVo> getCartView(Long userId) { Cart cart = cartRepository.findById(userId); CartVo cartVo = cart.view(); return new Result<CartVo>().success(cartVo); } @Override public Result<CartVo> addToCart(Long userId, Long productId, Long variantId, Integer quantity) { Cart cart = cartRepository.findById(userId); Product product = productRepository.findById(productId); if (!product.getSKU(variantId).is_available(quantity)){ return new Result<CartVo>().error("stock not enough"); } cart.add(variantId, quantity, product); CartVo cartVo = cart.view(); return new Result<CartVo>().success(cartVo); } @Override public Result<CartVo> updateCart(Long userId, String cartItemId, Integer quantity, Long productId, Long variantId) { Cart cart = cartRepository.findById(userId); if (variantId != null){ Product product = productRepository.findById(productId); if (!product.getSKU(variantId).is_available(1)){ return new Result<CartVo>().error("stock not enough"); } cart.delete(cartItemId); cart.add(variantId, 1, product); } if (quantity != null){ cart.updateQuantity(cartItemId, quantity); } CartVo cartVo = cart.view(); return new Result<CartVo>().success(cartVo); } @Override public Result<CartVo> deleteCart(Long userId, String cartItemId) { Cart cart = cartRepository.findById(userId); cart.delete(cartItemId); CartVo cartVo = cart.view(); return new Result<CartVo>().success(cartVo); } @Override public Result<CartVo> checkCartItem(Long userId, String cartItemId, Boolean checked) { Cart cart = cartRepository.findById(userId); cart.check(cartItemId, checked); CartVo cartVo = cart.view(); return new Result<CartVo>().success(cartVo); } @Override public Result<OrderPreviewVo> genOrder(Long userId, BigDecimal clientAmount, String sign) { return null; } }
可以看到 这样服务层根本就看不到任何的和数据库相关的逻辑,这些被封装进入各个模型中。
实际上,这个 service 的实现综合使用了多个模型的行为,不仅是 Cart 还有 Product 和 SKU 和 Inventory . 如果使用 充血模型的 OOP ,那么 service 层就应该是和一组具有行为的模型组合使用
为甚么充血模型更好
在开头的区别中,其实已经表述了。这里用我的理解
以我较为浅显的理解来说,不管是用贫血或者充血,实际上都做了一层映射,把数据库中的数据搬到模型中去。但是,贫血模型的作用就仅限于此了,存一下数据,完全丢失了OOP的优势,下面用AI总结复习下OOP的优势,大概是这几点:
✅ 1. 行为 + 状态聚合,保持业务不变性
OOP 把“状态”和“操作状态的方法”聚在一起,避免状态被胡乱篡改。
🔒 OOP 能封住内部状态,让所有变更走业务逻辑路径,避免非法状态。
✅ 2. 高内聚,低耦合
对象内部高度内聚,外部通过接口与之交互,减少模块间耦合。
减少上下游影响,提高模块独立性。
✅ 3. 更强的表达力和可读性(Ubiquitous Language)
真正的 OOP 类和方法贴近业务术语,代码即文档。
order.cancelByCustomer(reason); payment.retry();
相比那种冗长、低语义的 if/else 或 service 方法调用,更清晰、自然。
✅ 4. 更易测试和演进
行为封装在对象中,可直接单测对象逻辑,不必依赖复杂上下文。
模块化程度高时,未来调整逻辑/策略也更容易。
✅ 5. 符合人类思维建模方式
我们理解“订单”、“购物车”、“客户”这些概念都是具备状态和行为的实体,用对象建模贴近现实,更易于团队沟通和协作。
✅ 6. 支撑复杂业务建模(DDD)
OOP 是 DDD(领域驱动设计)的基础。没有真正的 OOP,谈不上战斗力强的领域模型。聚合根、实体、值对象、策略模式等,都是 OOP 的体现。
让我感到不适应的几个点:
再转变到使用充血模型的过程中,在几个点上我感到不适应。或者说感觉有点问题的地方,不知道有没有人有同感
第一,模型建立的时候
在service中,当我需要获取到模型实例的时候,我需要通过 Repository 去专门写一个创建模型,获取模型的方法,
有时候我会的模型类嵌套的过深,比如我会在 Product 中嵌套多个 Variant, 每个 Variant 中还有一个 Inventory 。这三个内容我写在三张表中,此时其实创建模型实例的方法就有一点点麻烦了。
我不知道这是不可避免的,还是说我的方法不对。以前我都是需要哪个,就直接在 service 里用 mapper 查对应的数据。
有可能是我抽象的太过了,组合 Product 的方式有问题,因为,有的时候,一些 service 层的方法是不需要用到那么多的数据的,那我就将一个产品相关的所有查询都打到数据库了,正是因为这一点,我想到了使用缓存去保存模型的数据。但是问题依然存在。
第二,模型持久化的时候
模型的持久化,真正的写入数据库的操作肯定还是在 Repository 中的。但是不是要从模型中发起呢?那么什么时候去进行持久化的写入数据库?持久化的时候,是不是又需要把数据从模型中解构出来,如果嵌套组合不合理,这一步也很麻烦。
对于这两个问题,我喂给AI, 他的回答我觉得还是有说法的,贴在下面:
✳️ 问题一:模型构建复杂(Product → Variant → Inventory)
✅ 你当前的困惑:
模型之间嵌套,Repository 获取领域对象成本变高;不像以前只查用到的字段那么轻便。
🎯 问题本质:
- 充血模型强调建模完整的领域行为语义,所以你要构造出一个“完整的聚合”,这确实更重了。
- 有时候,你不是真的需要完整聚合,而只是想查一小块数据,却为了行为封装“被迫”加载一堆子对象,这种做法成本高。
🔍 解决策略:
✅ 1. 明确:是否真的需要构建聚合根?
不是所有情况都要构建完整模型,按 DDD 的经验:
| 业务操作类型 | 是否构建聚合对象 |
|---|---|
| 状态变更(如加购物车、下单) | ✅ 是 |
| 只读(如查询商品详情) | ❌ 否,用 DTO 即可 |
📌 换句话说,只在需要行为的时候才构建模型,其余直接查 DTO。构建模型是为行为服务的,不是强制标准。
✅ 2. 构造方法中使用按需加载(Lazy or Factory)
你可以为 Product 提供多种构造方式:
java 复制编辑 // 用于完整聚合建模(有行为需求) Product withVariantsAndInventory = productRepository.loadFull(productId); // 用于轻量只读用途 ProductSummary summary = productRepository.loadSummary(productId);
用“意图驱动的模型构造方法”去控制装载深度,避免无谓的数据装载。
✅ 3. 组合与聚合边界是否划分过深?
你也提到“是不是组合方式有问题”,这个观察是非常敏锐的。
如果你在大多数业务中只对 Variant 或 Inventory 操作,而不是通过 Product 来驱动行为,说明它们可能应该是独立聚合根,而不是 Product 的一部分。
📌 聚合边界的定义应该基于事务一致性与行为闭包性,不是“物理上属于谁”。
✳️ 问题二:模型的持久化时机不明确
✅ 你当前的困惑:
充血模型强调模型发起操作,那我在什么时候调用 Repository 保存到数据库?Service 层还是模型自己保存?
🎯 问题本质:
这是 OOP + DDD 中一个经典问题:谁负责持久化,领域对象 or 应用服务?
🔍 推荐实践(领域建模中的常见方式):
✅ 1. 持久化操作由 Service 驱动,模型本身不关心持久化
DDD 里通常建议:模型自己只关注业务规则、状态变更,不关心数据落地。Repository 把对象“还原”和“保存”到数据库,统一由 Application Service 控制生命周期。
// 业务动作在模型中 cart.add(variantId, quantity, product); // 数据持久化由 Service 明确调用 cartRepository.save(cart);
📌 这样做的好处是:
- 保持模型纯粹(只关注业务)
- 易于测试
- 明确持久化时机
✅ 2. 用“变更记录”控制写入策略(Optional)
在某些系统中,模型中记录自身是否“dirty”,交给应用服务统一批量提交。
if (cart.isDirty()) { cartRepository.save(cart); }
甚至可以设计领域事件(如 CartChangedEvent)触发写入,这在 CQRS 或事件风格架构中常见。
当然,这两个问题都不是模型本身的问题,模型本身只要关注自身的业务逻辑和数据就好,AI提到的聚合根和聚合边界的问题,在设计之初是需要好好权衡的,如果组合在一起,就是不可避免的导致创建的时候较为复杂,所以要选择合适的程度去设计聚合边界。或者直接将读写的逻辑分离,避免一些不必要的操作打到数据库。