Skip to content

Aggregate ​

An aggregate holds state and the methods that change it. A successful method commits its state and emitted events together. A rejection or exception discards everything.

aggregate() has three forms. Pick the one that reads best for your domain.

FormSignatureUse when
Classaggregate(name, initialState)You like classes and this.state
Factoryaggregate(name, initialState, factory, options?)Methods share a context or need services
Event-evolvedaggregate(name, contract, evolve)State is a fold of events; you want declared commands (and HTTP)

Import ​

ts
import { aggregate } from '@marcelsud/accord'

Class aggregates ​

Extend aggregate(name, initialState) and write ordinary methods. The initial value types this.state.

ts
const Deposited = event('Deposited', { amount: Cents, balance: integer })

class Account extends aggregate('Account', { balance: 0 }) {
  deposit(amount: number) {
    const event = Deposited({ amount, balance: this.state.balance + amount })

    this.state.balance = event.balance
    this.emit(event)

    return ok({ balance: this.state.balance })
  }
}

Give empty collections and nullable fields a type annotation when the default can't express the full type:

ts
class Cart extends aggregate('Cart', { items: [] as string[], coupon: null as string | null }) {}

Instance API ​

MemberDescription
Account.create()A fresh instance with a copy of the defaults
Account.restore(state)An instance seeded with a copy of state
this.stateProtected working state
this.emit(event)Buffer an event (protected)
getState()A copy of the state
pullEvents()Drain the pending events

Direct calls on an instance mutate that local instance. That's handy for unit tests. For stored state, go through wire().

Reserved names

get, events, getState, pullEvents, emit, constructor, and then can't be used as method names.

Factory aggregates ​

Pass a factory that receives { state, emit } and returns the methods. They share one context.

ts
const Account = aggregate(
  'Account',
  { status: 'PENDING' as 'PENDING' | 'OPEN', balance: 0 },
  ({ state, emit, rules }) => ({
    open() {
      state.status = 'OPEN'
      emit(Opened())
      return ok(undefined)
    },
    deposit(amount: number) {
      if (state.status !== 'OPEN') return fail(NotOpen())
      if (amount > rules.maximum) return fail(DepositLimit({ maximum: rules.maximum }))

      state.balance += amount
      emit(Deposited({ amount, balance: state.balance }))
      return ok({ balance: state.balance })
    },
  }),
  { uses: { rules: DepositRules } }
)

Options ​

uses optional ​

uses: Record<string, Dependency>. Declared services arrive in the factory context next to state and emit.

Standalone instances ​

ts
const account = Account.create(
  { status: 'OPEN', balance: 100 },
  { rules: { maximum: 1_000 } }
)
const [reply, error] = account.deposit(50)

account.getState() // { status: 'OPEN', balance: 150 }
account.pullEvents() // [{ _tag: 'Deposited', amount: 50, balance: 150 }]

Account.create() copies the defaults; Account.create(state, services) copies state. Pass undefined as the seed to start from defaults with services.

Event-evolved aggregates ​

Declare commands with contracts, then fold events into state with evolve.

ts
import { aggregate, command, event, fail, integer, match, ok, refine, rejection, s } from '@marcelsud/accord'

const Cents = refine(integer, (n) => n > 0, 'positive cents')
const State = s.object({ open: s.boolean, balance: integer })
const Opened = event('Opened')
const Deposited = event('Deposited', { amount: Cents })
const AlreadyOpen = rejection('AlreadyOpen')
const NotOpen = rejection('NotOpen')

const open = command(
  { state: State, input: {}, events: [Opened], rejects: [AlreadyOpen] },
  (state) => (state.open ? fail(AlreadyOpen()) : ok({ events: [Opened()] }))
)

const deposit = command(
  { state: State, input: { amount: Cents }, events: [Deposited], rejects: [NotOpen], reply: { balance: integer } },
  (state, { amount }) => {
    if (!state.open) return fail(NotOpen())
    return ok({ events: [Deposited({ amount })], reply: { balance: state.balance + amount } })
  }
)

export const Account = aggregate(
  'Account',
  { state: State, initial: () => ({ open: false, balance: 0 }), commands: { open, deposit } },
  (state, event) =>
    match(event, {
      Opened: () => ({ ...state, open: true }),
      Deposited: ({ amount }) => ({ ...state, balance: state.balance + amount }),
    })
)

Calls take an input object: bank.account.deposit('a-1', { amount: 100 }). Input is validated and returns InvalidInput when it doesn't match.

Command contract ​

FieldType
stateSchemaInputState schema
inputSchemaInputInput schema
eventsEvent[]Events the command may emit
rejectsRejection[]optional Declared rejections
replySchemaInputoptional Reply schema
usesRecord<string, Dependency>optional Services

The handler returns ok({ events, reply? }) or fail(rejection). Calling it directly (deposit(state, input)) validates and returns a decision without saving anything.

You can also declare the aggregate contract once as { state, commands } and implement each command with command(AccountContract, 'deposit', body). Those commands keep their declared names and state schema. Only command() definitions can be exposed over HTTP.

Calls through the runtime ​

ts
const bank = wire(model('Bank', { aggregates: { account: Account } }))

const [receipt, error] = await bank.account.deposit('a-1', 100)
const [state] = await bank.account.get('a-1')
const [envelopes] = await bank.account.events('a-1', { after: 0 })
  • Calls for one ID run in order. Each starts from a copy of stored state (or the defaults).
  • A successful call saves state, events, and a receipt together.
  • get(id) returns a copy of the stored state (the initial value for a fresh ID).
  • events(id, { after }) returns committed envelopes after a sequence number.

Receipt ​

FieldDescription
replyWhat your method returned in ok()
eventsThe committed envelopes
versionNumber of successful calls
eventCursorLast committed event sequence (class and factory aggregates)
commandId, streamIdentity of the commit

Pass the receipt to view.get(key, { atLeast: receipt }) to read your own write.

Call options (event-evolved commands) ​

commandId optional ​

string. Deduplicates successful commits per instance. Reusing it with different input returns CommandConflict.

expectedVersion optional ​

number. Optimistic concurrency. A stale version returns Conflict.

ts
await bank.account.deposit('a-1', { amount: 100 }, { commandId: 'req-42', expectedVersion: 1 })

See also ​

Accord is currently UNLICENSED. No open-source license has been selected yet.