Mutations
Contents
Add mutations so clients can change data, not just read it.
Our API will manage notes: create one, fetch it by id, list them all, and delete one.
Defining the Note type
The type below uses more of the library than the schema we built in the previous chapter, so start by extending the import:
import {
GraphQLID,
GraphQLString,
list,
newSchemaGenerator,
nonNull,
reference
} from '/lib/graphql';
First, we need to define a Note type that describes the structure of a note object. Here’s a basic example:
const noteType = schemaGenerator.createObjectType({
name: 'Note',
fields: {
id: {
type: nonNull(GraphQLID),
},
title: {
type: nonNull(GraphQLString),
},
content: {
type: GraphQLString,
},
createdAt: {
type: nonNull(GraphQLString),
}
}
});
createObjectType defines the fields, and nonNull marks the ones that must always have a value.
Defining the root Mutation type
A mutation type is built like any other object type. The only difference is where it goes: assigned to mutation when the schema is created.
The root Mutation type holds the operations that change data — here, creating and deleting a note:
const rootMutationType = schemaGenerator.createObjectType({
name: 'Mutation',
fields: {
createNote: {
type: reference('Note'),
args: {
title: nonNull(GraphQLString),
content: nonNull(GraphQLString),
},
resolve: (env) => {
// Creates the note and returns it as the result.
}
},
deleteNote: {
type: reference('Note'),
args: {
id: nonNull(GraphQLID),
},
resolve: (env) => {
// Removes the note and returns it as the result.
}
},
}
});
Two things are new here: the fields take args, and they need a resolve function. A field whose name matches a property on the source object needs no resolver — the Note fields above rely on that.
The field type is given by reference, one of the type helpers described in the GraphQL Library documentation.
You could also directly use the noteType variable, which was initialized when creating the Note type. However, using reference is preferred, especially for larger schemas. |
Add it to the schema:
const graphQLSchema = schemaGenerator.createSchema({
query: rootQueryType, (1)
mutation: rootMutationType, (2)
dictionary: [noteType] (3)
});
| 1 | Sets the root Query type to handle data fetching operations. |
| 2 | Sets the root Mutation type to handle data modification operations. |
| 3 | Lists the types used in the schema, including custom objects like Note. |
dictionary is also necessary to ensure that objects referenced via reference are properly resolved and included in the schema. |
The complete schema.ts, including the getNote and getNotes query fields, which follow the same pattern:
import {
GraphQLID,
GraphQLString,
list,
newSchemaGenerator,
nonNull,
reference
} from '/lib/graphql';
import * as notes from '/lib/notes';
const schemaGenerator = newSchemaGenerator();
const noteType = schemaGenerator.createObjectType({
name: 'Note',
fields: {
id: {
type: nonNull(GraphQLID),
},
title: {
type: nonNull(GraphQLString),
},
content: {
type: GraphQLString,
},
createdAt: {
type: nonNull(GraphQLString)
}
}
});
const rootQueryType = schemaGenerator.createObjectType({
name: 'Query',
fields: {
serverTime: {
type: GraphQLString,
resolve: () => new Date().toISOString()
},
getNote: {
type: reference('Note'),
args: {
id: nonNull(GraphQLID),
},
resolve: (env) => notes.get((env.args as {id: string}).id),
},
getNotes: {
type: list(reference('Note')),
resolve: () => notes.list()
}
},
});
const rootMutationType = schemaGenerator.createObjectType({
name: 'Mutation',
fields: {
createNote: {
type: reference('Note'),
args: {
title: nonNull(GraphQLString),
content: nonNull(GraphQLString),
},
resolve: (env) => {
const args = env.args as {title: string; content: string};
const note = notes.create(args.title, args.content);
return note;
}
},
deleteNote: {
type: reference('Note'),
args: {
id: nonNull(GraphQLID),
},
resolve: (env) => {
const note = notes.remove((env.args as {id: string}).id);
return note;
}
}
}
});
const graphQLSchema = schemaGenerator.createSchema({
query: rootQueryType,
mutation: rootMutationType,
dictionary: [noteType]
});
export default graphQLSchema;
The resolvers delegate to a module of our own, which is where the notes live.
Storing the notes
Our schema needs somewhere to keep notes. This guide uses Cache Library, which keeps them in the memory of one server — enough to see mutations and subscriptions working, without bringing a content model into a chapter about GraphQL.
The starter already lists this library in build.gradle, commented out — uncomment it. Its version is in gradle/libs.versions.toml too, so there is nothing to add there. A declaration is still needed, as in the previous chapter: /lib/cache publishes no @enonic-types package either.
include libs.cache
declare global {
interface XpLibraries {
'/lib/cache': typeof import('./cache');
}
}
export interface Cache<T> {
get(key: string, fetcher: () => T): T;
getIfPresent(key: string): T | null;
put(key: string, value: T): void;
remove(key: string): void;
removePattern(keyRegex: string): void;
clear(): void;
getSize(): number;
}
export interface CacheParams {
size?: number;
expire?: number;
}
export declare function newCache<T>(params: CacheParams): Cache<T>;
"/lib/cache": ["./types/cache"],
Then create the module the resolvers call:
import {newCache} from '/lib/cache';
export interface Note {
id: string;
title: string;
content: string;
createdAt: string;
}
const NOTES = 'notes';
const cache = newCache<Record<string, Note>>({size: 10});
function notes(): Record<string, Note> {
return cache.get(NOTES, () => ({}));
}
export function list(): Note[] {
const all = notes();
return Object.keys(all).map((key) => all[key]);
}
export function get(id: string): Note | undefined {
return notes()[id];
}
export function create(title: string, content: string): Note {
const note: Note = {
id: Math.random().toString(36).substring(2, 15),
title: title,
content: content,
createdAt: new Date().toISOString(),
};
const all = notes();
all[note.id] = note;
cache.put(NOTES, all);
return note;
}
export function remove(id: string): Note | undefined {
const all = notes();
const note = all[id];
if (note) {
delete all[id];
cache.put(NOTES, all);
}
return note;
}
Two details are worth pointing out.
A single key holds the whole map. The library cannot iterate its entries, so there is no way to implement getNotes from one entry per note.
Every change is written back with put. Cache Library 3.0.0 stores objects by reference, so mutating the map returned by get would be enough on its own. Writing it back is what the library’s documentation asks for, and it keeps create and remove correct if that behaviour ever changes.
This store lives in memory on a single node and does not survive a reload. Every enonic dev rebuild and every enonic project deploy starts from an empty store, so expect to recreate your notes as you work through the rest of the tutorial. In a cluster each node would keep its own copy, and requests would see different notes depending on where they landed. Anything that must persist, or run on more than one node, belongs in lib-node or a grid library. |
XP 8 runs one script engine per application, so the module-level cache variable — like any module-level variable — is in practice shared by every request. Do not build on that. The platform gives no such guarantee, and the coming GraalJS engine pool will run several engines. Cache Library 3.1 adds application-wide named caches for exactly that case; 3.0.0 accepts only size and expire, and each call to newCache returns a cache local to the script context that made it. |
Mutations travel over the same endpoint as queries — they are sent in the query property of the request body, exactly like a query:
curl -X POST "http://localhost:8080/api/com.example.gqltutorial:graphql" \
-H "Content-Type: application/json" \
-d '{"query":"mutation { createNote(title: \"My Note\", content: \"Brief content\") { id title createdAt } }"}'
{
"data":{
"createNote":{
"id":"4oj8mwaojlc9z",
"title":"My Note",
"createdAt":"2026-08-13T11:06:29.426Z"
}
}
}
Fetch it back with getNote, or list everything with getNotes:
curl -X POST "http://localhost:8080/api/com.example.gqltutorial:graphql" \
-H "Content-Type: application/json" \
-d '{"query":"{ getNotes { id title } }"}'
{
"data":{
"getNotes":[
{
"id":"af6c4jrkjy4s5",
"title":"My Note"
}
]
}
}
Only the fields you ask for come back — content and createdAt exist on the note, but were not requested here.
Using variables
Values written into the operation work, but clients normally send them separately as variables. Declare them in the operation, then supply the values with the request:
mutation CreateNote($title: String!, $content: String!) {
createNote(title: $title, content: $content) {
id
createdAt
}
}
{
"title": "Shopping list",
"content": "Coffee, milk, bread"
}
The values travel in the variables property of the request body, which our controller already forwards to execute(). Most GraphQL clients have a separate pane for them:
curl -X POST "http://localhost:8080/api/com.example.gqltutorial:graphql" \
-H "Content-Type: application/json" \
-d '{"query":"mutation CreateNote($title: String!, $content: String!) { createNote(title: $title, content: $content) { id createdAt } }","variables":{"title":"Shopping list","content":"Coffee, milk, bread"}}'
{
"data":{
"createNote":{
"id":"wnh3voq1lrk9f",
"createdAt":"2026-09-02T09:53:05.369Z"
}
}
}
The response contains exactly the fields the operation selected — content was sent as a variable but not asked for back.
Note the single quotes around the payload. They keep the shell from expanding $title and $content before cURL ever sees them. |
Summary
This chapter covered:
-
adding a mutation to the schema
-
taking arguments on a field
-
registering a type in the
dictionarysoreferencecan resolve it -
sending argument values as variables
Coming up
In the final chapter you’ll add subscriptions, so clients are told about new and deleted notes as they happen.