Skip to content

Creating Queries

Use a Query when your application needs to retrieve data without performing a command operation.

A Xeno.JS Query:

  1. extends the Query base class;
  2. defines the input parameters required by the use case;
  3. provides cache options;
  4. is associated with a handler using its intent;
  5. can optionally be validated;
  6. can optionally participate in Query caching;
  7. is executed through the mediator.

You need:

  • an AppBuilder instance;
  • CQRS pipeline configuration through addPipeline();
  • a Query class;
  • a Query handler;
  • a service scope available when the Query is executed.

If you use validation, you also need a Zod schema or a custom validation strategy.

If you use Query caching, you must enable the Query pipeline.

Configure the CQRS pipeline through AppBuilder.addPipeline().

The minimal configuration is:

import { AppBuilder } from 'xeno'
const app = new AppBuilder()
.addPipeline()
const container = await app.build()

addPipeline() registers the CQRS infrastructure required to execute Commands and Queries.

It also initializes the default application services used by the CQRS pipeline.

Extend the Query class from @xeno-js/shared.

import { Query } from '@xeno-js/shared'
export interface User {
id: string
email: string
name: string
}
export class GetUserQuery extends Query<User> {
constructor(
public readonly userId: string,
) {
super('GetUserQuery', {
cacheKey: `user:${userId}`,
ttl: 60,
bypassCache: false,
consistentRead: false,
isUserScoped: false,
})
}
}

The first argument passed to super() is the Query intent.

The intent is important because Xeno.JS uses it to identify the Query and resolve the corresponding handler.

The Query base class also sets the request type to REQUEST_TYPE.QUERY.

Define the data required by the Query directly on the Query class.

For example:

export class GetUserQuery extends Query<User> {
constructor(
public readonly userId: string,
) {
super('GetUserQuery', {
cacheKey: `user:${userId}`,
ttl: 60,
bypassCache: false,
consistentRead: false,
isUserScoped: false,
})
}
}

Keep the Query focused on the input required by the use case.

The handler is responsible for executing the use case and returning the result.

A Query always receives an ICacheableOptions object.

The available options are:

Option Description
cacheKey Key used to identify the cached Query result.
ttl Optional cache lifetime.
bypassCache When true, skips reading the cache for this request.
consistentRead When true, skips reading the cache and performs a fresh read.
isUserScoped When true, scopes the cache key to the current user.

For example:

export class GetUserQuery extends Query<User> {
constructor(
public readonly userId: string,
) {
super('GetUserQuery', {
cacheKey: `user:${userId}`,
ttl: 60,
bypassCache: false,
consistentRead: false,
isUserScoped: false,
})
}
}

The cache key should identify the actual input that affects the Query result.

For example:

cacheKey: `user:${userId}`

For a Query with multiple parameters:

cacheKey: `users:${page}:${pageSize}:${status}`

Avoid using a static key when different Query parameters can produce different results.

Use ttl to control how long a successful Query result remains cached.

ttl: 60

The ICacheableOptions contract documents ttl as seconds.

If you omit ttl, the configured cache implementation determines the effective TTL behavior.

Use bypassCache when a particular Query execution should not use an existing cached value:

bypassCache: true

The Query still executes normally.

If the Query succeeds, its result can still be written to the cache.

Use consistentRead when the Query should perform a fresh read instead of returning an existing cached value:

consistentRead: true

Like bypassCache, this skips the cache read.

A successful result can still update the cache.

Set isUserScoped to true when the Query result depends on the current user:

isUserScoped: true

For example:

export class GetCurrentUserProfileQuery extends Query<User> {
constructor() {
super('GetCurrentUserProfileQuery', {
cacheKey: 'profile',
ttl: 60,
bypassCache: false,
consistentRead: false,
isUserScoped: true,
})
}
}

Use user-scoped caching for data that must not be shared between users.

Query caching is enabled through queryBus.isEnabled.

Configure it in addPipeline():

const app = new AppBuilder()
.addPipeline((config) => {
config.queryBus.isEnabled = true
})
const container = await app.build()

Once enabled, Xeno.JS adds Query caching to the Query pipeline.

You do not need to add QueryCachingPipeline manually.

AppBuilder provides an in-memory cache configuration by default.

You can explicitly enable the in-memory cache:

const app = new AppBuilder()
.addCache((config) => {
config.inMemory = true
})
.addPipeline((config) => {
config.queryBus.isEnabled = true
})
await app.build()

A Redis configuration can also be supplied through addCache():

const app = new AppBuilder()
.addCache((config) => {
config.inMemory = false
config.redis = {
// Redis configuration
}
})
.addPipeline((config) => {
config.queryBus.isEnabled = true
})
await app.build()

The exact Redis options depend on the configured CacheConfig type.

If no cache strategy is configured, the cache module cannot initialize.

Queries can use the same validation pipeline used by Commands.

Define a Zod schema whose key matches the Query intent.

For the Query:

export class GetUserQuery extends Query<User> {
constructor(
public readonly userId: string,
) {
super('GetUserQuery', {
cacheKey: `user:${userId}`,
ttl: 60,
bypassCache: false,
consistentRead: false,
isUserScoped: false,
})
}
}

the validation schema can be:

import { z } from 'zod'
export const getUserQuerySchema = z.object({
intent: z.literal('GetUserQuery'),
type: z.literal('QUERY'),
userId: z.string().min(1),
})

The schema is registered using the Query intent:

const app = new AppBuilder()
.addPipeline((config) => {
config.validation.zod = {
schemas: {
GetUserQuery: getUserQuerySchema,
},
}
})

See Validation Pipeline for the validation configuration and behavior.

For the complete Query-specific schema workflow, keep the schema next to the Query:

src/
└── application/
└── users/
├── get-user.query.ts
├── get-user.query.schema.ts
└── get-user.handler.ts

A Query needs a handler registered under the same intent.

Extend BaseHandler and implement executeAsync():

import type { ResultType } from '@xeno-js/shared'
import { Result } from '@xeno-js/shared'
import { BaseHandler } from '@/application'
import type { User } from './user.types'
import { GetUserQuery } from './get-user.query'
export class GetUserHandler extends BaseHandler<
GetUserQuery,
User
> {
protected async executeAsync(
request: GetUserQuery,
): Promise<ResultType<User>> {
const user = await this.findUser(request.userId)
if (!user) {
return Result.fail(
new Error('User not found'),
)
}
return Result.ok(user)
}
private async findUser(userId: string): Promise<User | undefined> {
// Load the user from your application service or repository.
return undefined
}
}

For handler registration and dependency injection, see Creating Handlers.

The handler registration token must exactly match the Query intent.

For the Query:

super('GetUserQuery', {
cacheKey: `user:${userId}`,
ttl: 60,
bypassCache: false,
consistentRead: false,
isUserScoped: false,
})

register the handler using GetUserQuery:

const app = new AppBuilder()
.addServices((container) => {
container.addTransient(
'GetUserQuery',
(scope) => {
return new GetUserHandler(
scope.resolve('USER_CONTEXT_FACTORY'),
)
},
)
})
.addPipeline()
await app.build()

The string must match exactly.

Query intent
↓
GetUserQuery
↓
registered handler

If the intent and registration token differ, Xeno.JS cannot resolve the handler.

Queries are executed through the mediator with mediator.query().

const query = new GetUserQuery('user-123')
const result = await mediator.query(
query,
new AbortController().signal,
)

The result is a ResultType<TResponse>.

Check the result before using its value:

if (result.isOk()) {
const user = result.getValueOrThrow()
console.log(user)
}

A complete Query setup can look like this:

src/
└── application/
└── users/
├── get-user.query.ts
├── get-user.query.schema.ts
├── get-user.handler.ts
└── user.types.ts
└── bootstrap.ts
export interface User {
id: string
email: string
name: string
}
import { Query } from '@xeno-js/shared'
import type { User } from './user.types'
export class GetUserQuery extends Query<User> {
constructor(
public readonly userId: string,
) {
super('GetUserQuery', {
cacheKey: `user:${userId}`,
ttl: 60,
bypassCache: false,
consistentRead: false,
isUserScoped: false,
})
}
}
import { z } from 'zod'
export const getUserQuerySchema = z.object({
intent: z.literal('GetUserQuery'),
type: z.literal('QUERY'),
userId: z.string().min(1),
})
import type { ResultType } from '@xeno-js/shared'
import { Result } from '@xeno-js/shared'
import { BaseHandler } from '@/application'
import { GetUserQuery } from './get-user.query'
import type { User } from './user.types'
export class GetUserHandler extends BaseHandler<
GetUserQuery,
User
> {
protected async executeAsync(
request: GetUserQuery,
): Promise<ResultType<User>> {
const user: User = {
id: request.userId,
email: 'john@example.com',
name: 'John Doe',
}
return Result.ok(user)
}
}
import { AppBuilder } from 'xeno'
import { GetUserHandler } from './application/users/get-user.handler'
import { getUserQuerySchema } from './application/users/get-user.query.schema'
const app = new AppBuilder()
.addServices((container) => {
container.addTransient(
'GetUserQuery',
(scope) => {
return new GetUserHandler(
scope.resolve('USER_CONTEXT_FACTORY'),
)
},
)
})
.addPipeline((config) => {
config.queryBus.isEnabled = true
config.validation.zod = {
schemas: {
GetUserQuery: getUserQuerySchema,
},
}
})
const container = await app.build()
const mediator = container.resolve('MEDIATOR')
const query = new GetUserQuery('user-123')
const result = await mediator.query(
query,
new AbortController().signal,
)
if (result.isOk()) {
console.log(result.getValueOrThrow())
}

This setup provides:

  • a Query with typed input and output;
  • a registered Query handler;
  • Zod validation;
  • Query caching;
  • a cache key based on the Query parameter;
  • execution through the mediator.

When Query caching is enabled and the Query has a cacheKey, Xeno.JS behaves as follows:

Query
│
├── cache read
│ │
│ ├── hit → return cached result
│ │
│ └── miss
│
├── execute handler
│
└── successful result → write to cache

A cache hit returns the cached result without executing the handler.

A successful cache miss stores the handler result.

Failed Query results are not written to the cache.

If a cache read fails, Xeno.JS logs a warning and continues with the Query execution.

If a cache write fails, Xeno.JS logs a warning and returns the Query result normally.

This means the cache is not required to be available for the Query handler to produce its result after a cache read/write failure.

To force a fresh read:

export class GetUserQuery extends Query<User> {
constructor(
public readonly userId: string,
) {
super('GetUserQuery', {
cacheKey: `user:${userId}`,
ttl: 60,
bypassCache: true,
consistentRead: false,
isUserScoped: false,
})
}
}

bypassCache: true prevents an existing cached value from being returned.

The Query still executes and, if successful, its result can be cached.

For a semantically explicit fresh read, use:

consistentRead: true

Both options prevent the cache from being read.

Check:

  1. the Query extends Query;
  2. the intent is correct;
  3. the handler is registered with exactly the same intent;
  4. addPipeline() is configured;
  5. the Query is executed through mediator.query().

For example:

super('GetUserQuery', {
cacheKey: `user:${userId}`,
ttl: 60,
bypassCache: false,
consistentRead: false,
isUserScoped: false,
})

must be registered as:

container.addTransient(
'GetUserQuery',
(scope) => new GetUserHandler(
scope.resolve('USER_CONTEXT_FACTORY'),
),
)

Check:

  1. queryBus.isEnabled is true;
  2. addPipeline() is called;
  3. the Query has a non-empty cacheKey;
  4. addCache() has a valid cache strategy;
  5. the Query parameters are represented in the cache key.

For example:

.addPipeline((config) => {
config.queryBus.isEnabled = true
})

and:

super('GetUserQuery', {
cacheKey: `user:${userId}`,
ttl: 60,
bypassCache: false,
consistentRead: false,
isUserScoped: false,
})

Every Query executes even when caching is configured

Section titled “Every Query executes even when caching is configured”

Check whether caching is enabled:

config.queryBus.isEnabled = true

Configuring a cache provider alone does not add Query caching to the Query pipeline.

Check:

  1. addPipeline() is configured;
  2. validation.zod contains a schema;
  3. the schema is registered using the exact Query intent;
  4. the Query is executed through the mediator.

For example:

config.validation.zod = {
schemas: {
GetUserQuery: getUserQuerySchema,
},
}

The key must match:

super('GetUserQuery', ...)

See Validation Pipeline for validation-specific troubleshooting.

Check the cacheKey.

Every value that can change the Query result should be represented in the key.

For example, avoid:

cacheKey: 'users'

when the Query depends on:

page
pageSize
status

Instead use a key that includes those parameters:

cacheKey: `users:${page}:${pageSize}:${status}`

If the result depends on the current user, also consider:

isUserScoped: true

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