☰
UML多重度与交互图:从建模契约到代码实现
2026/10/27 22:01:15 网站建设 项目流程

简介:本资源是一份面向软件工程专业学生及UML初学者的系统化复习资料,聚焦UML系统分析与设计核心考点,助力备考期末考试或软考相关科目。内容涵盖关联多重度、组合关系判定、客户-订单业务建模、顺序图与协作图对比辨析、高内聚原则、UML九种图的定位与选用(如类图表静态视图、用例图述系统边界、序列图显时序交互)、依赖/关联/泛化/实现等四种基本关系识别、领域模型构建及GRASP设计模式基础等12大高频知识点,每题均附详解与图示说明。资源为单个Word文档(.doc),全文132KB,结构清晰、排版规范,便于打印复习与碎片化学习。已有405人下载学习,内容紧扣教学大纲与典型试题,可直接用于知识自查、错题复盘与概念强化,是掌握UML建模思维与应试能力的实用型备考材料。

1. UML 不是画图工具,而是「把模糊需求锻造成可执行契约」的建模引擎

你有没有遇到过这样的翻车现场:需求文档写得天花乱坠,开发一动工就发现“客户说的‘提交订单’到底指点击按钮、校验库存、还是生成支付单?”;UML 类图画得工整漂亮,但一到写代码,Order类该不该持有Customer的完整引用?PaymentService的创建时机该由谁决定?——图还在,逻辑已崩。这不是 UML 不好用,而是我们长期把它当成了「静态快照生成器」,却忽略了它最硬核的底层机制:UML 是一套以图形为载体、以语义约束为筋骨、以职责分配为驱动的系统化建模协议。它不承诺自动生成代码,但每一条连线、每一个数字、每一组消息编号,都在强制你回答一个工程本质问题:这个系统状态变化的因果链,究竟由哪些对象在什么条件下、以什么顺序、承担什么责任来闭环?这份《UML 系统分析与设计复习试题》之所以值得深挖,正因为它不是零散知识点罗列,而是用 80+ 道真题,把 UML 从「画布上的线条」还原成「软件行为的编译器」——它逼你把“客户能下多个订单”这种自然语言,翻译成Customer[0..*] ——> Order[1]的多重度约束,再推导出Order必须持有customerId外键或Customer引用的实现刚性;它用 SSD(系统序列图)把makeNewSale()这个方法名,锚定到POS对象必须响应、Sale对象必须被创建、且Sale的生命周期由POS管理的架构事实。看懂这些题,你才真正拿到了打开面向对象设计黑匣子的钥匙:不是学怎么画图,而是学怎么让图成为代码的宪法。

2. 关联多重度:不是数量标注,而是对象生命周期与所有权的法律契约

UML 中的关联多重度(Multiplicity),表面看是类图上[0..1]、[1..*]这样的数字标签,但它的本质远比计数器深刻——它是对两个类实例之间内存关系、依赖强度、销毁权责的精确法律声明。试题第 1 题直击要害:为什么答案是 B(“一个类的实例能够与另一个类的多个实例相关联”)而非 A 或 C?因为 A 描述的是调用关系(属于依赖或方法参数),C 描述的是调用频次(属于运行时行为),而多重度定义的是结构层面的静态绑定能力:Order类能否在构造时就持有Customer的引用?Customer实例被销毁时,其关联的Order实例是否必须一同消亡?这直接决定了数据库外键设计、ORM 映射策略、甚至 GC 回收逻辑。

2.1 多重度如何编码对象所有权与生命周期

