Write Operations
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.