This guide continues the domain from the Getting Started guide — the same users resource and UserOperations class. Here we’ll add create, update, and delete operations to turn the read-only API into a full CRUD service.

If you haven’t completed the Getting Started guide yet, start there first.

What We’ll Build

By the end of this page, the users resource will support:

HTTP Endpoint Operation Response
GET /users Read multiple 200 OK (already implemented)
POST /users Create 201 Created
PATCH /users/{id} Update 204 No Content
DELETE /users/{id} Delete 204 No Content

Some request-level rejections are enforced for you before any of your own validation runs, because JSON:API names the status for each:

Request Response
POST whose body supplies its own data.id 403 Forbidden, CLIENT_GENERATED_ID_NOT_SUPPORTED — ids are assigned by the server
POST or PATCH whose data.type is not the collection’s type 409 Conflict, CONFLICT
PATCH whose data.id names a different resource than the URL 409 Conflict, CONFLICT

Each is answered on its own rather than alongside other validation errors — see Answering with a different status.

1. Accessing the Request Payload

Write operations receive a JSON:API document as the request body. The framework parses it and makes it available via request.getSingleResourceDocPayload(). You pass your attributes class to get typed access:

var payload = request.getSingleResourceDocPayload(UserAttributes.class);
UserAttributes attributes = payload.getData().getAttributes();

Without a type argument, attributes are deserialized as LinkedHashMap.

2. Add Create Operation

Add the create method to the existing UserOperations class:

@JsonApiResourceOperation(resource = UserResource.class)
public class UserOperations implements ResourceOperations<UserDbEntity> {

    private final UserDb userDb;

    public UserOperations(UserDb userDb) {
        this.userDb = userDb;
    }

    // readPage — already implemented in Getting Started
    @Override
    public PaginationAwareResponse<UserDbEntity> readPage(JsonApiRequest request) {
        // ...
    }

    @Override
    public UserDbEntity create(JsonApiRequest request) {
        var payload = request.getSingleResourceDocPayload(UserAttributes.class);
        UserAttributes attributes = payload.getData().getAttributes();
        return userDb.createUser(
                attributes.getFullName(),
                attributes.getEmail(),
                attributes.getCreditCardNumber()
        );
    }
}

The create method returns a UserDbEntity — the framework uses it to compose a 201 Created response with the newly created resource, including its server-generated id.

Extend UserDb to support creation:

public class UserDb {

    // ... existing code ...

    public UserDbEntity createUser(String fullName, String email, String creditCardNumber) {
        String id = String.valueOf(users.size() + 1);
        UserDbEntity entity = new UserDbEntity(id, fullName, email, creditCardNumber);
        users.put(id, entity);
        return entity;
    }
}

Request

POST /users

{
  "data": {
    "type": "users",
    "attributes": {
      "fullName": "Alice Smith",
      "email": "alice@example.com",
      "creditCardNumber": "999888777"
    }
  }
}

Response — 201 Created

{
  "data": {
    "attributes": {
      "fullName": "Alice Smith",
      "email": "alice@example.com",
      "creditCardNumber": "999888777"
    },
    "links": {
      "self": "/users/6"
    },
    "id": "6",
    "type": "users"
  }
}

3. Add Update Operation

Add the update method to the same UserOperations class:

@Override
public void update(JsonApiRequest request) {
    var attributes = request.getSingleResourceDocPayload().getData().getAttributes();

    Map<String, Object> changes = new LinkedHashMap<>();
    for (String attribute : List.of("fullName", "email", "creditCardNumber")) {
        if (attributes.containsKey(attribute)) {
            changes.put(attribute, attributes.get(attribute));
        }
    }

    userDb.updateUser(request.getResourceId(), changes);
}

The update method returns void — the framework returns 204 No Content automatically.

The resource ID comes from the URL path (/users/3), available via request.getResourceId(). The attributes come from the request body, and changes carries only the ones the client actually sent — PATCH is a partial update, and the next section explains why that distinction has to be made here rather than further down.

Extend UserDb:

public void updateUser(String id, Map<String, Object> changes) {
    UserDbEntity current = users.get(id);
    if (current == null) {
        throw new ResourceNotFoundException(id, new ResourceType("users"));
    }
    users.put(id, new UserDbEntity(
            id,
            (String) changes.getOrDefault("fullName", current.getFullName()),
            (String) changes.getOrDefault("email", current.getEmail()),
            (String) changes.getOrDefault("creditCardNumber", current.getCreditCardNumber())
    ));
}

Request

PATCH /users/3

{
  "data": {
    "type": "users",
    "id": "3",
    "attributes": {
      "fullName": "Jack Updated",
      "email": "jack.updated@doe.com",
      "creditCardNumber": "333456789"
    }
  }
}

Response — 204 No Content

Empty body.