多重度数值本身是语法糖,其背后映射的是三种核心语义模型:

  • [1]:强拥有(Strong Ownership)
    表示“整体-部分”关系中的组合(Composition)。如试题第 2 题中“类 A 由类 B 的一个实例和类 C 的 1 个或多个实例构成”,A与B的[1]关系意味着B的生命周期完全依附于A——A被销毁时,B必须同步销毁。在 Java 中,这通常体现为A的构造函数内new B(),且A持有B的私有 final 引用;在数据库中,B表必有a_id外键且ON DELETE CASCADE。

  • [0..*]:弱引用(Weak Reference)
    表示“使用关系”而非“拥有关系”。如试题第 3 题中“一个客户提交 0 个或多个订单”,Customer与Order的[0..*]关系说明Customer可以独立存在,Order的生命周期由自身业务规则管理(如支付超时自动取消)。此时Order表需存储customer_id,但删除Customer时Order不应级联删除,而应置为customer_id = NULL或归档。

  • [1..*]:强制关联(Mandatory Association)
    表示“无此部分则整体不成立”。如OrderItem与Order的关系:没有Order,OrderItem无法存在。这要求OrderItem构造时必须传入Order实例,且OrderItem的order_id外键不可为空(NOT NULL)。

提示:多重度不是孤立存在的。它必须与关联端的导航性(Navigation)和角色名(Role Name)结合解读。例如Customer关联端标注orders: Order[0..*],Order关联端标注customer: Customer[1],这明确表示Customer可通过orders集合访问所有订单,而每个Order必须通过customer属性反向定位所属客户——这种双向导航在代码中需显式实现(如 Hibernate 的@OneToMany(mappedBy="customer"))。

2.2 从多重度到代码:Java 与数据库的双重映射

让我们以试题第 3 题的客户-订单关系为例,将[0..*]与[1]多重度落地为可运行代码。关键点在于:多重度约束必须在类定义、构造逻辑、持久层配置三个层面保持一致。

// Customer.java - 多重度 [0..*] 的体现:可持有空集合,且不强制管理 Order 生命周期 public class Customer { private Long id; private String name; // 导航属性:支持从 Customer 查找所有 Order // 注意:这里使用 List 而非 Set,因订单有严格时序,且允许重复商品 private List<Order> orders = new ArrayList<>(); // 初始化为空集合,符合 [0..*] public void addOrder(Order order) { if (order.getCustomer() != null && !order.getCustomer().equals(this)) { throw new IllegalStateException("Order already belongs to another Customer"); } order.setCustomer(this); // 建立双向关联 this.orders.add(order); } // getter/setter 省略 } // Order.java - 多重度 [1] 的体现:Customer 引用不可为空,且 Order 构造时必须指定 public class Order { private Long id; private BigDecimal totalAmount; // 导航属性:强制关联 Customer,体现 [1] 约束 private Customer customer; // 不能为 null! // 构造函数强制 Customer 注入,确保 [1] 约束在对象创建时即满足 public Order(Customer customer, BigDecimal amount) { if (customer == null) { throw new IllegalArgumentException("Customer cannot be null for Order"); } this.customer = customer; this.totalAmount = amount; // 将自己注册到 Customer 的 orders 列表中,维护双向一致性 customer.addOrder(this); } // setter 必须校验非空 public void setCustomer(Customer customer) { if (customer == null) { throw new IllegalArgumentException("Customer cannot be null"); } this.customer = customer; } // getter/setter 省略 }

这段代码将多重度[0..*]和[1]转化为三重保障:

  1. 构造时强制:Order构造函数要求Customer参数,杜绝null状态;
  2. 运行时校验:setCustomer()方法二次校验,防止后期篡改;
  3. 双向同步:Customer.addOrder()自动建立反向引用,避免数据不一致。

在数据库层面,这对应着严格的 DDL 约束:

-- customers 表:主键 id CREATE TABLE customers ( id BIGSERIAL PRIMARY KEY, name VARCHAR(255) NOT NULL ); -- orders 表:customer_id 为非空外键,强制 [1] 约束 CREATE TABLE orders ( id BIGSERIAL PRIMARY KEY, total_amount DECIMAL(19,2) NOT NULL, customer_id BIGINT NOT NULL, -- NOT NULL 强制 [1] CONSTRAINT fk_orders_customer FOREIGN KEY (customer_id) REFERENCES customers(id) ON DELETE RESTRICT -- 禁止级联删除,因 Customer 可独立存在([0..*]) );

ON DELETE RESTRICT是关键:它确保删除Customer时,若存在关联Order,数据库直接报错,迫使业务层显式处理(如先归档订单),这正是[0..*]所要求的“弱拥有”语义——Customer的消亡不自动触发Order的消亡。

