Core Building Blocks / Command

The Command Builder

Define schemas, transforms, guards, events, and the business function with full type inference.

The command builder is a fluent API that collects every aspect of a command — schemas, transforms, guards, events, and the business function — into a single typed definition. That definition is attached to a service builder and registered at the event bridge when the service starts.

Every method on the builder returns a new builder instance with updated type parameters. This means TypeScript tracks the shape of your command as you build it, and the final setCommandFunction receives a fully typed context, payload, and parameter.

Schemas are the contract

Schemas drive everything in PURISTA: TypeScript inference, runtime validation, OpenAPI documentation, and cross-service type safety. Define them early and be explicit.

import { z } from 'zod'

// Input contract
export const signUpInputPayloadSchema = z.object({
  email: z.string().email(),
  password: z.string().min(8),
})

export const signUpInputParameterSchema = z.object({
  referralCode: z.string().optional(),
})

// Output contract
export const signUpOutputSchema = z.object({
  userId: z.string(),
})
SchemaMethodValidated when
addPayloadSchema(schema)Main input bodyAfter input transform
addParameterSchema(schema)Query/path params, metadataAfter input transform
addOutputSchema(schema)Return shapeAfter command function

The command function

export const signUpCommandBuilder = userServiceV1ServiceBuilder
  .getCommandBuilder('signUp', 'Register a new user')
  .addPayloadSchema(signUpInputPayloadSchema)
  .addParameterSchema(signUpInputParameterSchema)
  .addOutputSchema(signUpOutputSchema)
  .setCommandFunction(async function (context, payload, parameter) {
    // payload: { email: string; password: string }
    // parameter: { referralCode?: string }
    const user = await context.resources.db.insert('users', payload)
    return { userId: user.id }
  })

The function signature is (context, payload, parameter) => Promise<output>. The builder validates at compile time that your function’s arguments and return type match the declared schemas.

Always use async function

The builder calls assertNonArrowFunction(fn, ‘setCommandFunction’) at runtime and will throw if you pass an arrow function. This is because this must bind to the service instance for access to this.config, this.resources, and this.stores.

Input and output transforms

Transforms are optional hooks that convert data before validation or after the command function runs. They are useful for decryption, field mapping, or format conversion.

.setTransformInput(
  z.object({ rawEmail: z.string(), rawPassword: z.string() }),
  z.object({}),
  async function (context, rawPayload, rawParameter) {
    // Decrypt or remap fields before validation
    return {
      payload: { email: rawPayload.rawEmail.toLowerCase(), password: rawPayload.rawPassword },
      parameter: rawParameter,
    }
  },
)
.setTransformOutput(
  z.object({ id: z.string(), createdAt: z.date() }),
  async function (context, output) {
    // Convert internal shape to external shape
    return { userId: output.id, registeredAt: output.createdAt.toISOString() }
  },
)

Before and after guards

Guards are the right place for auth, authorization, quota checks, and audit logging. They keep your command function focused on business logic.

.setBeforeGuardHooks({
  requireValidEmail: async function (context, payload, parameter) {
    const domain = payload.email.split('@')[1]
    if (domain === 'tempmail.com') {
      throw new HandledError(StatusCode.BadRequest, 'Disposable email addresses are not allowed')
    }
  },
  rateLimit: async function (context, payload, parameter) {
    const { count } = await context.states.getState(`rate:${payload.email}`)
    if (count && count > 5) {
      throw new HandledError(StatusCode.TooManyRequests, 'Rate limit exceeded')
    }
  },
})
.setAfterGuardHooks({
  audit: async function (context, result, payload, parameter) {
    context.logger.info({ userId: result.userId, email: payload.email }, 'User signed up')
  },
})
Guards run in parallel — and must be regular functions

All before guards execute simultaneously with Promise.all. If any guard throws, the command function is skipped and the error is returned to the caller. After guards also run in parallel after the command function succeeds. Guards must be declared as async function, not arrow functions — the builder calls assertNonArrowFunction on each one at registration time and will throw if an arrow function is passed.

Emitting custom events

Commands can emit events that subscriptions react to. This is how you build event-driven flows:

