Creating and Registering Handlers
Introduction
Section titled “Introduction”A handler contains the application logic that executes a Command or Query.
In Xeno.JS, create a handler by extending BaseHandler, implement executeAsync(), and register the handler in the application service container.
The handler is associated with a Command or Query through its intent.
Before you start
Section titled “Before you start”You need:
- a Xeno.JS
AppBuilder; - a Command or Query;
- a handler extending
BaseHandler; - the CQRS pipeline enabled with
addPipeline(); - a handler registration whose token matches the request
intent.
For example:
const app = new AppBuilder() .addPipeline()AppBuilder also creates the application context during initialization, so BaseHandler can access the current user context when needed.
Create a Handler
Section titled “Create a Handler”Extend BaseHandler with:
- the request type;
- the response type.
Then implement executeAsync().
For example, for a CreateUserCommand:
import { Result, type ResultType } from '@xeno-js/shared'import { BaseHandler } from '@xeno-js/core'
import type { CreateUserCommand } from './create-user.command'
interface User { id: string email: string name: string}
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) }}executeAsync() receives the Command or Query that was sent through the mediator.
Return a ResultType<TResponse> using the Xeno.JS Result API.
Implement executeAsync()
Section titled “Implement executeAsync()”BaseHandler requires subclasses to implement:
protected abstract executeAsync( request: TRequest, signal?: AbortSignal,): Promise<ResultType<TResponse>>The request is the Command or Query being processed.
The response type is defined by the generic parameters passed to BaseHandler.
For example:
export class FindUserHandler extends BaseHandler< FindUserQuery, User> { protected async executeAsync( request: FindUserQuery, ): Promise<ResultType<User>> { // Execute the application use case. }}Cancellation
Section titled “Cancellation”The public handle() method receives an AbortSignal and checks whether the operation has already been aborted before executing the handler.
The current BaseHandler implementation pass that signal from handle() into executeAsync().
So, the signal parameter declared by executeAsync() is automatically populated by BaseHandler.
Inject Dependencies
Section titled “Inject Dependencies”Handlers can receive application dependencies through their constructor.
For example:
import type { ResultType } from '@xeno-js/shared'import { BaseHandler } from '@xeno-js/core'
import type { CreateUserCommand } from './create-user.command'
export interface UserRepository { create(email: string, name: string): Promise<User>}
interface User { id: string email: string name: string}
export class CreateUserHandler extends BaseHandler< CreateUserCommand, User> { constructor( private readonly userRepository: UserRepository, userContextFactory: UserContextFactory, ) { super(userContextFactory) }
protected async executeAsync( request: CreateUserCommand, ): Promise<ResultType<User>> { const user = await this.userRepository.create( request.email, request.name, )
return Result.ok(user) }}The dependencies are supplied when the handler is registered with the service container.
This keeps dependency construction outside the handler itself.
Register a Handler
Section titled “Register a Handler”Register handlers through AppBuilder.addServices().
The registration token must match the request intent.
For example, if the command uses:
super('CreateUserCommand')register the handler as:
container.addTransient( 'CreateUserCommand', (scope) => { return new CreateUserHandler( scope.resolve('USER_REPOSITORY'), scope.resolve('USER_CONTEXT_FACTORY'), ) },)The important relationship is:
CreateUserCommand │ │ intent = "CreateUserCommand" ▼"CreateUserCommand" │ ▼CreateUserHandlerThe intent and registration token must match exactly.
Register a Handler with AppBuilder
Section titled “Register a Handler with AppBuilder”A complete application configuration can look like this:
import { AppBuilder } from '@xeno-js/core'
const app = new AppBuilder() .addServices((container) => { container.addTransient( 'CreateUserCommand', (scope) => { return new CreateUserHandler( scope.resolve('USER_REPOSITORY'), scope.resolve('USER_CONTEXT_FACTORY'), ) }, ) }) .addPipeline()
await app.build()addServices() gives you access to the service container so you can register the handler and its dependencies.
addPipeline() enables the CQRS execution pipeline required to process Commands and Queries through the mediator.
Register Handler Dependencies
Section titled “Register Handler Dependencies”Register dependencies before resolving them from the handler factory.
For example:
const app = new AppBuilder() .addServices((container) => { container.addScoped( 'USER_REPOSITORY', (scope) => { return new UserRepository( scope.resolve('USER_DATA_SOURCE'), ) }, )
container.addTransient( 'CreateUserCommand', (scope) => { return new CreateUserHandler( scope.resolve('USER_REPOSITORY'), scope.resolve('USER_CONTEXT_FACTORY'), ) }, ) }) .addPipeline()
await app.build()Choose the handler lifetime according to its dependencies and application requirements.
A handler that has no state of its own is commonly suitable for transient registration, while dependencies with request or scope-specific state should be resolved through the active service scope.
Access the Current User Context
Section titled “Access the Current User Context”BaseHandler provides the protected _getCurrentContext() method.
Use it when the handler needs the current UserContext.
For example:
export class GetProfileHandler extends BaseHandler< GetProfileQuery, Profile> { protected async executeAsync( request: GetProfileQuery, ): Promise<ResultType<Profile>> { const context = this._getCurrentContext()
const userId = context.userId const tenantId = context.tenantId
// Load data for the current user and tenant.
return Result.ok({ userId, tenantId, }) }}BaseHandler receives the USER_CONTEXT_FACTORY dependency from the application context.
When registering the handler manually, resolve it from the service scope:
scope.resolve('USER_CONTEXT_FACTORY')This dependency is registered by Xeno.JS’s application context setup.
Connect a Handler to a Command
Section titled “Connect a Handler to a Command”A Command defines its intent when it extends Command.
For example:
import { Command } from '@xeno-js/shared'
export class CreateUserCommand extends Command<User> { constructor( public readonly email: string, public readonly name: string, ) { super('CreateUserCommand') }}Register the corresponding handler with the same intent:
container.addTransient( 'CreateUserCommand', (scope) => { return new CreateUserHandler( scope.resolve('USER_CONTEXT_FACTORY'), ) },)The mediator can then associate the request with the registered handler.
Connect a Handler to a Query
Section titled “Connect a Handler to a Query”Queries use the same mechanism.
For example:
import { Query } from '@xeno-js/shared'
export class FindUserQuery extends Query<User> { constructor( public readonly userId: string, ) { super( 'FindUserQuery', { cacheKey: `users:${userId}`, ttl: 60, bypassCache: false, consistentRead: false, isUserScoped: false, }, ) }}Register the handler using the same intent:
container.addTransient( 'FindUserQuery', (scope) => { return new FindUserHandler( scope.resolve('USER_REPOSITORY'), scope.resolve('USER_CONTEXT_FACTORY'), ) },)For query-specific caching configuration, see Caching.
Execute the Handler Through the Mediator
Section titled “Execute the Handler Through the Mediator”Handlers are normally not called directly.
Send a Command through the mediator:
const result = await mediator.send( new CreateUserCommand( 'john@example.com', 'John Doe', ), new AbortController().signal,)Or execute a Query:
const result = await mediator.query( new FindUserQuery('user-123'), new AbortController().signal,)The mediator uses the request intent to resolve the corresponding handler.
This means that the following three pieces must agree:
Command / Query │ └── intent │ ▼ Handler registration │ └── HandlerComplete Command Handler Example
Section titled “Complete Command Handler Example”A minimal application can be organized like this:
src/├── application/│ └── users/│ ├── create-user.command.ts│ └── create-user.handler.ts└── app.tscreate-user.command.ts
Section titled “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') }}create-user.handler.ts
Section titled “create-user.handler.ts”import { Result, type ResultType } from '@xeno-js/shared'import { BaseHandler } from '@xeno-js/core'
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) }}app.ts
Section titled “app.ts”import { AppBuilder } from '@xeno-js/core'
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()Once the application is built, the handler is available to the CQRS execution flow.
Create a Handler with a Repository
Section titled “Create a Handler with a Repository”A more typical application handler delegates persistence to a repository:
export class CreateUserHandler extends BaseHandler< CreateUserCommand, User> { constructor( private readonly userRepository: UserRepository, userContextFactory: UserContextFactory, ) { super(userContextFactory) }
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 both services:
const app = new AppBuilder() .addServices((container) => { container.addScoped( 'USER_REPOSITORY', (scope) => { return new UserRepository( scope.resolve('USER_DATA_SOURCE'), ) }, )
container.addTransient( 'CreateUserCommand', (scope) => { return new CreateUserHandler( scope.resolve('USER_REPOSITORY'), scope.resolve('USER_CONTEXT_FACTORY'), ) }, ) }) .addPipeline()
await app.build()This keeps the handler focused on the application use case while the repository remains responsible for persistence.
Handle Errors
Section titled “Handle Errors”A handler returns a ResultType<TResponse>.
For successful execution:
return Result.ok(user)For an application failure, return a failed Result using the appropriate Xeno.JS error:
return Result.fail( AppError.validationError( request.intent, 'User could not be created.', ),)The exact error should describe the failure that occurred in the application use case.
Pipeline behaviors can also affect the final result before or around handler execution. See the pipeline documentation for validation, authorization, idempotency, concurrency, and other cross-cutting behavior.
Troubleshooting
Section titled “Troubleshooting”HANDLER_NOT_FOUND
Section titled “HANDLER_NOT_FOUND”If the application reports that the handler cannot be found, check:
- the handler is registered with
addServices(); await app.build()is called;- the registration token exactly matches the request
intent; - the handler factory returns an object with a
handle()method; - all dependencies required by the handler are registered.
For example:
super('CreateUserCommand')requires:
container.addTransient( 'CreateUserCommand', (scope) => new CreateUserHandler( scope.resolve('USER_CONTEXT_FACTORY'), ),)CreateUserCommand and createUserCommand are different tokens.
SCOPE_NOT_AVAILABLE
Section titled “SCOPE_NOT_AVAILABLE”If the request cannot be processed because no service scope is available, make sure the Command or Query is being executed through the normal Xeno.JS application execution context.
Do not resolve and execute handlers manually outside the application scope when using the mediator.
PIPELINE_NOT_AVAILABLE
Section titled “PIPELINE_NOT_AVAILABLE”If the mediator reports that the CQRS pipeline is unavailable, ensure that the application includes:
const app = new AppBuilder() .addPipeline()before:
await app.build()Handler dependency cannot be resolved
Section titled “Handler dependency cannot be resolved”If a handler constructor dependency cannot be resolved, verify that the dependency is registered before the handler is created:
.addServices((container) => { container.addScoped( 'USER_REPOSITORY', (scope) => new UserRepository( scope.resolve('USER_DATA_SOURCE'), ), )
container.addTransient( 'CreateUserCommand', (scope) => new CreateUserHandler( scope.resolve('USER_REPOSITORY'), scope.resolve('USER_CONTEXT_FACTORY'), ), )})The token passed to scope.resolve() must correspond to a registered service.
Handler Checklist
Section titled “Handler Checklist”Before running your application, verify:
- the handler extends
BaseHandler; - the request type matches the Command or Query;
- the response type matches the request response;
-
executeAsync()is implemented; - required dependencies are declared in the constructor;
- the handler is registered with
addServices(); - the registration token exactly matches the request
intent; -
addPipeline()is enabled; - the application is built with
await app.build(); - the Command is sent with
mediator.send(); - the Query is executed with
mediator.query().
Related Documentation
Section titled “Related Documentation”- CQRS Overview
- Creating Commands
- Creating Queries
- Validation Pipeline
- Caching
- Idempotency
- Concurrency
- Dependency Injection
- App Builder
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