2.3 多重度避坑:那些让团队加班到凌晨的隐性陷阱

多重度看似简单,却是团队协作中最易引发血泪冲突的雷区。以下是基于真实项目踩坑记录的深度复盘:

现象 1:前端传参customer_id=null,后端Order创建成功,但业务逻辑崩溃

原因:后端仅校验了customer_id参数存在,未在Order构造函数或 Service 层做null强制检查,导致Order对象处于非法状态(违反[1]约束)。更糟的是,ORM(如 MyBatis)可能将null写入数据库,破坏外键完整性。
解决:在领域对象构造函数中做防御性编程(如上文Order构造函数),并配合数据库NOT NULL约束。永远不要信任任何外部输入,多重度是你的第一道防线。

现象 2:Customer删除后,其orders集合仍可遍历,但访问Order.customer报 NPE

原因:ORM 懒加载(Lazy Loading)导致Customer.orders返回代理集合,实际查询时Order对象已从数据库被物理删除,但 JVM 中Order实例仍存在,其customer字段为null。这暴露了多重度[0..*]与[1]的语义断层:Customer的消亡不等于Order的消亡,但Order的customer字段却成了悬空引用。
解决:在Customer的delete业务方法中,显式调用orderService.archiveOrdersByCustomerId(customerId)归档订单,而非直接DELETE FROM customers。归档操作将orders表的customer_id设为NULL或指向archived_customer,保持Order对象的customer字段可安全访问(返回null或占位符对象)。

现象 3:Order表customer_id允许NULL,但业务要求每个订单必须有客户

原因:DBA 为兼容历史脏数据,将customer_id设为NULLABLE,但 UML 类图明确标注[1]。开发按数据库设计编码,导致Order构造时接受null,违背了模型契约。
解决:模型优先(Model-First)原则。以 UML 多重度为唯一真理,推动 DBA 修改 DDL:ALTER TABLE orders ALTER COLUMN customer_id SET NOT NULL;。若历史数据无法清理,则需在应用层增加数据清洗 Job,而非妥协模型。

现象 4:Customer的orders集合在高并发下单时出现重复添加

原因:Customer.addOrder()方法未加锁,多线程同时调用导致orders.add(order)执行两次。这违反了Order与Customer的一对一归属关系(Order应只属于一个Customer)。
解决:将addOrder()设为synchronized,或使用ConcurrentLinkedDeque替代ArrayList。但更根本的是,在Order构造函数中完成customer.addOrder(this),利用构造时的单线程上下文保证原子性。

3. 交互图的本质:顺序图是时间轴上的因果律,协作图是空间中的责任网络

UML 交互图常被简化为“画消息箭头”,但试题第 4、72、73 题反复强调:顺序图(Sequence Diagram)与协作图(Collaboration Diagram)是同一套因果逻辑的两种投影方式,而非两种独立语法。它们共同服务于一个目标:将“用户点击按钮后系统发生什么”这一模糊过程,分解为可验证、可追溯、可分配的对象级事件流。顺序图沿垂直时间轴展开,像电影胶片逐帧记录消息发送的绝对时序;协作图则将对象置于二维平面,用带编号的消息线刻画其相对协作关系。二者可无损转换,但转换过程暴露出一个被忽视的真相:消息编号不是序号,而是因果依赖的拓扑排序。

3.1 顺序图:用时间戳固化对象间的因果链

顺序图的核心价值,在于它强制你回答:“这条消息,为什么必须在此刻、由这个对象、发给那个对象?” 试题第 21 题的 SSD(系统序列图)给出了典型场景:makeNewSale()→enterItem()→endSale()→makePayment()。这不是功能列表,而是系统状态变迁的法定路径。enterItem()必须在makeNewSale()之后,因为Sale对象必须先被创建,才能接收商品;endSale()必须在所有enterItem()之后,因为总金额计算依赖所有商品信息。这种时序约束,直接映射到代码中的前置条件(Precondition)和状态机(State Machine)。

