
本文厘清ddd与整洁架构中值对象(value object)与原始类型(如string、int)的使用边界,明确应用层、领域层与基础设施层间的数据契约设计原则——核心在于“在边界尽早解析,在内部坚持领域语义”。
本文厘清ddd与整洁架构中值对象(value object)与原始类型(如string、int)的使用边界,明确应用层、领域层与基础设施层间的数据契约设计原则——核心在于“在边界尽早解析,在内部坚持领域语义”。
在领域驱动设计(DDD)与整洁架构实践中,一个高频却易被误判的设计决策是:何时将原始类型(primitive)封装为值对象(Value Object),何时直接使用原始类型? 这并非语法或风格问题,而是关乎领域语义表达力、错误预防能力与架构演进弹性的关键分界。
一、边界即责任:输入 vs 输出,解析 vs 复制
根据“Parse, Don’t Validate”原则(Lexi Lambda, 2019),所有外部输入(API请求、消息队列、第三方回调等)都应被视为“未解析的字节流”,而非可信的领域数据。因此:
-
✅ 推荐做法:在应用层入口(如CQRS Command Handler、Controller)立即解析为值对象
// ✅ 好:明确约束 + 领域语义 + 可复用验证逻辑 data class RegisterNewUserCommand( val username: Username, // ← 封装:非空、长度3–20、仅含字母数字 val rawPassword: RawPassword, // ← 封装:最小长度、需含大小写字母+数字 val email: Email // ← 封装:RFC合规校验、域名存在性可选 ) // ❌ 风险:领域逻辑被迫处理原始字符串,校验散落各处,易漏、难测、不可复用 data class RegisterNewUserCommand( val username: String, val rawPassword: String, val email: String ) -
✅ 值对象必须满足两大特性:
无身份性(Identity-less):两个Email("a@b.com")相等即语义等价,无需ID;
-
不可变性(Immutable):构造后所有属性只读,杜绝状态污染。
data class Email private constructor(val value: String) : ValueObject { init { require(value.contains('@') && value.split("@").size == 2) { "Invalid email format" } } companion object { fun of(raw: String): Email = Email(raw.trim()) } }
二、领域层:只与领域概念对话
领域层(Domain Layer)应完全隔离原始类型——它只操作实体(Entity)、值对象(VO)、聚合根(Aggregate Root)和领域服务(Domain Service)。这意味着:
- 所有业务规则、不变式(invariants)、领域逻辑均基于值对象编写;
- Username参与权限校验,Email触发通知策略,CouponCode决定折扣有效性——这些语义无法由String承载;
- 若领域方法签名出现String参数(如fun applyDiscount(couponCode: String)),即违反领域纯洁性,暴露技术细节。
三、基础设施层:适配器决定契约粒度
基础设施层(Infrastructure)作为外部世界的“翻译官”,其接口契约需权衡解耦性与表达力:
-
✅ 推荐:端口(Port)使用值对象,适配器(Adapter)负责转换
// 端口定义(领域层/应用层依赖)→ 强语义、高内聚 interface CouponsRepository { fun findByCode(couponCode: CouponCode): Coupon? // ← CouponCode 是值对象 } // 适配器实现(基础设施层)→ 负责与数据库/HTTP/缓存交互 class JdbcCouponsRepository( private val jdbcTemplate: JdbcTemplate ) : CouponsRepository { override fun findByCode(couponCode: CouponCode): Coupon? { // 将值对象转为原始类型用于SQL查询 val codeStr = couponCode.value return jdbcTemplate.queryForObject( "SELECT * FROM coupons WHERE code = ?", arrayOf(codeStr), CouponRowMapper() ) } } ⚠️ 例外场景:简单查询可跳过值对象
若findByCode仅用于缓存键查找,且CouponCode无业务约束(如纯UUID),则String可接受——但需明确注释:“此处为性能优化,不表示领域语义缺失”。
四、关键总结:三句原则
- 入口即解析:所有外部输入在最外层(Controller/Command Handler)解析为值对象,避免原始类型渗入领域;
- 内部全领域:领域层与应用服务层只操作实体、值对象及领域事件,永不暴露String/Int作为业务概念;
- 出口即复制:对外输出(API响应、消息发送)可将值对象toString()或映射为DTO,无需反向封装——因下游不承担领域责任。
最终,值对象不是“过度设计”,而是把隐式规则显性化、把分散校验集中化、把技术噪声领域化。当username: String变成username: Username,你写的不再是代码,而是可执行的领域契约。











