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(),
})
| Schema | Method | Validated when |
|---|---|---|
addPayloadSchema(schema) | Main input body | After input transform |
addParameterSchema(schema) | Query/path params, metadata | After input transform |
addOutputSchema(schema) | Return shape | After 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.
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')
},
})
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 }
})
| Method | Purpose |
|---|---|
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 }
})