// Sale.java - 顺序图时序约束的代码实现 public class Sale { private State state = State.CREATED; // 初始状态 private List<SaleItem> items = new ArrayList<>(); private BigDecimal totalAmount = BigDecimal.ZERO; // 对应 SSD 中 makeNewSale() public Sale() { this.state = State.CREATED; } // 对应 SSD 中 enterItem() public void addItem(SaleItem item) { // 前置条件:必须在 CREATED 或 ITEM_ADDED 状态下才能添加 if (state != State.CREATED && state != State.ITEM_ADDED) { throw new IllegalStateException("Cannot add item in state: " + state); } items.add(item); totalAmount = totalAmount.add(item.getPrice().multiply(BigDecimal.valueOf(item.getQuantity()))); this.state = State.ITEM_ADDED; // 状态跃迁 } // 对应 SSD 中 endSale() public void finalizeSale() { // 前置条件:至少有一个商品,且状态为 ITEM_ADDED if (items.isEmpty() || state != State.ITEM_ADDED) { throw new IllegalStateException("Cannot finalize sale without items"); } this.state = State.FINALIZED; // 状态锁定,禁止再添加商品 } // 对应 SSD 中 makePayment() public PaymentReceipt processPayment(BigDecimal amount) { // 前置条件:必须已终结销售 if (state != State.FINALIZED) { throw new IllegalStateException("Sale must be finalized before payment"); } // 支付逻辑... return new PaymentReceipt(); } // 状态枚举 public enum State { CREATED, ITEM_ADDED, FINALIZED, PAID } }

这段代码将顺序图的四步消息流,转化为Sale对象的有限状态机(FSM)。每个方法调用都是对状态的合法跃迁,state字段就是顺序图的时间轴在内存中的镜像。addItem()的if校验,就是enterItem()消息必须在makeNewSale()之后的代码证明;finalizeSale()的items.isEmpty()校验,则是endSale()必须在所有enterItem()之后的强制约束。顺序图不是描述“发生了什么”,而是定义“什么情况下才允许发生”。

3.2 协作图:用空间布局揭示对象间的责任边界

协作图的价值,在于它撕掉了时间面纱,直击对象协作的责任网络拓扑。试题第 4 题指出:“协作图强调收发消息的对象的组织结构”。这意味着:当你看到POS→Sale→PaymentService的链式消息,协作图强迫你思考:POS为何不直接调用PaymentService?Sale作为中间节点,它承担了什么不可替代的职责?答案藏在 GRASP 模式中——Sale是信息专家(Information Expert),它持有所有商品信息和总金额,是计算支付金额的唯一权威;POS是控制器(Controller),负责接收用户事件并分发;PaymentService是低耦合(Low Coupling)的隔离层,封装支付细节。协作图的空间布局,本质上是在绘制一张职责分配地图。

graph LR A[POS] -->|1: makeNewSale| B[Sale] B -->|2: addItem| C[ProductCatalog] B -->|3: calculateTotal| D[PriceCalculator] A -->|4: endSale| B A -->|5: makePayment| E[PaymentService] B -->|6: getFinalAmount| D E -->|7: processPayment| F[BankGateway]

注意:此图非标准 UML 协作图(因 Mermaid 不支持标准语法),但精准表达了试题第 4 题的核心——消息编号(1,2,3...)不是时间序号,而是因果依赖的拓扑序。消息2(addItem)依赖1(makeNewSale)创建的Sale实例;消息3(calculateTotal)依赖2提供的商品数据;消息6(getFinalAmount)是4(endSale)的隐含前提。协作图用编号而非垂直位置,将时间维度压缩为依赖图,这正是它“易于添加对象”的优势——新增DiscountService只需在图中添加节点并连接编号8,无需重排整个时间轴。

3.3 顺序图与协作图的转换:一场关于“谁该知道什么”的哲学思辨

二者转换的难点,从来不是语法,而是责任归属的重新谈判。试题第 72 题点破:“顺序图清楚地表示了交互作用中的时间顺序,但没有明确表示对象间的关系”。让我们以endSale()消息为例:

  • 顺序图视角:POS生命线 →Sale生命线,箭头标注endSale(),强调这是POS主动发起的动作。
  • 协作图视角:POS与Sale间连线标注1: endSale(),但Sale生命线旁会注明state = FINALIZED,暗示endSale()的实质是Sale的内部状态变更。