Partial updates

The update above reads the attributes untyped and forwards only the ones the client sent. That is not a stylistic preference — it is what the specification requires, and what typed binding cannot express.

Updating a Resource’s Attributes requires that missing attributes be interpreted as if they were sent with their current values, and explicitly forbids treating them as null. A PATCH carrying only email must leave fullName alone — which is what getOrDefault says in updateUser, once changes is known to hold only what arrived.

A member that is present replaces the old value in full. For an array- or object-valued attribute that means wholesale replacement rather than a merge — the same stance the specification takes for to-many relationships.

Typed binding cannot tell those two cases apart. Jackson binds both an absent member and an explicit "fullName": null to null, so attributes.getFullName() cannot answer “did the client send this?”. Testing for null is the common workaround and it does satisfy the specification, but it costs you the ability to clear an attribute: the request that would clear it is indistinguishable from the one that omits it. On the raw LinkedHashMap returned by getSingleResourceDocPayload(), presence and value stay independent, so an explicit null can mean “clear this” while an absent key means “leave it alone”.

Both views may be used in the same operation — payloads are deserialized once per type and cached — so typed accessors can stay in place for attributes that are always sent, with the untyped view reserved for those that need “absent” and “null” to mean different things.

4. Add Delete Operation

Add the delete method:

@Override
public void delete(JsonApiRequest request) {
    userDb.deleteUser(request.getResourceId());
}

No request body is needed — the resource ID comes from the URL path.

Extend UserDb:

public void deleteUser(String id) {
    if (!users.containsKey(id)) {
        throw new ResourceNotFoundException(id, new ResourceType("users"));
    }
    users.remove(id);
}

Request

DELETE /users/3

Response — 204 No Content

Empty body.

5. Add Validation

Each operation has a dedicated validation method that runs before the main logic. Use JsonApiRequestValidator.forRequest(request) to build validation rules declaratively. Each validator callback receives a typed assertion object that supports fluent chaining:

import static pro.api4.jsonapi4j.operation.validation.JsonApiRequestValidator.forRequest;

@Override
public void validateCreate(JsonApiRequest request) {
    forRequest(request)
            .singleResourceBody(UserAttributes.class, body -> body
                    .withResourceTypeValidator(type -> type.isOneOf("users"))
                    .withAttributesValidator(att -> {
                        att.isNotNull();
                        att.field("email", UserAttributes::getEmail).asString()
                                .isNotBlank()
                                .isEmail();
                    }))
            .validate();
}

The validator collects all errors across sections and returns them in a single response. If both the resource type and email are invalid, the client gets both errors at once:

{
  "errors": [
    {
      "id": "...",
      "status": "400",
      "code": "INVALID_ENUM_VALUE",
      "detail": "'wrong' value is not allowed, available values: [users]",
      "source": {
        "pointer": "/data/type"
      }
    },
    {
      "id": "...",
      "status": "400",
      "code": "VALUE_INVALID_FORMAT",
      "detail": "Invalid email format",
      "source": {
        "pointer": "/data/attributes/email"
      }
    }
  ]
}

For a full list of validation methods per operation type, see Operations — Validation. For the complete error handling story, see Error Handling.

Summary

The complete UserOperations now looks like this:

@JsonApiResourceOperation(resource = UserResource.class)
public class UserOperations implements ResourceOperations<UserDbEntity> {

    private final UserDb userDb;

    public UserOperations(UserDb userDb) {
        this.userDb = userDb;
    }

    @Override
    public PaginationAwareResponse<UserDbEntity> readPage(JsonApiRequest request) {
        UserDb.DbPage<UserDbEntity> pagedResult = userDb.readAllUsers(request.getCursor());
        return PaginationAwareResponse.cursorAware(
                pagedResult.getEntities(),
                pagedResult.getCursor()
        );
    }

    @Override
    public UserDbEntity create(JsonApiRequest request) {
        var payload = request.getSingleResourceDocPayload(UserAttributes.class);
        UserAttributes attributes = payload.getData().getAttributes();
        return userDb.createUser(
                attributes.getFullName(),
                attributes.getEmail(),
                attributes.getCreditCardNumber()
        );
    }

    @Override
    public void update(JsonApiRequest request) {
        var payload = request.getSingleResourceDocPayload(UserAttributes.class);
        UserAttributes attributes = payload.getData().getAttributes();
        userDb.updateUser(
                request.getResourceId(),
                attributes.getFullName(),
                attributes.getEmail(),
                attributes.getCreditCardNumber()
        );
    }

    @Override
    public void delete(JsonApiRequest request) {
        userDb.deleteUser(request.getResourceId());
    }
}

Three methods added to the same class from Getting Started. The Resource, UserAttributes, and UserDbEntity classes remain unchanged — write operations use the same domain model as reads.