Java Serverless on AWS Lambda · Java on Lambda: Runtime Fundamentals

Handlers, Event Types and Serialization

Why a Java handler receives objects full of nulls, and how to model Lambda events correctly.

Scenario

Example app: a Java function consumes order events from EventBridge. The team wrote a POJO OrderEvent with fields orderId, totalAmount and customerEmail and uses it as the handler's input type.

What you see in production

The function runs without errors, but every field is null and orders are processed with empty data. When an upstream team adds a field, some invocations start failing with deserialization errors.

The naive code

public class OrderHandler implements RequestHandler<OrderEvent, String> {
    @Override
    public String handleRequest(OrderEvent event, Context context) {
        process(event.getOrderId(), event.getTotalAmount());   // nulls: the JSON is nested and snake_case
        return "ok";
    }
}
// Incoming: {"detail-type":"OrderPlaced","detail":{"order_id":"o-1","total_amount":42.5}}

Reproduce it

A tiny mapper with the same behaviour as naive bean-style binding: it only fills fields whose names match the top-level JSON keys exactly:

Field names and nesting decide what gets bound
import java.util.*;

public class Main {
    record Order(String orderId, Double totalAmount) {}

    static Order bindTopLevel(Map<String, Object> json) {
        return new Order((String) json.get("orderId"), (Double) json.get("totalAmount"));
    }

    @SuppressWarnings("unchecked")
    static Order bindDetail(Map<String, Object> json) {
        Map<String, Object> detail = (Map<String, Object>) json.get("detail");
        if (detail == null || detail.get("order_id") == null) throw new IllegalArgumentException("missing detail.order_id");
        return new Order((String) detail.get("order_id"), ((Number) detail.get("total_amount")).doubleValue());
    }

    public static void main(String[] args) {
        Map<String, Object> event = Map.of(
            "detail-type", "OrderPlaced",
            "detail", Map.of("order_id", "o-1", "total_amount", 42.5));
        System.out.println("naive POJO binding: " + bindTopLevel(event));
        System.out.println("envelope-aware:     " + bindDetail(event));
    }
}

Output

naive POJO binding: Order[orderId=null, totalAmount=null]
envelope-aware:     Order[orderId=o-1, totalAmount=42.5]

Run this on your computer — see “Get Started” for setup.

Root cause

Lambda's Java runtime deserializes the event JSON into your input type by matching property names. EventBridge, SQS, API Gateway and other sources wrap your payload in an envelope with their own structure and naming (detail, Records[].body, body as a string). A POJO that models only the business payload, or uses different naming, silently gets nulls. Strict mappers fail on unknown properties when publishers add fields.

The fix

  • Use the official event types from aws-lambda-java-events (ScheduledEvent/EventBridge events, SQSEvent, APIGatewayV2HTTPEvent) for the envelope.
  • Deserialize the payload yourself with a configured Jackson ObjectMapper (created once, statically): snake_case naming, FAIL_ON_UNKNOWN_PROPERTIES disabled, and validation of required fields.
  • Or take the raw stream (RequestStreamHandler) for full control.
public class OrderHandler implements RequestHandler<SQSEvent, SQSBatchResponse> {
    private static final ObjectMapper MAPPER = new ObjectMapper()
        .setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE)
        .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);

    @Override
    public SQSBatchResponse handleRequest(SQSEvent event, Context context) {
        List<SQSBatchResponse.BatchItemFailure> failures = new ArrayList<>();
        for (SQSEvent.SQSMessage msg : event.getRecords()) {
            try {
                OrderPlaced order = MAPPER.readValue(msg.getBody(), OrderPlaced.class);
                process(order.validate());
            } catch (Exception e) {
                failures.add(new SQSBatchResponse.BatchItemFailure(msg.getMessageId()));
            }
        }
        return new SQSBatchResponse(failures);
    }
}

Trade-offs

  • Custom deserialization adds a little code but makes the contract explicit and testable.
  • Ignoring unknown properties keeps consumers working when publishers add fields; validate the fields you rely on.
  • Powertools for AWS Lambda (Java) offers helpers for deserializing common envelopes.

Detect it in production

  • Unit tests with real sample events for each source (the aws-lambda-java-tests library loads JSON events into the event types).
  • Alarms on processed records with missing required data; validation failure counts.
  • Logs of deserialization errors after upstream deployments.

Key points

  • Events arrive in source-specific envelopes; model them with aws-lambda-java-events.
  • Deserialize payloads with a static, configured ObjectMapper and validate.
  • Test with real sample events.

Further reading: Define Lambda function handlers in Java · aws-lambda-java-libs on GitHub

Practice questions

Why might a Java handler's POJO input have all fields null?

Answer

The runtime binds JSON properties to fields by name. Event sources wrap the payload in their own envelope (for example EventBridge's detail) and may use different naming, so a POJO modelling only the business payload matches nothing.

Where should a Jackson ObjectMapper be created in a Lambda function?

Answer

Once, in a static field or during initialisation, because it is expensive to create and thread-safe after configuration; creating it per invocation wastes time and memory.

What is aws-lambda-java-tests useful for?

Answer

Loading JSON sample events into the aws-lambda-java-events types in JUnit tests, so handlers can be tested with realistic payloads for each event source.

Open in the app to mark lessons complete, take notes and track your progress.