这种差异引向一个设计哲学问题:endSale()的责任主体是谁?

  • 若POS是控制器,它应负责协调流程,endSale()是它向Sale下达的指令;
  • 若Sale是信息专家,endSale()应是Sale的自我终结行为,POS只需调用sale.endSale(),不关心内部细节。

真实项目中,我们选择后者。因为Sale持有终结所需的全部信息(商品列表、价格、折扣规则),POS知道的越少,耦合越低。这解释了为何协作图更利于设计:它迫使你审视每个对象的知识边界(Knowledge Boundary)。POS不需要知道Sale如何计算总金额,只需知道调用endSale()后Sale进入FINALIZED状态;PaymentService不需要知道Sale的结构,只需接收BigDecimal amount。协作图的空间布局,本质是将“高内聚”原则可视化——每个对象只暴露最小必要接口,其余逻辑深藏于其边界之内。

3.4 交互图避坑:消息编号、生命线与激活框的三大幻觉

交互图的常见误用,源于对图形元素的字面理解。以下是高频翻车点:

现象 1:顺序图中POS发送makeNewSale()后,Sale生命线立即出现,但代码中Sale构造耗时 200ms,导致后续消息延迟

原因:顺序图的生命线(Lifeline)和激活框(Activation Bar)是逻辑存在性的声明,而非物理执行时间的度量。Sale生命线出现,仅代表POS已获得对其的引用(如new Sale()返回),不保证构造函数执行完毕。
解决:在Sale构造函数中加入log.info("Sale created with id: {}", this.id),并在顺序图对应位置添加注释:“Sale构造完成,可接收消息”。将性能瓶颈视为独立关注点,用单独的性能序列图描述。

现象 2:协作图中POS→Sale的消息编号为1,Sale→ProductCatalog为2,但实际ProductCatalog查询是异步的,Sale在收到结果前就向POS返回了endSale()响应

原因:协作图默认假设同步调用,而现代系统大量使用异步(Callback、Future、Reactive)。编号1、2隐含了“2在1完成后才开始”的同步语义,与异步现实冲突。
解决:对异步消息,使用 UML 2.5 的asynchronous message语法(虚线箭头 +<<async>>标签),并在消息编号后加?表示不确定性,如2?: queryProduct()。或拆分为两个协作图:主流程图展示同步骨架,异步子图展示回调链。

现象 3:顺序图中POS向Sale发送enterItem(),但Sale类代码中无此方法,而是由SaleItem的addToSale(Sale sale)实现

原因:混淆了消息发起者与行为执行者。enterItem()是POS接收用户输入后的动作,但业务逻辑上,添加商品是SaleItem的职责(如需校验库存)。顺序图应忠实反映控制流,而非强行将方法塞进某个类。
解决:重构顺序图:POS→SaleItem发送create(itemId, quantity),SaleItem→Sale发送addItem(SaleItem item)。这符合 GRASP 的信息专家模式——SaleItem知道如何创建自身,Sale知道如何聚合。

现象 4:协作图中PaymentService与BankGateway间消息编号7,但BankGateway是第三方服务,其响应时间不可控,导致PaymentService的processPayment()方法超时

原因:协作图未体现外部依赖的脆弱性。7: processPayment的编号暗示其是确定性步骤,但外部服务调用本质是try-catch包裹的不确定性操作。
解决:在协作图中为BankGateway节点添加约束{external, unreliable},并在PaymentService到BankGateway的消息旁注明timeout=30s, retry=2。将容错逻辑视为协作的一部分,而非事后补丁。

4. 领域模型:不是实体草图,而是真实世界概念的语义压缩包

