The first instinct when you have a User POJO is to use it for everything: request bodies, response bodies, edits, deletes, errors. It works, briefly. Then the request POJO needs an id it shouldn't send, the response needs server-generated timestamps the client can't set, and the error response is a completely different shape. The cleanest convention — and the one most production frameworks settle on — is separate request and response models, named after what they carry. This lesson is the rationale, the layout, and a complete CRUD test that uses the pattern from end to end. The TypeScript for QA lesson on typed page-object data uses the same idea in a different language; the principle generalises.
Why one model isn't enough
A single User class accumulates tension as the API gets used:
- The request POST body has no
id(the server assigns it). The response does haveid. A sharedUsereither makesidnullable everywhere or sends"id": nullto the API. - The response has
createdAtandupdatedAt. The request has neither. A sharedUsereither sends timestamps the server ignores or has nullable fields the client never sets. - A PATCH body is partial — only the fields to change. A response is full. Separate models make this explicit; a shared model needs
@JsonInclude(NON_NULL)and runtime conventions. - An error response has a completely different shape (
error,message,details). Forcing it throughUseris nonsensical.
Two POJOs — CreateUserRequest and UserResponse — solve all of it cleanly.
Request model: only the fields the client sends
package com.mycompany.apitests.models.request;
public class CreateUserRequest {
private String name;
private String email;
private String role;
public CreateUserRequest(String name, String email, String role) {
this.name = name;
this.email = email;
this.role = role;
}
public CreateUserRequest() {} // Jackson needs this even on requests in some flows
// getters/setters
}Three fields, three setters, one constructor with the required ones. No id, no timestamps, no nullable accidents. The constructor's signature documents what the API requires.
Response model: everything the server sends
package com.mycompany.apitests.models.response;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
@JsonIgnoreProperties(ignoreUnknown = true)
public class UserResponse {
private int id;
private String name;
private String email;
private String role;
private String createdAt;
private String updatedAt;
// no-args ctor + getters/setters
}A few deliberate choices:
- Always
@JsonIgnoreProperties(ignoreUnknown = true)on response models, as covered in the previous lesson. String createdAt, notDate createdAt— keep dates as ISO strings unless your assertions need date math. Jackson can do the conversion, but the simpler type is friendlier when assertions just compare to a regex.- Public no-args constructor — Jackson requires it; the test's all-args usage doesn't need it but doesn't hurt.
A complete CRUD test
@Test
public void createReadUpdateDelete() {
// CREATE
CreateUserRequest createReq = new CreateUserRequest("Alice", "alice@test.com", "admin");
UserResponse created = given()
.contentType(ContentType.JSON)
.body(createReq)
.when()
.post("/users")
.then()
.statusCode(201)
.extract().as(UserResponse.class);
Assert.assertTrue(created.getId() > 0);
Assert.assertEquals(created.getName(), "Alice");
Assert.assertNotNull(created.getCreatedAt());
// READ
UserResponse fetched = given()
.pathParam("id", created.getId())
.when()
.get("/users/{id}")
.then()
.statusCode(200)
.extract().as(UserResponse.class);
Assert.assertEquals(fetched.getEmail(), "alice@test.com");
// UPDATE
UpdateUserRequest updateReq = new UpdateUserRequest("Alice Smith", "viewer");
UserResponse updated = given()
.pathParam("id", created.getId())
.contentType(ContentType.JSON)
.body(updateReq)
.when()
.patch("/users/{id}")
.then()
.statusCode(200)
.extract().as(UserResponse.class);
Assert.assertEquals(updated.getName(), "Alice Smith");
Assert.assertEquals(updated.getRole(), "viewer");
// DELETE
given()
.pathParam("id", created.getId())
.when()
.delete("/users/{id}")
.then()
.statusCode(204);
}The shape is unmistakable: every step uses the right request model on the way in, the right response model on the way out, and assertions read like English. No JsonPath strings; no field-name typos that could compile.
Project layout
A small but high-leverage convention — co-locate models by direction:
src/test/java/com/mycompany/apitests/models/
├── request/
│ ├── CreateUserRequest.java
│ ├── UpdateUserRequest.java
│ └── LoginRequest.java
└── response/
├── UserResponse.java
├── LoginResponse.java
└── ErrorResponse.java
Imports stay scoped — import ...models.request.* in tests that send, import ...models.response.* in tests that read. When the API team renames a request field, the only file that needs editing is in request/. The split is small upfront work that pays back at every refactor.
The error response model
Most APIs have a consistent error shape — error code, message, and per-field details. Model it once:
package com.mycompany.apitests.models.response;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import java.util.List;
@JsonIgnoreProperties(ignoreUnknown = true)
public class ErrorResponse {
private int status;
private String error;
private String message;
private List<String> details;
private String timestamp;
// no-args ctor + getters/setters
}Then assertions become assertions, not string-fishing:
@Test
public void emptyEmailReturnsValidationError() {
CreateUserRequest bad = new CreateUserRequest("Alice", "", "admin");
ErrorResponse error = given()
.contentType(ContentType.JSON)
.body(bad)
.when()
.post("/users")
.then()
.statusCode(400)
.extract().as(ErrorResponse.class);
Assert.assertEquals(error.getStatus(), 400);
Assert.assertEquals(error.getError(), "Validation Error");
Assert.assertTrue(error.getDetails().contains("Email is required"));
}A consistent error model means every negative test can use the same assertions. When the team adds a new validation rule, the test changes are mechanical.
How the models relate
- – CreateUserRequest
- – UpdateUserRequest
- – LoginRequest
- – UserResponse
- – LoginResponse
- – OrderResponse
- – ErrorResponse (status, error, message, details)
- Address (request + response) –
- Pagination metadata –
The "shared" branch is real but small — the value object types (an Address that's identical on the request and the response, a pagination wrapper used by every list endpoint) are the only places where one model serves both directions. Everything else splits.
Sub-models: when to extract
Inline POJOs are fine for one-off shapes; extract a separate file when the shape gets used twice. A practical rule of thumb: when the third test class needs to deserialise an Address, move Address.java into the shared models package and import it from both User and Order.
Update vs partial-update bodies
A common nuance: the same endpoint may accept a PUT (full replacement) and a PATCH (partial update). The wire shapes differ; the request models should differ too:
// PUT — every field required
public class ReplaceUserRequest {
private String name;
private String email;
private String role;
// all required, no nulls expected
}
// PATCH — only the fields the client wants to change
@JsonInclude(JsonInclude.Include.NON_NULL)
public class UpdateUserRequest {
private String name;
private String email;
private String role;
// any of these may be null; nulls are dropped from the body
}@JsonInclude(NON_NULL) is what makes the partial-update body actually partial. Without it, a PATCH that only sets role sends {"name": null, "email": null, "role": "viewer"} — and a strict API will clear the omitted fields. The annotation flips that to {"role": "viewer"}, the shape PATCH semantics demand.
⚠️ Common mistakes
- One
Userfor both directions. Works on day one, accumulates nullable fields and confused responsibilities by month two. Pay the small upfront cost to split request and response. @JsonInclude(NON_NULL)on a response model. It does nothing on a deserialised object —nullis fine in Java. The annotation belongs on request (PATCH) models, where the goal is to keep nulls off the wire.- Using
Map<String, Object>for the error shape. Tests then write((List<String>) error.get("details")).contains(...)— a chain of unchecked casts and the lure of typos. Build theErrorResponsePOJO; reach for the typed model the moment you write the second negative test.
🎯 Practice task
Refactor a small CRUD suite to use separated request and response models. 30 minutes against REQRES.
- Create the package structure:
models/request/,models/response/. Move/buildCreateUserRequest,UpdateUserRequest,UserResponse,ErrorResponse. - Write
createUser()— POST/api/userswith aCreateUserRequest. ExtractUserResponse. Assert onidandcreatedAt. (REQRES generates both.) - Write
updateUser()— PATCH/api/users/2with anUpdateUserRequestthat sets onlyjob. Use@JsonInclude(NON_NULL)so unset fields don't go on the wire. Confirm via.log().body()that the request payload only contains the fields you set. - Write
replaceUser()— PUT/api/users/2with aReplaceUserRequest(no@JsonIncludeannotation, all fields required). Note the difference in wire payload. - Error model. Force a 400 against any API that validates strictly (or fall back to inducing a 404 on REQRES for a non-existent user). Deserialise to
ErrorResponse. Assert on at least one field. - Shared sub-model. Add an
AddressPOJO. Use it both as a field onCreateUserRequestand as a field onUserResponse. Note that one shared file is fine when the shape is genuinely identical. - Smoke chain. Stitch together a single test that does CREATE → READ → UPDATE → DELETE on the same id. Each step should use the right typed model. Read the test top to bottom — note how no JsonPath strings are needed.
- Stretch: add an
@JsonIgnoreProperties(ignoreUnknown = true)to every response model. Then add a junk field to one (private String fakeField). The test should still pass — proving response models tolerate forward-evolution from the API.
Next lesson: Lombok — how to write all of these POJOs in a fraction of the lines, with @Data, @Builder, and the IDE plumbing that makes them feel native.