《Java 编程实战》6.2 DTO 设计:请求、响应与领域对象的边界

领域 record 不是 API 契约:Loan 没有 status 和 overdueDays,请求直接绑定领域对象又会带来批量赋值。本节用 Jackson 真实输出设计 BookDto、LoanDto、PageResponse 与 ApiError,划清请求、响应和领域对象的边界。

本节目标:把 libraryd 的 JSON 契约固定成一组 record,让请求只能传该传的字段,响应只暴露该暴露的字段,领域对象保持独立。
适用版本:JDK 25(Temurin 25.0.5)、Jackson 2.22.4。

承接的问题

6.1 节让 BookDto 和 LoanDto 能正确序列化了。但「能序列化」不等于「契约设计正确」。把领域对象直接当响应体,会遇到三个具体问题:

  • Loan 只有 id、isbn、memberId、borrowedOn、dueOn、returnedOn、renewCount,没有 status 和 overdueDays。客户端要自己判断「在借、逾期还是已归还」,每个客户端都得复制一遍规则。
  • Book 和 Loan 是内部模型。将来给 Loan 加一个 internalNote 或 fineCents,如果直接序列化领域对象,字段会立刻暴露给所有客户端。
  • 请求方向更危险。如果 POST /api/books 直接绑定 Book,客户端可以传 id、returnedOn 这类本该由服务端决定的字段。这种「批量赋值」(mass assignment)是很多越权漏洞的入口。

所以需要三组对象:请求 DTO、响应 DTO、领域对象。它们形状相似,但职责不同,不能合并。

核心机制一:三组 record 的分工

先看 libraryd 的契约。请求 DTO 只包含客户端有资格提供的字段:

package com.plume.library.web;

public record CreateBookRequest(String isbn, String title, String author, int year) {}

public record UpdateBookRequest(String title, String author, int year) {}

public record BorrowRequest(String isbn, String memberId) {}

public record ReturnRequest(String isbn) {}

响应 DTO 只包含客户端需要看到的字段:

package com.plume.library.web;

import java.time.LocalDate;

public record BookDto(String isbn, String title, String author, int year) {}

public record LoanDto(
        long id,
        String isbn,
        String memberId,
        LocalDate borrowedOn,
        LocalDate dueOn,
        LocalDate returnedOn,
        int renewCount,
        String status,
        long overdueDays) {}

public record ReturnReceiptDto(
        String isbn,
        String memberId,
        LocalDate returnedOn,
        long fineCents) {}

分页和错误各有自己的信封:

package com.plume.library.web;

import java.util.List;
import java.util.Map;

public record PageResponse<T>(List<T> items, long total, int page, int size) {}

public record ApiError(
        String type,
        String title,
        int status,
        String detail,
        String code,
        String requestId,
        Map<String, String> errors) {}

三组对象的边界可以这样记:

对象谁创建允许出现的字段典型错误
请求 DTO客户端 JSON只含可写字段暴露 id、returnedOn
响应 DTO服务端含派生字段,屏蔽内部字段直接返回领域对象
领域对象服务端业务不变量被 Jackson 注解污染

UpdateBookRequest 没有 isbn,因为 ISBN 是路径参数,也是主键,不允许通过请求体修改。BorrowRequest 没有 borrowedOn 和 dueOn,借期由 Loan.open 根据当天计算,客户端不能自己指定到期日。

核心机制二:派生字段在边界计算

LoanDto 里的 status 和 overdueDays 不是数据库列,而是根据 returnedOn 和 dueOn 算出来的。计算放在 DTO 的转换方法里,让所有响应走同一套规则:

package com.plume.library.web;

import com.plume.library.domain.Loan;

import java.time.LocalDate;

public record LoanDto(
        long id,
        String isbn,
        String memberId,
        LocalDate borrowedOn,
        LocalDate dueOn,
        LocalDate returnedOn,
        int renewCount,
        String status,
        long overdueDays) {

    public static LoanDto from(Loan loan, LocalDate today) {
        return new LoanDto(
                loan.id(),
                loan.isbn(),
                loan.memberId(),
                loan.borrowedOn(),
                loan.dueOn(),
                loan.returnedOn(),
                loan.renewCount(),
                statusOf(loan, today),
                loan.overdueDays(today));
    }

    private static String statusOf(Loan loan, LocalDate today) {
        if (!loan.active()) {
            return "RETURNED";
        }
        return loan.overdueDays(today) > 0 ? "OVERDUE" : "ACTIVE";
    }
}

status 只有三个值:ACTIVE、OVERDUE、RETURNED。判断顺序很重要:先看是否已归还,再看是否逾期。已归还的记录即使 dueOn 早就过了,状态也应该是 RETURNED,而不是 OVERDUE。

三种状态的本机输出:

[loan-active ] {"id":1,"isbn":"9787111213826","memberId":"m-001","borrowedOn":"2026-10-15","dueOn":"2026-10-29","returnedOn":null,"renewCount":0,"status":"ACTIVE","overdueDays":0}
[loan-overdue] {"id":1,"isbn":"9787115428028","memberId":"m-002","borrowedOn":"2026-09-28","dueOn":"2026-10-12","returnedOn":null,"renewCount":0,"status":"OVERDUE","overdueDays":6}
[loan-closed ] {"id":1,"isbn":"9787111213826","memberId":"m-001","borrowedOn":"2026-10-15","dueOn":"2026-10-29","returnedOn":"2026-10-17","renewCount":0,"status":"RETURNED","overdueDays":0}

这三条来自本机用 Jackson 2.22.4 序列化的结果。注意 overdueDays 在已归还时是 0,因为 Loan.overdueDays(today) 在 returnedOn 非空时用归还日计算,归还日早于到期日就压成 0。

派生字段放在响应 DTO 而不是领域对象里,有一个实际好处:today 是请求上下文的一部分,不是领域对象的固有属性。同一个 Loan 在 10 月 18 日查是 ACTIVE,在 11 月 2 日查就是 OVERDUE。把 today 传进转换方法,比把「当前时间」塞进领域对象更清楚。

核心机制三:请求 DTO 阻止批量赋值

假设 POST /api/books 直接绑定领域 Book:

// 不要这样做
Book book = JsonSupport.apiMapper().readValue(body, Book.class);

Book 的组件是 isbn、title、author、year,看起来风险不大。但 Loan 的组件里有 id、returnedOn、renewCount。如果借阅接口直接绑定 Loan,客户端可以传 "returnedOn":"2026-01-01",把一条新借阅伪装成已归还。

请求 DTO 的价值就是「只暴露可写字段」。BorrowRequest 只有 isbn 和 memberId,客户端即使传了 returnedOn,Jackson 也会因为未知字段而报错,本机报错原文:

[request-unknown] UnrecognizedPropertyException - Unrecognized field "extra" (class CreateBookRequest), not marked as ignorable

本机这条输出用 CreateBookRequest 加了一个 extra 字段,为了可读性把类名简化成了 CreateBookRequest。错误原文把类名和字段名都指出来了,web 层可以把它映射成 400,并在 ApiError.errors 里指出是哪个字段。

严格模式是请求边界的默认选择。如果为了「兼容旧客户端」把未知字段关掉,拼错的 memberID(大写 D)会被静默丢弃,服务端用 null 继续执行,最后报一个和根因无关的空指针。让错误在边界暴露,比在服务层排查便宜。

核心机制四:分页、错误与 null 策略

分页响应不是直接把 List<BookDto> 返回,而是包一层:

PageResponse<BookDto> page = new PageResponse<>(
        List.of(new BookDto("9787111213826", "Effective Java", "Joshua Bloch", 2018)),
        41, 0, 20);

本机输出:

[page   ] {"items":[{"isbn":"9787111213826","title":"Effective Java","author":"Joshua Bloch","year":2018}],"total":41,"page":0,"size":20}

items 是当前页,total 是满足条件的总数,page 从 0 开始,size 是每页大小。客户端需要 total 计算总页数;如果只返回数组,客户端不知道还有多少数据。第 4.3 节的分页 SQL 用 count(*) over() 或单独的 count 查询得到 total,这里只负责形状。

错误响应按 Problem Details 的字段组织,媒体类型是 application/problem+json:

ApiError error = new ApiError(
        "https://plumephp.com/problems/book-already-borrowed",
        "Book already borrowed", 409,
        "ISBN 9787111213826 already has an active loan",
        "E_BOOK_ALREADY_BORROWED", "3f2a-request",
        Map.of("isbn", "already borrowed"));

本机输出:

[error  ] {"type":"https://plumephp.com/problems/book-already-borrowed","title":"Book already borrowed","status":409,"detail":"ISBN 9787111213826 already has an active loan","code":"E_BOOK_ALREADY_BORROWED","requestId":"3f2a-request","errors":{"isbn":"already borrowed"}}

字段各司其职:

  • type 是稳定的问题类型 URI,客户端可以按它做分支,不要按 detail 做匹配。
  • title 是给人看的短标题。
  • status 与 HTTP 状态码一致。
  • detail 是这次请求的具体说明。
  • code 是 libraryd 的 ErrorCode 值,便于日志和客户端精确判断。
  • requestId 对应响应头 X-Request-Id,排查时能把客户端报错和服务端日志串起来。
  • errors 是字段级错误,校验失败时使用;没有字段错误时可以是空 Map。

status 不要和 ErrorCode.httpStatus() 各写一份。ApiError 构造时从 ErrorCode 取状态码,避免路由里再写一个 switch,两处不一致时以领域错误码为准。

null 字段的取舍

returnedOn 在活动借阅时是 null。默认序列化会保留字段,本机输出:

[default] {"id":1,"isbn":"9787111213826","memberId":"m-001","borrowedOn":"2026-10-18","dueOn":"2026-11-01","returnedOn":null,"renewCount":0,"status":"ACTIVE","overdueDays":0}