领域模型(Domain Model)常被误解为“数据库表的类图映射”,但试题第 15、44、50 题斩钉截铁地指出:领域模型是一组表示真实世界概念类(Conceptual Classes)的抽象,而非软件部件(Software Components)的蓝图。它不关心Customer类该有几个字段、用什么 ORM 框架,只关心“客户”这个概念在业务语境中有哪些本质属性、与哪些其他概念存在不可分割的关联、遵循什么核心规则。这份复习题中反复出现的“销售实例”、“电话用户”、“POS 收款台”,都不是技术组件,而是业务世界里的活生生的概念。领域模型的价值,正在于它用最精炼的图形,把模糊的业务语言(如“客户可以退货”)压缩成可推理的语义单元(Customer与Return的[0..*]关联,Return的status属性约束为PENDING|APPROVED|REJECTED)。

4.1 领域模型的构建:从名词短语到概念类的淬炼术

试题第 18、20、23 题给出了领域建模的两大实操法:概念类类别表与标识名词短语。但这并非机械的文本扫描,而是一场严谨的语义过滤。以试题第 20 题场景为例:“顾客带着购买的商品或服务来到 POS 收款台”,初筛名词短语得到顾客、商品或服务、POS 收款台。但真正的建模高手会追问:

  • 顾客vs客户:业务中是否区分“首次来访者”与“注册用户”?若无区分,统一为Customer;若有,则Visitor与Customer是不同概念类。
  • 商品或服务:这是一个“或”关系,暗示Product与Service是继承体系的子类,而非一个Item类。领域模型中应画出Item(抽象父类)→Product、Service(具体子类)的泛化关系。
  • POS 收款台:是硬件设备(HardwareDevice)?还是软件系统(PointOfSaleSystem)?试题语境中它响应makeNewSale(),显然是软件系统,故建模为POS类。

最终,试题第 20 题的答案顾客、POS 收款台、收款员、销售、商品标识、商品、商品说明,实则是经过三次过滤的结果:

  1. 去修饰词:去掉“购买的”、“收款台的”,保留核心概念;
  2. 去动词化:“销售”是Sale(名词),而非sell(动词);
  3. 去实现细节:“商品标识”是ProductID(值对象),而非String字段。
// 领域模型核心类:聚焦概念,剥离技术 public class Customer { private final CustomerID id; // 值对象,不可变 private final String name; private final ContactInfo contactInfo; // 值对象,聚合 public Customer(CustomerID id, String name, ContactInfo contactInfo) { this.id = Objects.requireNonNull(id); this.name = Objects.requireNonNull(name); this.contactInfo = Objects.requireNonNull(contactInfo); } // 仅暴露业务方法,无 setter } // ProductID:值对象,体现“商品标识”是概念,非字符串 public final class ProductID { private final String value; public ProductID(String value) { this.value = Objects.requireNonNull(value).trim(); if (this.value.isEmpty()) { throw new IllegalArgumentException("ProductID cannot be empty"); } } @Override public boolean equals(Object o) { /* 基于 value 比较 */ } @Override public int hashCode() { /* 基于 value 计算 */ } }

这段代码体现了领域模型的精髓:用不可变值对象(Value Object)封装概念,用聚合根(Aggregate Root)划定一致性边界。CustomerID不是String,而是承载“商品标识”业务语义的独立概念;Customer的构造函数强制所有属性非空,确保对象创建即合法——这正是领域模型对真实世界规则的忠实编码。

4.2 领域模型与软件设计的鸿沟:为什么类图不能直接生成代码?

领域模型与最终代码之间,横亘着一条名为“技术决策”的鸿沟。试题第 51 题尖锐指出:“在画 SSD 图时,应对所涉及的系统:D. 不对系统的内部结构与功能进行描述”。这揭示了领域模型的刻意抽象性:它只描述POS作为一个黑盒接收makeNewSale()消息并返回Sale,绝不涉及POS是 Spring Boot 微服务还是嵌入式 C 程序。这种抽象是优势,也是陷阱——若开发时直接将POS类图映射为@RestController,就犯了“过早技术绑定”的错误。

让我们以Sale的持久化为例,看领域模型如何与技术实现解耦:

// 领域层:纯业务逻辑,无技术痕迹 public class Sale { private final SaleID id; private final List<SaleItem> items; private final BigDecimal totalAmount; private SaleStatus status; public Sale(SaleID id, List<SaleItem> items) { this.id = id; this.items = new ArrayList<>(items); // 防御性复制 this.totalAmount = calculateTotal(items); this.status = SaleStatus.CREATED; } // 业务方法:只依赖领域概念 public void addItem(SaleItem item) { items.add(item); this.totalAmount = calculateTotal(items); } private BigDecimal calculateTotal(List<SaleItem> items) { return items.stream() .map(i -> i.getPrice().multiply(BigDecimal.valueOf(i.getQuantity()))) .reduce(BigDecimal.ZERO, BigDecimal::add); } } // 应用层:协调领域对象与技术设施 @Service public class SaleApplicationService { private final SaleRepository saleRepository; // 仓储接口,领域层定义 private final ProductCatalog productCatalog; // 外部服务接口,领域层定义 public SaleApplicationService(SaleRepository saleRepository, ProductCatalog productCatalog) { this.saleRepository = saleRepository; this.productCatalog = productCatalog; } @Transactional public Sale createNewSale() { SaleID id = SaleID.generate(); // 领域服务生成 ID Sale sale = new Sale(id, new ArrayList<>()); saleRepository.save(sale); // 仓储实现可为 JPA、MyBatis 或内存 Map return sale; } } // 基础设施层:技术实现细节 @Repository public class JpaSaleRepository implements SaleRepository { @Override public void save(Sale sale) { // JPA 实体映射,此处省略 jpaRepository.save(toJpaEntity(sale)); } }

这个分层架构,完美诠释了领域模型的定位:Sale类只包含业务规则(addItem、calculateTotal),不关心如何存库;SaleApplicationService作为“胶水层”,调用领域对象并注入技术设施(SaleRepository);JpaSaleRepository则是纯粹的技术实现。领域模型是设计的北极星,它告诉你“系统必须做什么”,而技术选型决定“如何高效地做”。复习题中所有关于“销售”、“订单”的题目,都在训练你识别这个北极星——无论未来用 Spring Cloud 还是 Quarkus,Sale的业务内核永不改变。

4.3 领域模型避坑:概念污染、贫血模型与过度设计的三重绞杀

领域模型的失败,往往始于对“真实世界”的拙劣模仿。以下是血泪教训:

现象 1:Customer类中出现lastLoginTime: LocalDateTime、isEmailVerified: boolean等字段

原因:混淆了领域概念与应用状态。lastLoginTime是认证子域的状态,isEmailVerified是用户管理子域的规则,它们不属于“客户”在销售领域的本质属性。强行塞入,导致Customer承担无关职责,违反高内聚。
解决:划分子域(Bounded Context)。Customer属于销售子域,只保留id,name,contactInfo;UserAccount属于认证子域,管理登录态。跨子域通信通过防腐层(Anti-Corruption Layer)。

现象 2:Sale类只有getItems()、getTotalAmount()等 getter,所有业务逻辑放在SaleService中

原因:贫血模型(Anemic Domain Model)——领域对象沦为数据容器,业务逻辑散落在服务层。这使Sale失去“智能”,addItem()的校验逻辑(如库存检查)无法内聚在Sale内部。
解决:遵循“富领域模型”原则。Sale.addItem()应包含完整业务规则,如if (item.getQuantity() > inventoryService.getStock(item.getProductID())) throw new InsufficientStockException();。将逻辑留在概念身边。

现象 3:为Product添加categoryPath: String字段,以支持前端面包屑导航

原因:技术需求倒灌领域模型。categoryPath是 UI 层的渲染优化,与“商品”概念无关。将其加入Product,污染了领域内核,且当分类树变更时,Product实例需批量更新categoryPath,违背不变性。
解决:将categoryPath移至应用层或视图模型(ViewModel)。Product只保留categoryID,前端通过CategoryService.findById(categoryID)动态构建路径。领域模型只存储事实,不存储派生数据。

现象 4:SaleItem类中定义discountRate: BigDecimal,但折扣规则由促销引擎动态计算

原因:将可变规则固化为静态属性。discountRate不是SaleItem的固有属性,而是PromotionEngine.applyDiscount(saleItem)的计算结果。硬编码会导致促销活动变更时需修改SaleItem类。
解决:SaleItem

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询