Skip to content

Write paths

Each write picks one path: aggregate-sourced or direct-DB. Never both.

Nwire ships two write paths and the framework supports either. A single action, however, must commit to one. Doing both — calling ctx.actor(Actor, id) and db.insert(...) in the same handler — splits authorship of the entity across two stores, gives you no single source of truth, and lets the actor's event log and the row drift the moment one of them fails. The examples in this repo sometimes show both shapes side-by-side for comparison; do not read that as permission to mix them in your own code.

Aggregate-sourced (actor + events)

Pick this when state has invariants that must hold across writes, when you need an audit log of how the entity got into its current shape, or when the entity will be replayed for projections or sagas. The actor enforces the invariants in methods, records events with recordThat, and the runtime folds the events into the next state via when. The event log is the entity — everything else is derived. State stores (@nwire/mongo, @nwire/store-file, snapshot stores) exist to make replay cheap, not to own the truth. Writes flow action → actor.method → recordThat(Event) → runtime appends + folds + publishes.

Direct-DB (CRUD against the data layer)

Pick this when the entity is a row. No multi-step lifecycle, no cross-aggregate invariants, no audit requirement — just INSERT, UPDATE, DELETE against a table you own. The handler resolves the database from the container (resolve<DB>("db")) and calls the Drizzle / Prisma / Mongo client directly. There is no event, no actor, no projection involvement. The row IS the state. Writes flow action → handler → db.insert/update/delete → return row. This is the right default for most CRUD surfaces — admin panels, lookup tables, reference data, simple per-user records.

Mixed reads are fine

Queries are read-only and can blend both. A defineQuery may pull from a projection (folded from actor events) AND join against a direct-DB table in the same request — the read side is not where the rule applies. The rule is about authorship of a write: who is allowed to mutate this entity, and through which surface. Reads compose freely; writes commit to one path.

Picking the path

NeedAggregate-sourcedDirect-DB
Multi-step invariantsyesno
Event audit log / replayyesno
Pure CRUD (one entity, no rules)noyes
Multi-step saga / workflowyesno
External system writes (no rules)noyes
Snapshot reads (cheap point-in-time)read from storeread from row

Same action, both ways

Commissioning a station. Aggregate-sourced — the entity has a lifecycle (active, decommissioned), invariants on transitions, and downstream projections that need the events:

ts
// modules/stations/actions/create-station.ts
export const createStation = defineAction("stations.create-station", {
  input: z.object({ name: z.string(), location: locationSchema }),
  emits:  [StationWasCreated],
  handler: async ({ input, use }) => {
    const stationId = randomUUID();
    const station = await use(Station, stationId);
    station.create(input);              // method enforces invariants, recordThat(...)
    return { stationId };               // runtime appends + folds + publishes
  },
});

Direct-DB — the station is just a row in a table the admin panel manages, no lifecycle worth modelling:

ts
// app/api.ts
api.wire(post("/stations", { body: StationInput }), async ({ input, resolve }) => {
  const db = resolve<DB>("db");
  const [row] = await db
    .insert(stations)
    .values({ ...input, status: "active" })
    .returning();
  return { $status: 201, body: row };
});

Both are valid. They are not interchangeable, and they are not stackable.


If you're not sure, start with direct-DB; promote to actor only when invariants force it.

MIT licensed.