DeesseJS RPC

Mutations

Modify data in your API. Mutations are exposed as HTTP POST/PATCH/DELETE endpoints with automatic cache invalidation.

Mutations are public write operations exposed via HTTP. You use them to modify data. When data changes, you must invalidate related cache keys so clients see fresh data on their next query.

Defining a Mutation

When you call defineContext, it returns an object containing t. This t object provides procedure builders:

api.ts
import { defineContext } from '@deessejs/server'

const { t, createAPI } = defineContext({
  context: () => ({ db: myDatabase }),
})

Mutation Builder

To define a mutation, you use the t.mutation() function. It takes a definition object with args and handler, and returns a procedure you can call or register in a router:

api.ts
import { defineContext } from '@deessejs/server'
import { z } from 'zod'
import { ok, err } from '@deessejs/fp'
import { ValidationException } from '@deessejs/server'

const { t, createAPI } = defineContext({
  context: () => ({ db: myDatabase }),
})

const createUser = t.mutation({
  args: z.object({
    name: z.string().min(1),
    email: z.string().email(),
  }),
  handler: async (ctx, args) => {
    const existing = await ctx.db.users.findByEmail(args.email)

    if (existing) {
      return err(new ValidationException('Email already exists'))
    }

    const user = await ctx.db.users.create(args)

    return ok(user, {
      invalidate: [
        'users:list',
        ['users', { id: user.id }],
      ],
    })
  },
})

How it works:

PartDescription
tObject returned by defineContext with procedure builders
t.mutation()Higher-order function that returns a procedure
argsZod schema for input validation
handlerAsync function (ctx, args) => Result
Return valueA callable procedure you can add to a router

What you get back:

The t.mutation() function returns a procedure - a function with metadata attached. You can:

  1. Register it in a router:

    api.ts
    export const appRouter = t.router({
      users: t.router({
        create: createUser,
      }),
    })
  2. Call it directly (for testing or internal use):

    const result = await createUser(ctx, { name: 'John', email: 'john@example.com' })

Key differences from queries:

AspectQueryMutation
PurposeRead dataWrite data
Return optionkeys for cachinginvalidate for cache busting
HTTP methodGETPOST/PATCH/DELETE

Mutation Properties

Prop

Type

Return Value

To signal a successful mutation and invalidate related cache entries, you use ok() with the invalidate option. This tells the cache system which entries to clear:

return ok(result, { invalidate: [['users', { id: args.id }]] })

The invalidate option tells the cache system which entries to clear:

FormatExampleUse Case
['entity:list']['users:list']Invalidate a list cache
['entity', { filter }]['users', { id: 1 }]Invalidate specific entity
['entity:*']['users:*']Wildcard - invalidate all entity caches

Cache Invalidation

When a mutation succeeds, you need to clear related cache entries so clients see fresh data. You do this by returning ok() with the invalidate option listing the cache keys to clear:

api.ts
const createUser = t.mutation({
  args: z.object({ name: z.string(), email: z.string().email() }),
  handler: async (ctx, args) => {
    const user = await ctx.db.users.create(args)

    // Clear the users list and the new user cache
    return ok(user, {
      invalidate: [
        'users:list',
        ['users', { id: user.id }],
      ],
    })
  },
})

When to Invalidate

To know which keys to invalidate, consider what data your mutation changes. The table below shows the recommended keys based on the action type:

// Create
return ok(user, {
  invalidate: ['users:list', ['users', { id: user.id }]]
})

// Update
return ok(user, {
  invalidate: [['users', { id: args.id }]]
})

// Delete
return ok({ success: true }, {
  invalidate: [['users', { id: args.id }], 'users:list']
})

Complete Example

The following example shows a full mutations implementation with createUser, updateUser, and deleteUser. You can see how each mutation returns the appropriate cache keys to invalidate:

api.ts
import { defineContext } from '@deessejs/server'
import { z } from 'zod'
import { ok, err } from '@deessejs/fp'
import { ValidationException, NotFoundException } from '@deessejs/server'

const { t, createAPI } = defineContext({
  context: () => ({ db: myDatabase }),
})

const createUser = t.mutation({
  args: z.object({
    name: z.string().min(1).max(100),
    email: z.string().email(),
  }),
  handler: async (ctx, args) => {
    const existing = await ctx.db.users.findByEmail(args.email)

    if (existing) {
      return err(new ValidationException('Email already exists'))
    }

    const user = await ctx.db.users.create(args)

    return ok(user, {
      invalidate: [
        'users:list',
        ['users', { id: user.id }],
      ],
    })
  },
})

