Skip to content

L4 — + Forge (actors, events, projections, workflows)

L3 + @nwire/forge for the full domain stack: actors (state machines), events (past-tense facts), projections (read models), workflows (multi-step processes), and Studio (live event stream + EventStorming canvas).

What you add

bash
pnpm add @nwire/forge @nwire/messages

The shape

End-to-end: a student submits an answer, the actor records it, a projection materializes the read model.

ts
// 1. Event — past-tense fact.
import { defineEvent } from "@nwire/messages"
import { z } from "zod"

export const AnswerWasSubmitted = defineEvent({
  name: "submissions.answer-was-submitted",
  outcome: "milestone",
  audience: ["product"],
  schema: z.object({
    studentId: z.string(),
    exerciseId: z.string(),
    answer: z.string(),
    submittedAt: z.string().datetime(),
  }),
})

// 2. Schema — data shape + lifecycle states.
import { defineSchema } from "@nwire/forge"

export const SubmissionData = defineSchema({
  name: "submission",
  key: "studentId",
  fields: {
    studentId:  z.string(),
    exerciseId: z.string(),
    answer:     z.string().optional(),
  },
  states: {
    submitted: { initial: true },
    graded:    { final: true },
  },
})

// 3. Actor — state machine bound to one submission.
import { defineActor } from "@nwire/forge"

export const Submission = defineActor({
  schema: SubmissionData,
  states: {
    submitted: {
      on: {
        [AnswerWasSubmitted.name]: { assign: (_state, event) => event },
      },
    },
  },
})

// 4. Action — the user-visible verb that emits the event.
import { defineAction } from "@nwire/forge"

export const submitAnswer = defineAction({
  name: "submissions.submit-answer",
  persona: "Avi (9, beginner)",
  journeyStep: "J3-submit",
  schema: z.object({
    studentId: z.string(),
    exerciseId: z.string(),
    answer: z.string(),
  }),
  emits: [AnswerWasSubmitted],
  handler: async (input) =>
    AnswerWasSubmitted({ ...input, submittedAt: new Date().toISOString() }),
})

// 5. Wire it up.
import { defineModule, createApp } from "@nwire/forge"
import { httpInterface, endpoint } from "@nwire/http"
import { rest } from "@nwire/forge"

const submissions = defineModule("submissions", {
  actions: [submitAnswer],
  actors:  [Submission],
  events:  [AnswerWasSubmitted],
})

const app = await createApp({
  modules: [submissions],
})

await httpInterface({ prefix: "/api", port: 3000 })
  .wire(app)
  .run()

What's new vs L3

PrimitivePurpose
defineEventPast-tense fact your domain cares about. Strongly typed, schema-validated.
defineSchemaData shape + lifecycle states + storage hints, declared once.
defineActorState machine bound to one entity. Handlers never touch state directly; state moves through on / assign / target.
defineActionUser-visible command that emits one or more events. persona, journeyStep, slo are Studio metadata.
defineProjectionRead model materialized from events. Cross-cutting; one event can feed N projections.
defineQueryRead entrypoint; takes a projection key, returns a typed result.
defineWorkflowMulti-step process (saga) with timers and correlation.
defineModuleBounded context — actions + actors + events + projections grouped under one name.
createAppComposes modules + plugins + transports + container into a runtime.

What you get free at L4

  • Studio (pnpm dlx @nwire/studio) — live event stream, EventStorming canvas with Play Trace, dispatch UI, actor browser.
  • Telemetry — one tagged-union stream of 22 kinds; one OTel plugin ships every span to your backend.
  • Test harnessharness({ app }) boots the whole thing in-process with in-memory adapters.
  • Topology — same modules run as a monolith or N split services. Switch one string in the topology manifest.
  • OpenAPI — actions wired via httpInterface().wire(app) show up in /openapi.json automatically.

When you've outgrown L4

You haven't. L4 is the top of the ladder. If something feels missing, look in:

MIT licensed.