Skip to content

Creating Commands

Use a Command when you need to execute an application operation through the Xeno.JS CQRS pipeline.

A Command consists of:

  1. a class extending Command;
  2. an intent identifying the Command;
  3. the data required by the operation;
  4. a Handler registered with the same intent;
  5. optionally, validation and other Command pipeline features.

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.

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.


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.COMMAND

You 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:

CreateUserCommand

as its service token.

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.


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.

For larger applications, keep the Command and its schema together:

src/
└── application/
└── users/
├── create-user.command.ts
└── create-user.schema.ts

For example:

create-user.schema.ts
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.


A Command must have a Handler registered with the same intent.

For the previous example:

super('CreateUserCommand')

the Handler must be registered as:

CreateUserCommand

A 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 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.

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.


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.intent

For 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.


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.ts
application/users/create-user.command.ts
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')
}
}
application/users/create-user.schema.ts
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),
})
application/users/create-user.handler.ts
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)
}
}
bootstrap.ts
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
│
▼
CreateUserHandler

The same intent connects the Command to its Handler and to its validation schema.


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.


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.


Commands can also use the common CQRS pipeline features:

These features are configured through addPipeline().


If executing a Command produces a HANDLER_NOT_FOUND error, check that:

  1. the Command has an intent;
  2. the Handler is registered in the service container;
  3. the registration token exactly matches the Command intent;
  4. the Handler exposes a handle() method through BaseHandler;
  5. addPipeline() has been called;
  6. 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'),
),
)

Check that:

  1. addPipeline() is configured;
  2. config.validation.zod is defined;
  3. the schema is registered under the exact Command intent;
  4. the Command is executed through the mediator;
  5. 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.



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