const updateUser = t.mutation({
  args: z.object({
    id: z.number(),
    name: z.string().min(1).max(100).optional(),
    email: z.string().email().optional(),
  }),
  handler: async (ctx, args) => {
    const user = await ctx.db.users.find(args.id)

    if (!user) {
      return err(new NotFoundException(`User ${args.id} not found`))
    }

    if (args.email) {
      const existing = await ctx.db.users.findByEmail(args.email)
      if (existing && existing.id !== args.id) {
        return err(new ValidationException('Email already taken'))
      }
    }

    const updated = await ctx.db.users.update(args.id, args)

    return ok(updated, {
      invalidate: [
        ['users', { id: args.id }],
        'users:list',
      ],
    })
  },
})

const deleteUser = t.mutation({
  args: z.object({ id: z.number() }),
  handler: async (ctx, args) => {
    const user = await ctx.db.users.find(args.id)

    if (!user) {
      return err(new NotFoundException(`User ${args.id} not found`))
    }

    await ctx.db.users.delete(args.id)

    return ok({ success: true }, {
      invalidate: [
        ['users', { id: args.id }],
        'users:list',
      ],
    })
  },
})

export const appRouter = t.router({
  users: t.router({
    create: createUser,
    update: updateUser,
    delete: deleteUser,
  }),
})

Error Handling

When a mutation fails, you should return an error using err(). This allows clients to handle failures gracefully and receive meaningful error messages:

handler: async (ctx, args) => {
  if (!args.email.includes('@')) {
    return err(new ValidationException('Invalid email'))
  }

  const user = await ctx.db.users.create(args)
  return ok(user, { invalidate: ['users:list'] })
}

Always validate input and return meaningful errors:

ExceptionUse When
ValidationExceptionInput fails validation (bad email, missing fields)
NotFoundExceptionEntity doesn't exist
UnauthorizedExceptionNot authenticated
ForbiddenExceptionNot allowed to perform action

Anti-Patterns

Don't Use Mutations for Reads

Mutations are for write operations, not reads. Using mutations for reads defeats the purpose of having separate query and mutation procedures:

// Don't - mutations should not be used for reads
const getUser = t.mutation({
  args: z.object({ id: z.number() }),
  handler: async (ctx, args) => {
    return ok(await ctx.db.users.find(args.id))
  },
})

// Do - queries are for reads
const getUser = t.query({
  args: z.object({ id: z.number() }),
  handler: async (ctx, args) => {
    return ok(await ctx.db.users.find(args.id), { keys: [['users', { id: args.id }]] })
  },
})

Don't Forget to Invalidate

After a mutation succeeds, you must invalidate the related cache keys. Otherwise, clients will continue to see stale cached data:

// Don't - cache becomes stale
handler: async (ctx, args) => {
  await ctx.db.users.update(args.id, args)
  return ok({ success: true })
}

// Do - invalidate related cache
handler: async (ctx, args) => {
  await ctx.db.users.update(args.id, args)
  return ok({ success: true }, { invalidate: [['users', { id: args.id }]] })
}

Don't Invalidate Too Much

When invalidating cache keys, be specific about what you clear. Invalidating too aggressively defeats the purpose of caching and can cause unnecessary performance issues:

// Don't - too aggressive, defeats caching purpose
return ok(user, { invalidate: ['users:*', 'posts:*', 'comments:*'] })

// Do - invalidate only what changed
return ok(user, { invalidate: [['users', { id: user.id }]] })

Best Practices

1. Always Return Errors Explicitly

When validation fails or an error occurs, use err() to return the error explicitly. This gives clients clear feedback about what went wrong:

handler: async (ctx, args) => {
  const existing = await ctx.db.users.findByEmail(args.email)

  if (existing) {
    return err(new ValidationException('Email already exists'))
  }

  // Continue with creation...
}

2. Invalidate Specific Keys

Only invalidate cache keys that could be affected by your mutation. Invalidate too much and you lose the benefits of caching:

// Creating a user affects the list and the new entity
return ok(user, {
  invalidate: ['users:list', ['users', { id: user.id }]]
})

// Updating a user's name doesn't affect the list ordering
return ok(user, {
  invalidate: [['users', { id: user.id }]]
})

3. Validate Before Database Operations

Catch invalid input early by validating before performing database operations. This prevents unnecessary database load and gives faster error responses:

handler: async (ctx, args) => {
  if (args.name.length < 1 || args.name.length > 100) {
    return err(new ValidationException('Name must be 1-100 characters'))
  }

  // Database operation...
}

Next Steps

On this page