Using POJOs for Request and Response Models

8 min read

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 have id. A shared User either makes id nullable everywhere or sends "id": null to the API.
  • The response has createdAt and updatedAt. The request has neither. A shared User either 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 through User is 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, not Date 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

POJO models
  • – 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 User for 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 — null is 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 the ErrorResponse POJO; 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.

  1. Create the package structure: models/request/, models/response/. Move/build CreateUserRequest, UpdateUserRequest, UserResponse, ErrorResponse.
  2. Write createUser() — POST /api/users with a CreateUserRequest. Extract UserResponse. Assert on id and createdAt. (REQRES generates both.)
  3. Write updateUser() — PATCH /api/users/2 with an UpdateUserRequest that sets only job. 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.
  4. Write replaceUser() — PUT /api/users/2 with a ReplaceUserRequest (no @JsonInclude annotation, all fields required). Note the difference in wire payload.
  5. 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.
  6. Shared sub-model. Add an Address POJO. Use it both as a field on CreateUserRequest and as a field on UserResponse. Note that one shared file is fine when the shape is genuinely identical.
  7. 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.
  8. 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.

// tip to track lessons you complete and pick up where you left off across devices.