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

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 payload = request.getSingleResourceDocPayload(UserAttributes.class);
    UserAttributes attributes = payload.getData().getAttributes();
    userDb.updateUser(
            request.getResourceId(),
            attributes.getFullName(),
            attributes.getEmail(),
            attributes.getCreditCardNumber()
    );
}

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 updated attributes come from the request body.

Extend UserDb:

public void updateUser(String id, String fullName, String email, String creditCardNumber) {
    if (!users.containsKey(id)) {
        throw new ResourceNotFoundException(id, new ResourceType("users"));
    }
    users.put(id, new UserDbEntity(id, fullName, email, creditCardNumber));
}

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.

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.