Posted in

Java Records: Creating Complex JSON Payloads the Modern Java Way

If you’ve been testing APIs over the years, you may have noticed payloads getting more complex, think nested arrays, conditional fields, and deep structures. There are many valid reasons for this. Our applications are no longer single large systems; they are now networks of smaller, specialised Microservices that must combine many different pieces of information into one single data package for the client.

Simple JSON objects can be included within methods without overwhelming the codebase, whilst static files offer a simple solution without offering much variety or extendability. As payloads get more complicated, these methods become cumbersome and hard to manage

The Builder Pattern is another widely accepted solution designed specifically for creating complex, immutable objects. It works by employing a verbose, multi-step process where you manually chain several distinct methods (e.g.setId(1).setName("Bob")) to collect parameters before calling .build(). While this approach successfully guarantees immutability (cannot be changed), it introduces significant boilerplate code for every field in your data structure, making the model definition itself bulky and time-consuming to maintain as payloads evolve. In contrast, Java Records offer a superior, boilerplate-free solution by automatically providing a clean, concise canonical constructor that allows the entire nested data hierarchy to be constructed directly in a single, readable line of code.

It’s time to level up! If you’re on Java 16 or later, let’s dive into how Java Records combined with Jackson’s ObjectMapper Create the most elegant and safe way to generate those pesky nested payloads for your tests.


Complex Payloads

When generating a JSON payload for an API test, we need two things:

  1. Structure: A clear way to define the nested hierarchy (objects and arrays).
  2. Clean Code: A simple, concise way to instantiate and populate that structure.

Let’s look at the structure we want to generate, a generic user payload with a nested array of roles:

JSON

{
  "userId": 12345,
  "userName": "testleft_user",
  "accountStatus": "ACTIVE",
  "attributes": [
    {
      "role": "ADMIN",
      "level": 90
    },
    {
      "role": "READER",
      "level": 10
    }
  ]
}

Solution: The Power of Java Records

To model this structure, we define one Record for the top-level payload and another Record for the nested array item.

Understanding the Boilerplate Killer

Java Records are specialised data classes that eliminate the need to write repetitive code. When you define a Record, the Java compiler treats it as a Data Carrier, automatically generating the following essential members:

  • Canonical Constructor: A public constructor that requires a value for every field in the exact order they are declared. This is the constructor we use to create the entire object in one clean line.
  • Final Fields: All components are implicitly private final, making the object immutable by design. This is key for thread safety in applications and integrity in our tests.
  • Accessors: Public methods matching the field names (e.g., userId() instead of getUserId()).
  • equals() and hashCode(): Implementations that ensure two Record instances are considered equal only if all of their corresponding fields are equal. This makes them reliable keys in Maps or elements in Sets.

Defining the Records (Our Model)

We will place these Records inside a dedicated data class (which often resides in a model, dto, or payload package in a typical Maven project) to define our API schema.

Java

import java.util.List;

public class UserPayloadModel {

    // Record for the nested objects inside the 'attributes' array
    public record UserAttribute(String role, Integer level) {
    }

    // Record for the top-level payload
    public record UserPayload(
        Long userId,
        String userName,
        String accountStatus,
        List<UserAttribute> attributes // Nested List of Records!
    ) {
    }
}

Now, instead of relying on slow setter methods or complex Builder objects, we create the objects directly using their concise constructors.


The Final Step: Serialisation with ObjectMapper

We have our beautiful, type-safe Java objects. Now, we use Jackson’s ObjectMapper to turn that object graph into the final JSON string.

The generatePayload Method

To keep our test code clean, we’ll wrap the generation logic in a reusable static method. Crucially, we must configure the ObjectMapper correctly.

Java

import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.annotation.JsonInclude;
// ... imports for UserPayloadModel ...

public class PayloadGenerator {

    // IMPORTANT: Configure ObjectMapper once to ignore null fields!
    private static final ObjectMapper MAPPER = new ObjectMapper()
        .setSerializationInclusion(JsonInclude.Include.NON_NULL);
    
    // --- The Main Payload Generation Method ---
    public static String generateUserPayload(
        String name, 
        String status, 
        List<UserAttribute> roles) throws Exception { 

        // 1. Create the complex nested objects using the auto-generated constructors
        UserPayload payload = new UserPayload(
            12345L, 
            name, 
            status, 
            roles
        );

        // 2. Serialize the Record object using the configured ObjectMapper
        // The NON_NULL setting ensures the JSON is clean!
        return MAPPER.writeValueAsString(payload);
    }
}

Writing the Test Case: The Ultimate Clean Execution

The real benefit is seen in the test file itself. We can now demonstrate the clean, minimal structure required in a modern JUnit test:

Java

import org.junit.jupiter.api.Test;
import java.util.List;
import com.testleft.payload.PayloadGenerator;
import com.testleft.payload.UserPayloadModel;

public class UserPayloadGenerationTest {

    @Test
    void shouldGenerateComplexPayloadSuccessfully() {
        
        List<UserPayloadModel.UserAttribute> roles = List.of(
            new UserPayloadModel.UserAttribute("ADMIN", 90),
            new UserPayloadModel.UserAttribute("READER", 10)
        );
        
        String jsonBody = PayloadGenerator.generateUserPayload(
            "testleft_user", 
            "ACTIVE", 
            roles
        );
        
        System.out.println(jsonBody);
    }
}

The Key Takeaway

By combining Java Records for immutable data modelling and a configured ObjectMapper (set to ignore nulls) For serialisation, we eliminate mountains of boilerplate, enforce type safety, and generate complex, production-ready JSON payloads for our tests with minimal effort. This is the definition of clean, maintainable test automation that leaves unreliable file operations behind.


Leave a Reply

Your email address will not be published. Required fields are marked *