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:
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:
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:
| Part | Description |
|---|---|
t | Object returned by defineContext with procedure builders |
t.mutation() | Higher-order function that returns a procedure |
args | Zod schema for input validation |
handler | Async function (ctx, args) => Result |
| Return value | A 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:
-
Register it in a router:
api.ts export const appRouter = t.router({ users: t.router({ create: createUser, }), }) -
Call it directly (for testing or internal use):
const result = await createUser(ctx, { name: 'John', email: 'john@example.com' })
Key differences from queries:
| Aspect | Query | Mutation |
|---|---|---|
| Purpose | Read data | Write data |
| Return option | keys for caching | invalidate for cache busting |
| HTTP method | GET | POST/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:
| Format | Example | Use 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:
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:
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:
| Exception | Use When |
|---|---|
ValidationException | Input fails validation (bad email, missing fields) |
NotFoundException | Entity doesn't exist |
UnauthorizedException | Not authenticated |
ForbiddenException | Not 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
- Internal Procedures - Server-only operations
- Routers - Organize procedures into namespaces
- Cache Invalidation - More invalidation patterns