Skip to main content

✍️ Pushing events

Modifying the state of your application (i.e. pushing new events to your event stores) is done by executing commands. They typically consist in:

  • Fetching the required aggregates (if not the initial event of a new aggregate)
  • Validating that the modification is acceptable
  • Pushing new events with incremented versions

Command

import { Command, tuple } from '@castore/core';

type Input = { name: string; level: number };
type Output = { pokemonId: string };
type Context = { generateUuid: () => string };

const catchPokemonCommand = new Command({
commandId: 'CATCH_POKEMON',
// 👇 "tuple" is needed to keep ordering in inferred type
requiredEventStores: tuple(pokemonsEventStore, trainersEventStore),
// 👇 Code to execute
handler: async (
commandInput: Input,
[pokemonsEventStore, trainersEventStore],
// 👇 Additional context arguments can be provided
{ generateUuid }: Context,
): Promise<Output> => {
const { name, level } = commandInput;
const pokemonId = generateUuid();

await pokemonsEventStore.pushEvent({
aggregateId: pokemonId,
version: 1,
type: 'POKEMON_CAUGHT',
payload: { name, level },
});

return { pokemonId };
},
});
info

Note that we only provided TS types for Input and Output properties. That is because, as stated in the core design, Castore is meant to be as flexible as possible, and that includes the validation library you want to use (if any): The Command class can be used directly if no validation is required, or implemented by other classes which will add run-time validation methods to it 👍

Commands handlers should NOT use read models when validating that a modification is acceptable. Read models are like cache: They are not the source of truth, and may not represent the freshest state.

Fetching and pushing events non-simultaneously exposes your application to race conditions. To counter that, commands are designed to be retried when an EventAlreadyExistsError is triggered (which is part of the EventStorageAdapter interface).

Command Retry

Finally, Command handlers should be, as much as possible, pure functions. If they depend on impure functions like functions with unpredictable outputs (e.g. id generation), mutating effects, side effects or state dependency (e.g. external data fetching), you should pass them through the additional context arguments rather than directly importing and using them. This will make them easier to test and to re-use in different contexts, such as in the React Visualizer.

🔧 Reference

Constructor:

  • commandId (string): A string identifying the command
  • handler ((input: Input, requiredEventsStores: EventStore[]) => Promise<Output>): The code to execute
  • requiredEventStores (EventStore[]): A tuple of EventStores that are required by the command for read/write purposes. In TS, you should use the tuple util to preserve tuple ordering in the handler (tuple doesn't mute its inputs, it simply returns them)
  • eventAlreadyExistsRetries (?number = 2): Number of handler execution retries before breaking out of the retry loop (See section above on race conditions)
  • onEventAlreadyExists (?(error: EventAlreadyExistsError, context: ContextObj) => Promise<void>): Optional callback to execute when an EventAlreadyExistsError is raised.

    The EventAlreadyExistsError class contains the following properties:

    • eventStoreId (?string): The eventStoreId of the aggregate on which the pushEvent attempt failed
    • aggregateId (string): The aggregateId of the aggregate
    • version (number): The version of the aggregate The `ContextObj` contains the following properties:
    • attemptNumber (?number): The number of handler execution attempts in the retry loop
    • retriesLeft (?number): The number of retries left before breaking out of the retry loop
import { Command, tuple } from '@castore/core';

const doSomethingCommand = new Command({
commandId: 'DO_SOMETHING',
requiredEventStores: tuple(eventStore1, eventStore2),
handler: async (commandInput, [eventStore1, eventStore2]) => {
// ...do something here
},
});

Properties:

  • commandId (string): The command id
const commandId = doSomethingCommand.commandId;
// => 'DO_SOMETHING'
  • requiredEventStores (EventStore[]): The required event stores
const requiredEventStores = doSomethingCommand.requiredEventStores;
// => [eventStore1, eventStore2]
  • handler ((input: Input, requiredEventsStores: EventStore[]) => Promise<Output>): Function to invoke the command
const output = await doSomethingCommand.handler(input, [
eventStore1,
eventStore2,
]);