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/messagesThe 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
| Primitive | Purpose |
|---|---|
defineEvent | Past-tense fact your domain cares about. Strongly typed, schema-validated. |
defineSchema | Data shape + lifecycle states + storage hints, declared once. |
defineActor | State machine bound to one entity. Handlers never touch state directly; state moves through on / assign / target. |
defineAction | User-visible command that emits one or more events. persona, journeyStep, slo are Studio metadata. |
defineProjection | Read model materialized from events. Cross-cutting; one event can feed N projections. |
defineQuery | Read entrypoint; takes a projection key, returns a typed result. |
defineWorkflow | Multi-step process (saga) with timers and correlation. |
defineModule | Bounded context — actions + actors + events + projections grouped under one name. |
createApp | Composes 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 harness —
harness({ 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.jsonautomatically.
When you've outgrown L4
You haven't. L4 is the top of the ladder. If something feels missing, look in:
- Common recipes — auth, RBAC, multi-tenancy, multi-transport, cron, observability.
- Standalone packages — use one piece of L1-L3 inside a non-Nwire context.
- Under the hood — write a plugin, an adapter, or a new transport.