如果设置 NON_NULL,字段会消失,本机输出:

[non-null] {"id":1,"isbn":"9787111213826","memberId":"m-001","borrowedOn":"2026-10-18","dueOn":"2026-11-01","renewCount":0,"status":"ACTIVE","overdueDays":0}

两种都能用,但同一个 API 要统一:

  • 保留 null:结构稳定,客户端可以始终按同一个字段集解析;缺点是 payload 略大。
  • 省略 null:payload 小;缺点是客户端要区分「字段缺失」和「值为空」。

libraryd 选择保留 null,因为 LoanDto 的字段集是契约的一部分,客户端不应该靠字段是否存在来判断状态。状态已经由 status 表达,returnedOn 只提供日期。

在 libraryd 里落地:一个 DtoMapper

转换逻辑集中在一个类里,web 层只调用它,不自己拼 DTO:

package com.plume.library.web;

import com.plume.library.domain.Book;
import com.plume.library.domain.Loan;

import java.time.LocalDate;
import java.util.List;

public final class DtoMapper {

    private DtoMapper() {
    }

    public static BookDto toDto(Book book) {
        return new BookDto(book.isbn(), book.title(), book.author(), book.year());
    }

    public static LoanDto toDto(Loan loan, LocalDate today) {
        return LoanDto.from(loan, today);
    }

    public static ReturnReceiptDto toReceipt(Loan closed, long fineCents) {
        return new ReturnReceiptDto(
                closed.isbn(), closed.memberId(), closed.returnedOn(), fineCents);
    }

    public static <T> PageResponse<T> page(List<T> items, long total, int page, int size) {
        return new PageResponse<>(items, total, page, size);
    }
}

ApiError 的 type 在 web 层由错误码转成 kebab-case 的 URI,例如 E_BOOK_ALREADY_BORROWED 去掉前缀后变成 book-already-borrowed。type 一旦发布就不要改,客户端可能按它做分支;要改语义就新增一个类型。status 直接取 ErrorCode.httpStatus(),code 取同一个错误码的 wire 值,两处不会漂移。

PageResponse 和 ApiError 在 web 层组装,不进入领域包。领域包不知道 HTTP 状态码,也不知道 Problem Details 的字段名。这是模块依赖方向的要求:web 依赖 domain,反过来不成立。

常见错误与边界

错误一:请求直接绑定领域对象。 Book、Loan 的组件包含服务端字段,客户端可以借此覆盖 id、returnedOn。请求 DTO 只保留可写字段,是防止批量赋值的第一道门。

错误二:创建和更新共用一个 DTO。 如果 UpdateBookRequest 也带 isbn,客户端就能通过更新接口改主键。创建需要 isbn,更新不需要,两个 record 分开定义,哪怕字段只差一个。

错误三:响应直接返回领域对象。 领域对象缺少 status、overdueDays,客户端得自己算;将来领域对象加内部字段,还会无意中暴露。响应 DTO 是面向客户端的稳定视图,领域对象可以自由演进。

错误四:用 Map<String, Object> 当响应。 字段名靠字符串约定,编译器帮不上忙;重构时改了一个 key,测试可能仍然通过。DTO 的字段是编译期检查的,改名会立刻编译失败。

错误五:分页只返回数组。 没有 total,客户端无法知道总页数;没有 page、size,也无法判断当前页。分页信封不是冗余,是协议的一部分。

边界:错误码与 HTTP 状态。 ErrorCode 已经带了 httpStatus(),不要在路由里为每个异常写 switch。新增错误码时,同时补上 value()、httpStatus() 和默认 title(),三处保持一致。

边界:DTO 的校验。 本节只负责形状,字段是否必填、长度是否超限,属于第 8 章的 Jakarta Validation。DTO 可以带校验注解,但转换方法不应该承担校验职责。

小结

  • 请求 DTO、响应 DTO、领域对象是三组不同的类型,形状相似但职责不同。
  • 请求 DTO 只暴露可写字段,阻止客户端覆盖 id、returnedOn 等内部状态。
  • LoanDto 的 status 和 overdueDays 在边界计算,规则只写一遍。
  • PageResponse 提供 items、total、page、size,客户端才能正确分页。
  • ApiError 按 Problem Details 组织,type 稳定、code 精确、requestId 可追踪。
  • libraryd 保留 null 字段,让响应结构稳定;转换逻辑集中在 DtoMapper。

DTO 的形状定下来后,还剩一类边界没有处理:当响应里出现子类型,或者对象之间互相引用时,Jackson 默认行为会失败。下一节看多态、循环引用,以及把 JSON 写进 PostgreSQL 的 jsonb 列。

阅读导航:上一节:6.1 Jackson 基础:绑定、命名与日期 · 下一节:6.3 序列化边界:多态、循环引用与 JSONB 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Netty 高性能网络编程》15.3 linkd-v2 验收与复盘
  2. 《Netty 高性能网络编程》15.2 限流、超时与降级
  3. 《Netty 高性能网络编程》15.1 网关路由与转发