DeesseJS RPC

onSuccess

React to successful handler execution

Overview

The onSuccess hook executes after your handler completes successfully. It receives the context, the arguments that were passed to the handler, and the result that the handler returned. This gives you full access to the outcome of the procedure, allowing you to react to successful executions without modifying the response.

The hook runs only when the handler returns an ok result. If the handler returns an err or throws an exception, onSuccess does not fire. Use onError for those cases instead.

When to Use onSuccess

You will use onSuccess when you need to perform operations that depend on the handler's output. Common scenarios include logging successful completions with response data, invalidating caches after data changes, recording metrics for successful operations, and triggering side effects that should happen only when work completes successfully.

Unlike beforeInvoke which is about preparing for execution, onSuccess is about reacting to outcomes. This distinction matters because it determines which hook you should use for a given concern.

Basic Example

The following example shows how to attach an onSuccess hook to a query. The hook logs the result after the handler returns successfully.

import { defineContext } from '@deessejs/server'
import { z } from 'zod'
import { ok } from '@deessejs/fp'

const { t, createAPI } = defineContext({
  context: () => ({ logger: console }),
})

const getUser = t.query({
  args: z.object({ id: z.number() }),
  handler: async (ctx, args) => {
    const user = await ctx.db.users.findUnique({ where: { id: args.id } })
    return ok(user)
  }
})

const getUserWithLogging = getUser.onSuccess((ctx, args, result) => {
  ctx.logger.info(`getUser completed: user ${args.id} fetched`)
})

The hook receives three parameters: the context, the original arguments, and the result value. For ok results, the result is the value inside the ok wrapper.

Audit Logging Example

Audit logging is a common use case for onSuccess. You can record who performed what action and what the outcome was, creating an immutable record for compliance or troubleshooting.

import { defineContext } from '@deessejs/server'
import { z } from 'zod'
import { ok } from '@deessejs/fp'

const { t, createAPI } = defineContext({
  context: () => ({ auditLog: auditService }),
})

const updateEmail = t.mutation({
  args: z.object({
    userId: z.number(),
    newEmail: z.string().email(),
  }),
  handler: async (ctx, args) => {
    const updated = await ctx.db.users.update({
      where: { id: args.userId },
      data: { email: args.newEmail },
    })
    return ok(updated)
  }
})

const updateEmailWithAudit = updateEmail.onSuccess(async (ctx, args, result) => {
  await ctx.auditLog.record({
    userId: ctx.user.id,
    action: 'email_updated',
    targetUserId: args.userId,
    previousEmail: result.previousEmail,
    newEmail: args.newEmail,
    timestamp: new Date(),
  })
})

This pattern captures the outcome of the mutation and persists it to an audit trail. Because onSuccess only runs on successful handlers, you know the audit record reflects a completed action.

Cache Invalidation Example

After mutating data, you often need to invalidate cached entries so future queries return fresh data. You can do this in the handler itself, or you can centralize cache invalidation in an onSuccess hook.

import { defineContext } from '@deessejs/server'
import { z } from 'zod'
import { ok } from '@deessejs/fp'

const { t, createAPI } = defineContext({
  context: () => ({ cache: redisClient }),
})

const createPost = t.mutation({
  args: z.object({
    title: z.string(),
    content: z.string(),
  }),
  handler: async (ctx, args) => {
    const post = await ctx.db.posts.create({ data: args })
    return ok(post)
  }
})

const createPostWithCacheInvalidation = createPost.onSuccess((ctx, args, result) => {
  // Invalidate the list cache since a new post exists
  ctx.cache.del('posts:list')
  // Invalidate the user's post cache
  ctx.cache.del(`user:${ctx.user.id}:posts`)
})

By keeping cache invalidation in a hook rather than the handler, you keep the handler focused on business logic and make cache management explicit.

Multiple Hooks Chaining

You can chain multiple onSuccess hooks to a single procedure. Each hook runs in order, and all hooks run after a successful handler execution.

const deletePost = t.mutation({
  args: z.object({ id: z.number() }),
  handler: async (ctx, args) => {
    const deleted = await ctx.db.posts.delete({ where: { id: args.id } })
    return ok(deleted)
  }
})

const deletePostWithHooks = deletePost
  .onSuccess((ctx, args, result) => {
    ctx.logger.info(`Post ${args.id} deleted by user ${ctx.user.id}`)
  })
  .onSuccess(async (ctx, args, result) => {
    // Notify external systems
    await ctx.notifier.notifyPostDeleted(args.id)
  })
  .onSuccess((ctx, args, result) => {
    // Invalidate caches
    ctx.cache.del(`post:${args.id}`)
  })

Chaining keeps each hook small and focused, making them easier to test and reason about. You can add or remove hooks without modifying the handler or other hooks.

Anti-Patterns

The following patterns show what you should avoid with onSuccess.

Do not use onSuccess for error handling. If you need to react to errors, use the onError hook. Using onSuccess for error scenarios leads to awkward conditional logic inside the hook and misses the point of the separation.

// Anti-pattern: Error handling in onSuccess
const riskyOperation = t.query({
  args: z.object({}),
  handler: async (ctx, args) => {
    if (somethingFailed) {
      return err(new Error('Failed'))
    }
    return ok(result)
  }
}).onSuccess((ctx, args, result) => {
  // This never runs when somethingFailed is true
  // But you might be tempted to check result.ok here
})

Do not throw errors in onSuccess. If a hook throws, it will cause an error in the caller. If you need to signal failure from a hook, use the onError hook instead.

Do not modify the return value in onSuccess. The result has already been sent to the caller by the time the hook runs. Modifying it does not affect what the caller receives.

Best Practices

Design onSuccess hooks to be idempotent when possible. Because hooks can run multiple times in some configurations, a hook that performs an action like sending a notification should handle the case where the action has already been performed.

const processPayment = t.mutation({
  args: z.object({ orderId: z.number() }),
  handler: async (ctx, args) => {
    // Payment processing
    return ok(paymentResult)
  }
})

const processPaymentWithNotifications = processPayment.onSuccess(async (ctx, args, result) => {
  // Check if already notified to avoid duplicate notifications
  const alreadyNotified = await ctx.db.notifications.exists({
    orderId: args.orderId,
    type: 'payment_success',
  })
  if (!alreadyNotified) {
    await ctx.notifier.sendPaymentSuccess(args.orderId)
  }
})

Keep hooks focused on side effects that should happen after successful operations. For business logic that affects the outcome, keep it in the handler where it belongs.

On this page