Builder、JavaBean 和 DTO 在对象创建中如何取舍?
简化版
JavaBean 适合框架绑定和简单数据传输,Builder 适合创建字段多、可选项多、需要校验或希望不可变的业务对象,DTO 则主要承担跨层或跨进程的数据承载。取舍时看对象是否有业务不变量、是否需要不可变、是否由框架反射创建。
详细版
三者关注点不同:
- JavaBean:无参构造器加 getter/setter,框架友好,但对象可能处于半初始化状态;
- Builder:通过命名步骤收集参数,最后
build()统一校验并创建成品对象; - DTO:用于接口、RPC、消息、持久化映射等数据传输,通常不承载复杂业务行为。
如果是 Controller 入参、JSON 反序列化对象、ORM 映射对象,JavaBean 或 record/DTO 往往更合适。如果是订单创建命令、HTTP 客户端配置、不可变值对象、复杂查询条件,Builder 更合适。
面试里不要把 Builder 说成“替代 setter 的高级写法”。真正判断标准是:对象创建是否需要清晰命名、统一校验、默认值、不可变交付和防止半成品泄漏。
完整版教学
一、JavaBean 的优势和天然风险
JavaBean 的典型结构是无参构造器加 setter。它对框架非常友好,因为 JSON 反序列化、表单绑定、ORM 映射都能先创建对象,再逐个填字段。
UserDTO dto = new UserDTO();
dto.setName("Tom");
dto.setAge(18);
dto.setEmail("tom@example.com");
它的问题也来自同一个机制:对象在 setter 没调用完之前就是半初始化状态。如果某段代码在 setName() 后、setEmail() 前拿到了对象,就可能看到不完整数据。对于纯数据传输对象,这个风险可控;对于有业务不变量的领域对象,就容易出问题。
记忆钩子:JavaBean 对框架友好,Builder 对业务不变量友好。
二、Builder 的核心收益不是少写代码
Builder 往往比 JavaBean 写更多代码,但它换来几个关键收益。第一,调用方能通过方法名看懂参数含义。第二,默认值集中在 Builder。第三,build() 是统一交付边界。第四,最终对象可以设计成不可变。
UserProfile profile = UserProfile.builder()
.name("Tom")
.age(18)
.email("tom@example.com")
.marketingOptIn(false)
.build();
如果对象有 10 个字段,其中 4 个可选、3 个有默认值、2 个存在组合约束,Builder 会比 setter 更稳。因为 setter 只能表达“改了某个字段”,而 Builder 可以表达“收集参数后交付一个合法成品”。
三、DTO 的职责是传输,不是承载复杂业务
DTO 的重点是数据边界。它常出现在 API 入参出参、RPC 消息、MQ 消息、数据库投影、前后端交互中。DTO 可以用 JavaBean、record、构造器或 Builder 实现,但 DTO 本身不是一种创建型模式。
HTTP JSON -> CreateOrderRequestDTO -> Application Service -> Order.create(...)
数据库行 -> OrderPO -> Repository -> Order
Order -> OrderResponseDTO -> HTTP JSON
这条流程里,DTO 负责把外部数据带进来或带出去;业务对象负责表达业务规则。不要因为 DTO 字段多,就机械套 Builder。很多 DTO 需要被 Jackson、MyBatis、Spring MVC 等框架反射创建,JavaBean 反而更顺。
四、用数字判断哪种方式更合适
可以用字段数量和规则复杂度做粗略判断。比如一个对象只有 3 个字段且全部必填,构造器或 record 更直观。一个对象有 12 个字段,其中 8 个可选,且有 5 条校验规则,Builder 的收益明显更高。
| 场景 | 字段数量 | 规则复杂度 | 推荐方式 |
|---|---|---|---|
| 坐标 Point | 2 | 低 | 构造器/record |
| 登录表单 DTO | 2-4 | 中 | JavaBean/record + 校验框架 |
| HTTP 客户端配置 | 8-15 | 高 | Builder |
| ORM 实体 | 5-30 | 中 | JavaBean 或框架约定 |
| 领域值对象 | 3-10 | 中高 | 构造器/静态工厂/Builder |
这个表不是绝对规则,但能帮助面试回答从“喜好”变成“基于约束的选择”。
五、半初始化对象为什么是关键分界线
JavaBean 的 setter 允许对象被逐步修改,这意味着对象可能短暂处于不合法状态。比如 startTime 必须小于 endTime,用 setter 时很难保证任何时刻都满足。
Period period = new Period();
period.setStartTime(100);
period.setEndTime(50); // 此时对象已经处于非法状态
Builder 可以先收集两个字段,最后统一判断:
Period period = Period.builder()
.startTime(100)
.endTime(50)
.build(); // 在这里拒绝
关键区别在于:JavaBean 先暴露对象再慢慢补齐,Builder 先收集参数再交付对象。业务对象越强调不变量,越应该避免半初始化状态泄漏。
六、框架绑定场景为什么常保留 JavaBean
Spring MVC、Jackson、MyBatis 等框架通常需要无参构造器、setter、字段访问或反射机制。虽然很多框架也支持构造器绑定和 Builder 反序列化,但配置复杂度会更高,团队一致性也要考虑。
例如一个请求 DTO:
public class CreateUserRequest {
private String name;
private Integer age;
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public Integer getAge() { return age; }
public void setAge(Integer age) { this.age = age; }
}
这类对象可以用校验注解处理输入合法性,然后在应用服务里转换成真正的领域对象:
User user = User.builder()
.name(request.getName())
.age(request.getAge())
.build();
这样 DTO 和领域对象各守边界:DTO 适配外部协议,领域对象保护业务规则。
七、Builder 与 DTO 可以组合使用
Builder 不一定只属于领域对象。复杂响应 DTO、测试数据构造器、SDK 请求对象,也可以使用 Builder。关键是看调用方是否需要可读的构建体验和校验边界。
外部入参:
JavaBean DTO + Bean Validation
内部领域:
Builder / 静态工厂 + 不变量保护
外部出参:
简单 DTO / record
测试数据:
TestDataBuilder,提供合理默认值
测试数据 Builder 很常见。比如创建订单测试对象时,默认给一个合法订单,只在测试里覆盖关心字段,这比每个测试都手写 12 个字段更稳定。
八、常见误区与追问
- 误区:Builder 一定比 JavaBean 更高级。 两者解决的问题不同,框架绑定场景 JavaBean 可能更合适。
- 误区:DTO 字段多就应该用 Builder。 DTO 主要是传输结构,是否使用 Builder 要看构建复杂度和框架兼容。
- 误区:JavaBean 一定不安全。 对简单传输对象可以接受,风险主要在有业务不变量的对象。
- 追问:Controller 入参用 Builder 好不好? 通常不优先,除非框架和团队明确支持构造器或 Builder 绑定。
- 追问:领域对象为什么不建议暴露 setter? setter 会让对象创建后继续被任意修改,不变量难以长期保证。
- 追问:Builder 和 Bean Validation 怎么配合? 外部 DTO 用 Bean Validation 做输入校验,内部 Builder 做业务对象不变量校验。
- 追问:record 能不能替代 Builder? 字段少、全部必填时 record 很好;字段多、可选项多、默认值多时 Builder 更清晰。
九、加强记忆
取舍时抓住三个问题:谁创建它、它有没有业务不变量、创建后能不能被随便改。框架创建的传输对象偏 JavaBean/DTO,业务规则强的成品对象偏 Builder 或静态工厂;不要为了“看起来像设计模式”把所有对象都改成 Builder。