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:

src/main/resources/apis/graphql/schema.ts
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:

src/main/resources/apis/graphql/schema.ts
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:

src/main/resources/apis/graphql/schema.ts
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:

src/main/resources/apis/graphql/schema.ts
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:

/src/main/resources/apis/graphql/schema.ts
import {
    GraphQLID,
    GraphQLString,
    list,
    newSchemaGenerator,
    nonNull,
    reference
} from '/lib/graphql';
import {send} from '/lib/xp/event';
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);

                send({
                    type: 'note.created',
                    distributed: true,
                    data: {
                        note: note,
                    }
                });

                return note;
            }
        },
        deleteNote: {
            type: reference('Note'),
            args: {
                id: nonNull(GraphQLID),
            },
            resolve: (env) => {
                const note = notes.remove((env.args as {id: string}).id);

                send({
                    type: 'note.deleted',
                    distributed: true,
                    data: {
                        note: note,
                    }
                });

                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.

Add the dependency:

build.gradle
include libs.cache

Then create the module the resolvers call:

src/main/resources/lib/notes.ts
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>>({name: 'notes', 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;
}

Three details are worth pointing out.

The cache is named. That makes it application-wide, shared by every script context, which is the only reason it can be created at module level: an unnamed cache is created once per context, so entries written in one would be missing in another. A named cache also copies values in and out rather than holding them by reference, which is why create and remove write the map back with put instead of relying on having mutated it.

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.

Notes outlive a redeploy. A named cache survives a script reload, and with no expire set its entries live until the application is stopped, uninstalled or reconfigured — so notes you create will still be there after enonic project deploy.

This is still a single-server store. Each node in a cluster keeps its own cache, so requests would return different notes depending on where they landed. An application that needs to run on more than one node, or to survive the application being stopped, should use lib-node or a grid library instead.
Never keep application state in a module-level variable — a plain object or array holding your data. XP gives no guarantee that a module is instantiated once, so such a variable is not reliably shared. A named cache is safe because the variable holds a handle to shared storage rather than the data itself.

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 dictionary so reference can 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.


Contents

Contents