DeesseJS RPC

afterInvoke

Run logic after your handler completes, regardless of success or failure

Overview

The afterInvoke hook runs after your handler completes, whether it succeeds or fails. Unlike onSuccess which only fires on success, or onError which only fires on error, afterInvoke fires in all cases. This makes it ideal for cleanup operations, final logging, metrics collection, and any behavior that should happen regardless of the outcome.

The hook receives the context, the original arguments, and the full result from the handler. The result is a Result<Output> type, so you can inspect whether it succeeded or failed.

When to Use afterInvoke

You will find afterInvoke useful when you need to perform operations that must happen no matter what. Common use cases include releasing resources that were allocated in beforeInvoke, recording final metrics regardless of success or failure, logging completion status with final outcome information, and performing cleanup that must happen even when errors occur.

Unlike onSuccess and onError which are specific to their respective outcomes, afterInvoke gives you a single place to handle concerns that apply to both success and failure paths.

Basic Example

The following example shows how to attach an afterInvoke hook to a query procedure. The hook logs a message after the handler completes, regardless of whether it succeeded.

import { defineContext } from '@deessejs/server'
import { z } from 'zod'
import { ok, err } 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.find(args.id)
    if (!user) {
      return err(new Error('User not found'))
    }
    return ok(user)
  }
})

const getUserWithCompletionLogging = getUser.afterInvoke((ctx, args, result) => {
  if (result.ok) {
    ctx.logger.info(`getUser succeeded: fetched user ${args.id}`)
  } else {
    ctx.logger.warn(`getUser failed: ${result.error.message} for id ${args.id}`)
  }
})

The hook receives the full Result<Output> type, allowing you to differentiate between success and failure within the same hook.

Cleanup Example

When you allocate resources in beforeInvoke or within the handler, afterInvoke is the right place to release them. This ensures cleanup happens even when the handler throws an exception.

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

const { t, createAPI } = defineContext({
  context: () => ({ tempDir: tempDirectory }),
})

const processData = t.query({
  args: z.object({ fileId: z.string() }),
  handler: async (ctx, args) => {
    // Allocate a temp file for processing
    const tempFile = ctx.tempDir.create()
    ctx.currentTempFile = tempFile

    const result = await processFile(tempFile, args.fileId)
    return ok(result)
  }
})

const processDataWithCleanup = processData.afterInvoke((ctx, args, result) => {
  // Always clean up the temp file, whether succeeded or failed
  if (ctx.currentTempFile) {
    ctx.tempDir.delete(ctx.currentTempFile.id)
    ctx.currentTempFile = null
  }
})

By centralizing cleanup in afterInvoke, you avoid repeating cleanup logic in every error path within the handler.

Metrics Collection Example

When you need to record metrics for both successes and failures, afterInvoke gives you a single hook to handle both paths.

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

const { t, createAPI } = defineContext({
  context: () => ({ metrics: metricsClient }),
})

const submitOrder = t.mutation({
  args: z.object({ items: z.array(z.object({ id: z.number(), quantity: z.number() })) }),
  handler: async (ctx, args) => {
    const result = await ctx.payment.process(args.items)
    return ok(result)
  }
})

const submitOrderWithMetrics = submitOrder.afterInvoke((ctx, args, result) => {
  // Record the duration (could be tracked in beforeInvoke too)
  const duration = Date.now() - ctx.startTime

  // Record metrics regardless of outcome
  ctx.metrics.record('order.submit.duration', duration)

  if (result.ok) {
    ctx.metrics.increment('order.submit.success')
  } else {
    ctx.metrics.increment('order.submit.failure')
    ctx.metrics.record('order.submit.error', { message: result.error.message })
  }
})

This pattern ensures you always get metrics data, even when errors occur, without needing separate hooks for success and failure.

Difference from onSuccess and onError

The afterInvoke hook is different from onSuccess and onError in when it fires and what it receives:

HookWhen it runsArguments
beforeInvokeBefore the handler(ctx, args)
afterInvokeAfter handler (always)(ctx, args, result: Result<Output>)
onSuccessAfter successful handler(ctx, args, data: Output)
onErrorAfter failed handler(ctx, args, error)

Use afterInvoke when you need behavior in both success and failure paths. Use onSuccess or onError when you need behavior only in one specific path.

const operation = t.query({ ... })

// Runs on both success and failure
operation.afterInvoke((ctx, args, result) => {
  // result.ok === true means success, result.ok === false means error
  ctx.logger.info(`Operation finished: ${result.ok ? 'success' : 'failure'}`)
})

// Runs only on success
operation.onSuccess((ctx, args, data) => {
  // Only runs when handler returns ok
  ctx.logger.info(`Operation succeeded with data: ${data}`)
})

// Runs only on failure
operation.onError((ctx, args, error) => {
  // Only runs when handler returns err or throws
  ctx.logger.error(`Operation failed: ${error.message}`)
})

Serial Execution with Other Hooks

All lifecycle hooks execute in a predictable order. beforeInvoke runs first, then the handler, then onSuccess or onError, and finally afterInvoke runs last.

const operation = t.query({ ... })
  .beforeInvoke((ctx, args) => {
    console.log('1. beforeInvoke')
  })
  .onSuccess((ctx, args, data) => {
    console.log('2. onSuccess')
  })
  .onError((ctx, args, error) => {
    console.log('2. onError')
  })
  .afterInvoke((ctx, args, result) => {
    console.log('3. afterInvoke (always last)')
  })

The execution flow on success: beforeInvoke -> handler -> onSuccess -> afterInvoke

The execution flow on failure: beforeInvoke -> handler -> onError -> afterInvoke

Anti-Patterns

The following patterns show what you should avoid with afterInvoke.

Do not use afterInvoke when onSuccess or onError would be more appropriate. If you only need behavior on one specific path, use the specific hook. Reserve afterInvoke for behavior that genuinely needs to run on both paths.

// Anti-pattern: Checking result.ok in afterInvoke when you could use onSuccess/onError
const getUser = t.query({
  args: z.object({ id: z.number() }),
  handler: async (ctx, args) => { /* ... */ }
}).afterInvoke((ctx, args, result) => {
  if (result.ok) {
    // This should be in onSuccess instead
    ctx.logger.info('User fetched successfully')
  }
})

Do not modify the result in afterInvoke. By the time afterInvoke runs, the result has already been sent to the caller. Modifying it does not affect what the caller receives.

Do not throw errors in afterInvoke. If a hook throws, the error propagates to the caller and may cause confusing behavior. If you need to handle errors, log them instead of throwing.

Best Practices

Keep afterInvoke focused on behavior that must happen regardless of outcome. Cleanup, final logging, and metrics are good candidates. Business logic that should only run on success belongs in onSuccess.

const processPayment = t.mutation({
  args: z.object({ orderId: z.number(), amount: z.number() }),
  handler: async (ctx, args) => {
    const result = await ctx.payment.charge(args.amount)
    return ok(result)
  }
})
  .afterInvoke((ctx, args, result) => {
    // Always release the payment session resource
    ctx.paymentSession.release()
  })
  .afterInvoke((ctx, args, result) => {
    // Always record the final duration metric
    ctx.metrics.record('payment.duration', Date.now() - ctx.startTime)
  })

Design hooks to be independent of each other. Each hook should handle a single concern so that adding or removing hooks does not affect the behavior of others. This makes your code easier to test and maintain.

When you need different behavior for success and failure within afterInvoke, prefer using separate onSuccess and onError hooks instead. Only use conditional logic inside afterInvoke when the behavior genuinely must share the same hook.

On this page