Creating Commands
Introduction
Section titled “Introduction”Use a Command when you need to execute an application operation through the Xeno.JS CQRS pipeline.
A Command consists of:
- a class extending
Command; - an
intentidentifying the Command; - the data required by the operation;
- a Handler registered with the same intent;
- optionally, validation and other Command pipeline features.
Before you start
Section titled “Before you start”You need:
- an
AppBuilder; - the CQRS pipeline enabled with
addPipeline(); - a Command class;
- a Handler registered with the Command intent;
- access to the mediator where the Command is executed.
If you want validation, you also need a Zod schema.
Enable CQRS
Section titled “Enable CQRS”addPipeline() registers the CQRS infrastructure used to execute Commands and Queries.
import { AppBuilder } from 'xeno-js'
const app = new AppBuilder() .addPipeline()
await app.build()You can add validation, idempotency, concurrency, and other pipeline features to the same configuration.
Create a Command
Section titled “Create a Command”Extend the Command class and pass the Command intent to super().
import { Command } from '@xeno-js/shared'
export class CreateUserCommand extends Command<User> { constructor( public readonly email: string, public readonly name: string, ) { super('CreateUserCommand') }}The Command base class automatically sets:
command.type === REQUEST_TYPE.COMMANDYou only need to define the data required by your application operation.
The intent is important because Xeno.JS uses it to identify the Command and resolve its Handler.
In this example:
super('CreateUserCommand')means that the Handler must be registered with:
CreateUserCommandas its service token.
Define the Command data
Section titled “Define the Command data”Command properties should contain the input required by the application operation.
For example:
export class CreateUserCommand extends Command<User> { constructor( public readonly email: string, public readonly name: string, ) { super('CreateUserCommand') }}The resulting Command contains:
{ intent: 'CreateUserCommand', type: 'COMMAND', email: 'john@example.com', name: 'John Doe',}You do not need to manually add intent or type to the class.
Add Command validation
Section titled “Add Command validation”If the Command accepts external input, you can validate it before the Handler runs.
First enable validation:
import { AppBuilder } from 'xeno-js'
const app = new AppBuilder() .addPipeline((config) => { config.validation.zod = { schemas: {}, } })
await app.build()Then register the schema using the Command intent as the key.
For example:
import { z } from 'zod'
const createUserSchema = z.object({ intent: z.literal('CreateUserCommand'), type: z.literal('COMMAND'), email: z.string().email(), name: z.string().min(1),})
const app = new AppBuilder() .addPipeline((config) => { config.validation.zod = { schemas: { CreateUserCommand: createUserSchema, }, } })
await app.build()The schema key must match:
super('CreateUserCommand')The validation pipeline uses the Command’s intent to select the schema.
Keep schemas next to the Command
Section titled “Keep schemas next to the Command”For larger applications, keep the Command and its schema together:
src/└── application/ └── users/ ├── create-user.command.ts └── create-user.schema.tsFor example:
import { z } from 'zod'
export const createUserSchema = z.object({ intent: z.literal('CreateUserCommand'), type: z.literal('COMMAND'), email: z.string().email(), name: z.string().min(1),})Then register it during application bootstrap:
import { AppBuilder } from 'xeno-js'
import { createUserSchema } from './application/users/create-user.schema'
const app = new AppBuilder() .addPipeline((config) => { config.validation.zod = { schemas: { CreateUserCommand: createUserSchema, }, } })
await app.build()For more details about validation behavior and custom validation, see Validation.
Create the Command Handler
Section titled “Create the Command Handler”A Command must have a Handler registered with the same intent.
For the previous example:
super('CreateUserCommand')the Handler must be registered as:
CreateUserCommandA Handler extends BaseHandler and implements executeAsync().
import { BaseHandler } from 'xeno-js'import { Result, type ResultType } from '@xeno-js/shared'
import type { CreateUserCommand } from './create-user.command'
export class CreateUserHandler extends BaseHandler< CreateUserCommand, User> { protected async executeAsync( request: CreateUserCommand, ): Promise<ResultType<User>> { const user = { id: crypto.randomUUID(), email: request.email, name: request.name, }
return Result.ok(user) }}See Creating Handlers for the complete Handler configuration and dependency injection patterns.
Register the Handler
Section titled “Register the Handler”Register the Handler in the service container using the Command intent.
import { AppBuilder } from 'xeno-js'
import { CreateUserHandler } from './application/users/create-user.handler'
const app = new AppBuilder() .addServices((container) => { container.addTransient( 'CreateUserCommand', (scope) => { return new CreateUserHandler( scope.resolve('USER_CONTEXT_FACTORY'), ) }, ) }) .addPipeline()
await app.build()The registration token:
'CreateUserCommand'must match the Command intent:
super('CreateUserCommand')If the names do not match, Xeno.JS cannot resolve the Handler when the Command is executed.
Register Handler dependencies
Section titled “Register Handler dependencies”If your Handler requires application services, resolve them from the active service scope.
For example:
export class CreateUserHandler extends BaseHandler< CreateUserCommand, User> { constructor( identityFactory: IFactory<void, UserContext>, private readonly userRepository: UserRepository, ) { super(identityFactory) }
protected async executeAsync( request: CreateUserCommand, ): Promise<ResultType<User>> { const user = await this.userRepository.create({ email: request.email, name: request.name, })
return Result.ok(user) }}Register the dependencies explicitly:
const app = new AppBuilder() .addServices((container) => { container.addScoped( 'USER_REPOSITORY', (scope) => new UserRepository( scope.resolve('DB_CONTEXT'), ), )
container.addTransient( 'CreateUserCommand', (scope) => { return new CreateUserHandler( scope.resolve('USER_CONTEXT_FACTORY'), scope.resolve('USER_REPOSITORY'), ) }, ) }) .addPipeline()
await app.build()Xeno.JS uses explicit dependency registration. The Handler’s dependencies are resolved from the service scope passed to the factory.
For more information, see Dependency Injection.
Execute a Command
Section titled “Execute a Command”Once the Command and Handler are registered, execute the Command through the mediator.
const command = new CreateUserCommand( 'john@example.com', 'John Doe',)
const result = await mediator.send( command, new AbortController().signal,)The mediator executes the Command pipeline and eventually resolves the Handler using:
command.intentFor this Command:
command.intent === 'CreateUserCommand'so the mediator resolves the Handler registered with:
'CreateUserCommand'The returned value is a ResultType<TResponse>.
Handle the result according to your application’s error-handling conventions.
Complete example
Section titled “Complete example”A simple Command implementation can be organized as follows:
src/├── application/│ └── users/│ ├── create-user.command.ts│ ├── create-user.handler.ts│ └── create-user.schema.ts└── bootstrap.tsCommand
Section titled “Command”import { Command } from '@xeno-js/shared'
export interface User { id: string email: string name: string}
export class CreateUserCommand extends Command<User> { constructor( public readonly email: string, public readonly name: string, ) { super('CreateUserCommand') }}Schema
Section titled “Schema”import { z } from 'zod'
export const createUserSchema = z.object({ intent: z.literal('CreateUserCommand'), type: z.literal('COMMAND'), email: z.string().email(), name: z.string().min(1),})Handler
Section titled “Handler”import { BaseHandler } from 'xeno-js'import { Result, type ResultType } from '@xeno-js/shared'
import type { CreateUserCommand, User } from './create-user.command'
export class CreateUserHandler extends BaseHandler< CreateUserCommand, User> { protected async executeAsync( request: CreateUserCommand, ): Promise<ResultType<User>> { const user: User = { id: crypto.randomUUID(), email: request.email, name: request.name, }
return Result.ok(user) }}Application bootstrap
Section titled “Application bootstrap”import { AppBuilder } from 'xeno-js'
import { createUserSchema } from './application/users/create-user.schema'import { CreateUserHandler } from './application/users/create-user.handler'
const app = new AppBuilder() .addServices((container) => { container.addTransient( 'CreateUserCommand', (scope) => { return new CreateUserHandler( scope.resolve('USER_CONTEXT_FACTORY'), ) }, ) }) .addPipeline((config) => { config.validation.zod = { schemas: { CreateUserCommand: createUserSchema, }, } })
const container = await app.build()The important relationship is:
CreateUserCommand │ │ intent = "CreateUserCommand" ▼"CreateUserCommand" service registration │ ▼CreateUserHandlerThe same intent connects the Command to its Handler and to its validation schema.
Add idempotency to a Command
Section titled “Add idempotency to a Command”Idempotency is a Command-specific pipeline feature.
Configure it through commandBus.idempotency:
const app = new AppBuilder() .addPipeline((config) => { config.commandBus.idempotency = { lockTtlSeconds: 300, processedTtlSeconds: 86400, } })
await app.build()The idempotency mechanism uses the request requestId to identify the operation.
See Idempotency for the complete configuration and behavior.
Add concurrency handling to a Command
Section titled “Add concurrency handling to a Command”Concurrency retry handling is also Command-specific.
const app = new AppBuilder() .addPipeline((config) => { config.commandBus.concurrency = { maxRetries: 3, delayConfig: { baseDelayMs: 100, maxJitterMs: 50, }, } })
await app.build()See Concurrency for the complete configuration and retry behavior.
Add other pipeline features
Section titled “Add other pipeline features”Commands can also use the common CQRS pipeline features:
These features are configured through addPipeline().
Troubleshooting
Section titled “Troubleshooting”HANDLER_NOT_FOUND
Section titled “HANDLER_NOT_FOUND”If executing a Command produces a HANDLER_NOT_FOUND error, check that:
- the Command has an intent;
- the Handler is registered in the service container;
- the registration token exactly matches the Command intent;
- the Handler exposes a
handle()method throughBaseHandler; addPipeline()has been called;- the application has been built with
await app.build().
For example:
super('CreateUserCommand')must correspond to:
container.addTransient( 'CreateUserCommand', (scope) => new CreateUserHandler( scope.resolve('USER_CONTEXT_FACTORY'), ),)Validation is not running
Section titled “Validation is not running”Check that:
addPipeline()is configured;config.validation.zodis defined;- the schema is registered under the exact Command intent;
- the Command is executed through the mediator;
- the schema validates the Command request shape.
For example:
config.validation.zod = { schemas: { CreateUserCommand: createUserSchema, },}must match:
super('CreateUserCommand')The Handler is not receiving the expected data
Section titled “The Handler is not receiving the expected data”The Command class defines the data passed to the Handler:
export class CreateUserCommand extends Command<User> { constructor( public readonly email: string, public readonly name: string, ) { super('CreateUserCommand') }}The Handler receives the same Command instance:
protected async executeAsync( request: CreateUserCommand,) { request.email request.name}If validation is enabled, remember that the schema validates the request object before the Handler executes.
Related documentation
Section titled “Related documentation”- CQRS Overview
- Creating Queries
- Creating Handlers
- Validation
- Exception Handling
- Logging
- Performance Tracking
- Idempotency
- Concurrency
- Dependency Injection
Support Us
Section titled “Support Us”Xeno.JS is an MIT-licensed open source project. It can grow thanks to the support of these awesome people. If you’d like to join them, please read more at support section
