Postboy Help

Message Roles (CQRS-style organisation)

Postboy itself does not enforce any naming or structural rules — it only understands message types and contracts.
However, the author recommends organising messages according to their role in the system, following a CQRS-inspired separation.

This document describes four clear roles:

  • Query – a request for data (always returns a result)

  • Command – an instruction to perform an action (may or may not return a result)

  • Event – a notification that something happened (never returns a result)

  • Executor – a synchronous operation, often used for mapping or instant transformation

All roles share the same Postboy infrastructure (ConnectMessage, exec, sub, fire, fireCallback) and differ only in intent and, optionally, naming conventions.

Why separate by role?

  • Readability: a developer can instantly understand what a message does by looking at its name and location

  • Architecture clarity: the codebase makes the distinction between queries, side‑effecting commands, and domain events visible at the file system level

  • Predictable communication: callers know whether to expect a result, and handlers understand their responsibility

  • Easier refactoring: changing a command’s side effects does not affect queries, and vice versa

The four roles

1. Query

A query is a read request. It always returns data and never changes system state.

  • Extends PostboyCallbackMessage<T>

  • Always calls msg.finish(...) with a result

  • Named with the suffix Query

Example — loading a user

import {PostboyCallbackMessage} from '@artstesh/postboy'; export class GetUserQuery extends PostboyCallbackMessage<User | null> { static readonly ID = 'user.get'; constructor(public readonly userId: string) { super(); } }

Handler:

postboy.exec(new ConnectMessage(GetUserQuery, new Subject<GetUserQuery>())); postboy.sub(GetUserQuery).subscribe(async (msg) => { const user = await api.loadUser(msg.userId); msg.finish(user ?? null); });

Caller:

postboy.fireCallback(new GetUserQuery('42')).subscribe((user) => { console.log(user?.name); });

2. Command

A command instructs the system to do something. It may return a result (e.g., the ID of a created entity) or nothing at all.

  • Extends PostboyCallbackMessage<T> (if returns something) or PostboyGenericMessage (if not)

  • If there is a useful return value, it is returned via msg.finish(...); otherwise there is nothing to do for a subscriber

  • Named with the suffix Command

Example A — command that returns a value

export class CreateOrderCommand extends PostboyCallbackMessage<string> { static readonly ID = 'order.create'; constructor(public readonly customerId: string, public readonly items: CartItem[]) { super(); } }

Handler:

postboy.sub(CreateOrderCommand).subscribe(async (msg) => { const orderId = await orderService.create(msg.customerId, msg.items); msg.finish(orderId); });

Example B — command without a meaningful result

export class ClearHistoryCommand extends PostboyGenericMessage { static readonly ID = 'history.clear'; constructor(public readonly userId: string) { super(); } }

Handler:

postboy.sub(ClearHistoryCommand).subscribe(async (msg) => { await historyService.clear(msg.userId); });

Caller:

postboy.fireCallback(new ClearHistoryCommand('42'));

3. Event

An event is a pure notification. Something happened; anyone interested can react. Events never return a result and do not expect a single handler.

Example

import {PostboyGenericMessage} from '@artstesh/postboy'; export class OrderShippedEvent extends PostboyGenericMessage { static readonly ID = 'order.shipped'; constructor(public readonly orderId: string) { super(); } }

Registration and publishing:

postboy.exec(new ConnectMessage(OrderShippedEvent, new Subject<OrderShippedEvent>())); // Publishing (inside shipping service) postboy.fire(new OrderShippedEvent('ORD-123'));

Subscriber (e.g., notification service):

postboy.sub(OrderShippedEvent).subscribe((msg) => { sendEmail(msg.orderId, 'Your order has been shipped'); });

4. Executor

An executor is a synchronous operation that transforms or computes something immediately.
It is a command-like message that always runs synchronously and is typically used for mapping, formatting, or simple lookups.

Example — product model to view‑model mapping

export class ProductToViewModelExecutor extends PostboyExecutor<ProductVM> { static readonly ID = 'product.to-vm'; constructor(public readonly product: Product) { super(); } }

Handler (synchronous):

export class PosToViewModelHandler extends PostboyHandler<ProductVM,ProductToViewModelExecutor> { handle(executor: ProductToViewModelExecutor): ProductVM { return { id: executor.product.id, displayName: executor.product.name.toUpperCase(), stockLabel: executor.product.inStock ? 'Available' : 'Sold out', }; } }

Caller:

const product = await api.loadProduct(msg.product.id); const viewModel = postboy.exec(new ProductToViewModelExecutor(product));

Naming conventions

The suggested naming pattern is:

Role

Base class

Name suffix

Returns

Query

PostboyCallbackMessage<T>

…Query

Always

Command

PostboyCallbackMessage<T>
PostboyGenericMessage

…Command

Optional

Event

PostboyGenericMessage

…Event

Never

Executor

PostboyExecutor<T>

…Executor

Usually

Directory structure

The recommended layout is:

src/app/features/…/messages/ commands/ create-order.command.ts clear-history.command.ts queries/ get-user.query.ts search-products.query.ts events/ order-shipped.event.ts stock-updated.event.ts executors/ product-to-view-model.executor.ts cart-model-to-widget.executor.ts

This grouping immediately tells a developer where to look when they need to send a request, publish an event, or understand what side effects a command has.

Important note

Postboy does not require these conventions. You can name your classes however you like, put them anywhere, and mix roles arbitrarily.
The suggestions above are an author’s recommended practice that makes large‑scale event‑driven codebases easier to navigate and maintain.

03 мая 2026