.setCommandFunction(async function (context, payload, parameter) {
  const user = await context.resources.db.insert('users', payload)

  // Emit an event that subscriptions can listen for
  await context.emit('userSignedUp', { userId: user.id, email: payload.email })

  return { userId: user.id }
})

To emit events, declare them on the builder so TypeScript knows about them:

.canEmit('userSignedUp', z.object({ userId: z.string(), email: z.string() }))

Cross-service invocation

Commands can invoke other commands across services. The caller does not know where the target service runs — only its name, version, and command name. The event bridge routes the request and returns the response transparently.

.canInvoke('ProfileService', '1', 'createProfile', {
  outputSchema: z.object({ profileId: z.string() }),
  payloadSchema: z.object({ userId: z.string() }),
})
.setCommandFunction(async function (context, payload, parameter) {
  const user = await context.resources.db.insert('users', payload)

  // Invoke a command in another service
  const profile = await context.invoke(
    { serviceName: 'ProfileService', serviceVersion: '1', serviceTarget: 'createProfile' },
    { userId: user.id },
  )

  return { userId: user.id, profileId: profile.profileId }
})
MethodPurpose
canInvoke(service, version, command, config?)Declare that this command can call another command
canConsumeStream(service, version, stream, ...)Declare that this command can consume a stream
canEnqueue(queueName, payloadSchema?, parameterSchema?)Declare that this command can enqueue jobs

HTTP exposure

Commands are not HTTP endpoints by default. Expose them explicitly using the Hono-based HTTP server:

.exposeAsHttpEndpoint('POST', 'api/v1/users', 'application/json', undefined, 'application/json')

You can also control OpenAPI metadata:

.setOpenApiSummary('Register a new user')
.setOpenApiOperationId('signUp')
.addOpenApiTags('Authentication')
.addOpenApiErrorStatusCodes(StatusCode.Conflict, StatusCode.TooManyRequests)
.makeEndpointPublic() // disables auth for this endpoint

For async patterns (return 202 Accepted while work continues in a queue):

.exposeAsHttpEndpoint('POST', 'api/v1/users/async', undefined, undefined, undefined, undefined, { mode: 'async' })

Building the definition

The builder’s getDefinition() method is async. It converts schemas to JSON Schema for OpenAPI and returns a CommandDefinition that you attach to the service builder:

userServiceV1ServiceBuilder.addCommandDefinition(await signUpCommandBuilder.getDefinition())

Full example

import { z } from 'zod'
import { StatusCode, HandledError } from '@purista/core'
import { userServiceV1ServiceBuilder } from '../userService.js'

const signUpInputPayloadSchema = z.object({
  email: z.string().email(),
  password: z.string().min(8),
})

const signUpInputParameterSchema = z.object({
  referralCode: z.string().optional(),
})

const signUpOutputSchema = z.object({
  userId: z.string(),
})

export const signUpCommandBuilder = userServiceV1ServiceBuilder
  .getCommandBuilder('signUp', 'Register a new user')
  .addPayloadSchema(signUpInputPayloadSchema)
  .addParameterSchema(signUpInputParameterSchema)
  .addOutputSchema(signUpOutputSchema)
  .canEmit('userSignedUp', z.object({ userId: z.string(), email: z.string() }))
  .canInvoke('ProfileService', '1', 'createProfile', {
    outputSchema: z.object({ profileId: z.string() }),
    payloadSchema: z.object({ userId: z.string() }),
  })
  .setBeforeGuardHooks({
    blockDisposable: async function (_context, payload, _parameter) {
      if (payload.email.endsWith('@tempmail.com')) {
        throw new HandledError(StatusCode.BadRequest, 'Disposable emails not allowed')
      }
    },
  })
  .setCommandFunction(async function (context, payload, parameter) {
    const user = await context.resources.db.insert('users', payload)
    await context.emit('userSignedUp', { userId: user.id, email: payload.email })

    const profile = await context.invoke(
      { serviceName: 'ProfileService', serviceVersion: '1', serviceTarget: 'createProfile' },
      { userId: user.id },
    )

    return { userId: user.id }
  })