noddde

Queries

Defining type-safe queries with DefineQueries, implementing query handlers, and dispatching through the QueryBus.

Queries are read-only requests for data. They never modify state. In a CQRS architecture, queries are the counterpart to commands on the write side -- commands express intent to change, queries express a request to read. For background on the command/query separation, see CQRS and Event Sourcing.

The Query Interface

A query is defined by extending Query<TResult>:

import { Query } from "@noddde/core";

interface GetBankAccountByIdQuery extends Query<BankAccountView> {
  name: "GetBankAccountById";
  payload: { id: string };
}

The Query interface has two type parameters:

interface Query<TResult, TQueryNames extends string | symbol = string> {
  name: TQueryNames;
  payload?: any;
}
ParameterPurpose
TResultThe return type of this query. This is a phantom type -- it does not appear as a field on the object but links the query to its expected result at the type level.
TQueryNamesOptional constraint on the name field. Defaults to string.

Defining Query Types with DefineQueries

The recommended way to define queries is the DefineQueries utility type. It creates a discriminated union from a definition map, consistent with how events and commands are defined (see Defining Aggregates):

import { DefineQueries } from "@noddde/core";

type BankAccountQuery = DefineQueries<{
  GetBankAccountById: { payload: { id: string }; result: BankAccountView };
  ListBankAccountTransactions: {
    payload: { accountId: string; limit?: number; offset?: number };
    result: TransactionView[];
  };
  GetAccountSummary: { payload: { accountId: string }; result: AccountSummary };
  GetSystemStats: { result: SystemStats };
}>;

Each key becomes a query name. Each value specifies:

  • result (required) -- The return type of the query. Encoded as a phantom type.
  • payload (optional) -- The data carried by the query. Omit for queries with no parameters.

Queries Without Payload

Omit the payload field for parameterless queries:

type StatsQuery = DefineQueries<{
  GetSystemStats: { result: SystemStats };
}>;

// Dispatched as: { name: "GetSystemStats" }

The QueryResult Extractor

QueryResult<TQuery> extracts the result type from a query definition:

import { QueryResult } from "@noddde/core";

type Result = QueryResult<GetBankAccountByIdQuery>;
// BankAccountView

Use QueryResult when you need the result type without importing the view type directly. If the query's result type changes, all consumers update automatically:

async function handleGetAccount(
  req: Request,
): Promise<QueryResult<GetBankAccountByIdQuery>> {
  return domain.dispatchQuery<GetBankAccountByIdQuery>({
    name: "GetBankAccountById",
    payload: { id: req.params.id },
  });
}

Query Naming Conventions

PatternExampleUse Case
Get[Entity]By[Field]GetBankAccountByIdSingle entity lookup
List[Entities]ListBankAccountTransactionsCollection queries
Get[Entity][Aspect]GetAccountSummaryDerived or computed views
Search[Entities]SearchTransactionsFiltered searches
Count[Entities]CountPendingTransactionsAggregate count queries

The QueryHandler Type

A QueryHandler receives a query payload and infrastructure, then returns the result:

type QueryHandler<
  TInfrastructure extends Infrastructure,
  TQuery extends Query<any>,
> = (
  query: TQuery["payload"],
  infrastructure: TInfrastructure,
) => QueryResult<TQuery> | Promise<QueryResult<TQuery>>;
ParameterTypeDescription
queryTQuery["payload"]The query payload -- the filtering and lookup parameters. This is the payload field, not the entire query object.
infrastructureTInfrastructureInfrastructure services (repositories, caches, external clients).
ReturnQueryResult<TQuery>The result type inferred from the query definition. Can be sync or async.

Basic Handler

const getBankAccountByIdHandler: QueryHandler<
  BankingInfrastructure,
  GetBankAccountByIdQuery
> = async (query, { bankAccountViewRepository }) => {
  return bankAccountViewRepository.getById(query.id);
};

The handler receives query as the payload ({ id: string } in this case), destructures the repository from infrastructure, and returns the result.

Handler with Filtering and Pagination

const listTransactionsHandler: QueryHandler<
  BankingInfrastructure,
  ListBankAccountTransactionsQuery
> = async (query, { transactionViewRepository }) => {
  return transactionViewRepository.list({
    accountId: query.accountId,
    status: query.status,
    limit: query.limit ?? 50,
    offset: query.offset ?? 0,
  });
};

Default values for optional parameters are typically applied inside the handler.

Synchronous Handlers

Query handlers do not have to be async. If the data source is in-memory, return the result directly:

const getSystemStatsHandler: QueryHandler<
  AppInfrastructure,
  GetSystemStatsQuery
> = (query, { statsCache }) => {
  return statsCache.getStats();
};

Projection-Backed vs Standalone Handlers

Projection-Backed

Most query handlers read from views built by projections. The projection keeps the read model up to date from the event stream, and the query handler serves it:

Events --> Projection --> View Store --> Query Handler --> Result

These handlers are defined inside defineProjection as part of the queryHandlers map.

Standalone Query Handlers

Some queries do not need a prebuilt read model. They compute results on-the-fly from raw data or external services:

const calculateInterestHandler: QueryHandler<
  BankingInfrastructure,
  CalculateInterestQuery
> = async (query, { bankAccountViewRepository }) => {
  const account = await bankAccountViewRepository.getById(query.accountId);

  const interest = (account.balance * query.rate * query.days) / 365;

  return {
    accountId: query.accountId,
    currentBalance: account.balance,
    projectedInterest: Math.round(interest * 100) / 100,
    projectedBalance: Math.round((account.balance + interest) * 100) / 100,
    days: query.days,
    rate: query.rate,
  };
};

Standalone handlers are useful for computed or derived data that does not justify a persistent read model, queries that combine data from multiple sources at read time, and queries against external services.

Standalone handlers are registered separately in the domain configuration under readModel.standaloneQueryHandlers:

const domain = await configureDomain({
  writeModel: { aggregates: { BankAccount } },
  readModel: {
    projections: {
      /* ... */
    },
    standaloneQueryHandlers: {
      CalculateInterest: calculateInterestHandler,
    },
  },
  infrastructure: {
    /* ... */
  },
});

The QueryBus Interface

The QueryBus is the dispatch mechanism for queries. It provides a single dispatch method that routes a query to its registered handler and returns the typed result:

interface QueryBus {
  dispatch<TQuery extends Query<any>>(
    query: TQuery,
  ): Promise<QueryResult<TQuery>>;
}

Typed Dispatch

The return type is automatically derived from the query type argument:

async function example(queryBus: QueryBus) {
  // result is typed as BankAccountView
  const account = await queryBus.dispatch<GetBankAccountByIdQuery>({
    name: "GetBankAccountById",
    payload: { id: "acct-001" },
  });

  // result is typed as TransactionView[]
  const transactions =
    await queryBus.dispatch<ListBankAccountTransactionsQuery>({
      name: "ListBankAccountTransactions",
      payload: { accountId: "acct-001", limit: 20, offset: 0 },
    });
}

The explicit type argument on dispatch<GetBankAccountByIdQuery>(...) serves two purposes: it infers the return type, and it validates the query object shape.

InMemoryQueryBus

noddde ships with InMemoryQueryBus, an in-process implementation:

import { InMemoryQueryBus } from "@noddde/engine";

const queryBus = new InMemoryQueryBus();

InMemoryQueryBus maintains a handler registry keyed by query name. It throws if no handler is registered for a dispatched query or if a duplicate handler is registered for the same name. During domain initialization, the framework automatically registers both projection query handlers and standalone query handlers on the bus.

Dispatching from Application Code

The Domain class provides dispatchQuery, giving you a symmetric API alongside dispatchCommand:

// In an HTTP handler, GraphQL resolver, or service layer
const account = await domain.dispatchQuery<GetBankAccountByIdQuery>({
  name: "GetBankAccountById",
  payload: { id: accountId },
});

Dispatching from an Express Route

app.get("/accounts/:id", async (req, res) => {
  try {
    const account = await domain.dispatchQuery<GetBankAccountByIdQuery>({
      name: "GetBankAccountById",
      payload: { id: req.params.id },
    });
    res.json(account);
  } catch (error) {
    res.status(404).json({ error: "Account not found" });
  }
});

Custom QueryBus Implementations

For production, you may need a query bus with caching, middleware, or distributed routing. A custom implementation must satisfy the QueryBus interface:

class ProductionQueryBus<TInfrastructure extends Infrastructure>
  implements QueryBus
{
  private handlers = new Map<string, QueryHandler<any, any>>();

  constructor(private infrastructure: TInfrastructure) {}

  register<TQuery extends Query<any>>(
    name: string,
    handler: QueryHandler<TInfrastructure, TQuery>,
  ): void {
    this.handlers.set(name, handler);
  }

  async dispatch<TQuery extends Query<any>>(
    query: TQuery,
  ): Promise<QueryResult<TQuery>> {
    const handler = this.handlers.get(query.name as string);
    if (!handler) {
      throw new Error(`No handler registered for query: ${String(query.name)}`);
    }
    return handler(query.payload, this.infrastructure);
  }
}

When building a production query bus, consider error handling for unregistered query names and handler failures, logging for observability, middleware for cross-cutting concerns like caching or authorization, and async initialization for handlers that need database connections or cache warming.

Error Handling

Query handlers can throw errors for invalid queries or missing data. The error propagates through the query bus to the caller:

const getBankAccountByIdHandler: QueryHandler<
  BankingInfrastructure,
  GetBankAccountByIdQuery
> = async (query, { bankAccountViewRepository }) => {
  const account = await bankAccountViewRepository.getById(query.id);

  if (!account) {
    throw new Error(`Bank account not found: ${query.id}`);
  }

  return account;
};

How you handle the error at the call site depends on your application layer (HTTP status codes, GraphQL errors, etc.).

Next Steps

  • Domain Configuration -- Wiring projections, query handlers, and the query bus into the domain
  • Testing Domains -- End-to-end testing strategies for the full read/write pipeline

On this page