The principle
Everything in Nwire follows from one rule:
Every primitive is a plain value. Lifecycle is events. Composition is by reference.
Read it once. The whole framework falls out.
What it means
Plain value. defineAction(...), defineEvent(...), defineActor(...), defineWorkflow(...), defineProjection(...) — none of these have side effects at definition time. They produce immutable objects. No globals are mutated, no registries are populated. You can console.log them; you can put them in arrays; you can compose them anywhere.
Lifecycle is events. Boot, mount, ready, shutdown, action-dispatched, error-thrown — all of these are framework hooks. A plugin that wants to react to boot uses its ctx on: definePlugin("…", ({ on }) => on("AppBooted", …)). A listener that reacts to a domain fact uses when(PostWasApproved, …). Same closure shape, same mental model — on for hooks, when for events.
Composition is by reference. An app registers its primitives through its registry and installs plugins. forgePlugins(...) carries infrastructure only — each forge plugin reads the runtime by kind for the actors, projections, and workflows the registry holds. Multi-app apps compose via appCompose(...). None of this happens through registration side effects — you pass createApp({ registry, plugins: [...] }), and the framework wires the world at boot.
Why it matters
These three rules turn into automatic answers to every design question:
| Question | Answer |
|---|---|
| Where does the handler live? | Inside the action by default (co-located value); outside via plugin override. |
| How does a plugin extend the framework? | bind(name, factory) for capabilities; on("AppBooted", handler) (plugin ctx) for lifecycle. |
| What's the difference between a workflow and a framework hook? | A workflow reacts to domain events via when(...) inside defineWorkflow(...); a plugin observes lifecycle hooks via on(...). Events use when, hooks use on. |
| How do packages compose? | Each ships a buildApp() factory; consumers call appCompose(...) and mount the result. |
| How do I override package behavior? | Pass overrides to the package's build*App({ ... }) factory; the package decides what to expose. |
| How do I test with mocked dependencies? | Pass a mocked binding via the factory or wrap a plugin around definePlugin("override", ({bind}) => bind("db", mockDb)). |
When a design question has a clean answer because of a single rule, the rule is load-bearing. This is that rule.
The Tense Convention
A single naming convention encodes hook semantics in the event name:
| Suffix | Tense | Dispatch mode | Can prevent? | Can mutate? | Example |
|---|---|---|---|---|---|
*-ing | present participle | series-bail (sequential) | yes — return false | yes — mutate event payload | ActionDispatching, AppBooting, EventRecording |
*-ed | past participle | parallel (Promise.allSettled) | no — already happened | no — fact is immutable | ActionCompleted, AppReady, EventRecorded |
You read the event name; you know how it behaves. No { interceptable: true } flag to look up in docs. No surprise about whether your handler can block the operation. The grammar IS the contract.
A third mode exists for the rare case where you need sequential execution but can't bail: series. Throw to fail the chain; otherwise everything runs in order. Use it when ordering matters but you have no veto power (e.g., DB-migration steps).
Three pieces, repeated everywhere
Every primitive in Nwire is built from the same three pieces:
- A typed value —
defineAction({...}),defineEvent({...}), etc. - react to something happening —
when(Event, handler)for domain events,on(hookName, fn)for framework hooks bind(name, factory)— make something available to handlers
That's it. Listeners and actors use when to react to events. Plugins use bind to register dependencies, and on to tap framework hooks. Apps compose plugins, handlers, and sub-apps with appCompose. The vocabulary doesn't grow with the system.
What it rules out
The principle is also a no-list:
- ❌ Decorator metadata (
@Inject,@Module) — those are registration side effects. - ❌ Magic resolution (Laravel-style typed parameter injection) — implicit, hard to trace.
- ❌ Service locators as the primary API — they hide dependencies in code; explicit
resolveonly when you need dynamic dispatch. - ❌ Separate hook system parallel to the event bus — same primitive, different events.
- ❌ Mutable global state (a
framework.register(...)call) — every composition is a new value.
If a proposed feature requires one of these, the answer is "we don't do that." Find another way to express the same need within the principle.
Where this lives
This page is the canonical reference. Every primitive's doc links back to it. When you find yourself asking "should Nwire do X?" — the test is whether X falls out of the principle naturally. If it does, ship it. If it requires a new concept that doesn't fit, the answer is usually no.
The principle is the framework's spine. The rest is implementation.