Migrating from Express
Express is a HTTP server. Nwire is a domain framework that has a HTTP wire. Migration is more about reshaping how you think than rewriting. This guide walks through the conceptual translation, then the mechanical steps.
The conceptual shift
Express thinks in routes. Nwire thinks in actions.
// Express:
app.post("/submissions", auth, validate(submitSchema), async (req, res) => {
const result = await submissionService.create(req.body, req.user)
res.status(201).json(result)
})// Nwire:
const submitAnswer = defineAction({
name: "submissions.submit-answer",
schema: submitSchema, // zod, same shape — validation is free
policy: "student-self", // authz hook
emits: [AnswerSubmittedEvent],
handler: async (input, ctx) =>
AnswerSubmitted({ ...input, studentId: ctx.envelope.userId, submittedAt: now() }),
})
defineModule("submissions", {
actions: [submitAnswer],
routes: defineRoute({ "POST /submissions": submitAnswer }),
})The HTTP route is just one transport. The same action also runs via the queue worker, the CLI, and the dispatch UI in Studio. You wrote the business operation once; the framework gave it three calling conventions.
Express threads req everywhere. Nwire threads ctx.envelope.
ctx.envelope carries messageId, correlationId, causationId, tenant, userId, timestamp, version. Set once at the wire (from x-tenant-id / auth middleware / etc.), threaded through every nested call automatically. No more pass req down 8 layers.
ctx.logger is already scoped to the envelope — every log line carries the ids without you remembering.
Express has middleware. Nwire has DispatchMiddleware + plugins.
Middleware in Nwire is around action dispatch, not HTTP requests:
runtime.use(async (next, action, input, ctx) => {
const start = performance.now()
try {
return await next()
} finally {
ctx.logger.info("dispatched", { action: action.name, durationMs: performance.now() - start })
}
})The pre-built ones: @nwire/observability (tracing), @nwire/auth (authz). Plus the canonical telemetry stream (runtime.onTelemetry) for read-only observation.
Mechanical migration
1. Identify your bounded contexts
If your Express app already has folders like controllers/users/, controllers/orders/, controllers/billing/ — each is a candidate module. The rule: one BC = one module. Domains shouldn't leak.
2. For each controller, identify the actions
POST /users→users.createactionPOST /users/:id/verify-email→users.verify-emailactionGET /users/:id→users.getqueryGET /users→users.listquery
Notice: GETs become queries (read projections). POSTs / PUTs / DELETEs become actions (commands that emit events).
3. Identify events
For each action, what happened? Past tense. Granular.
users.create→UserCreatedEventusers.verify-email→UserEmailVerifiedEventorders.place→OrderPlacedEvent, maybeOrderConfirmedEventif it's two-step
Don't combine: OrderPlacedOrCancelledEvent is a smell.
4. Identify aggregates
What state needs invariant enforcement? "Only confirmed orders can be shipped." "Can't refund a refunded payment." Those are state machines. Each gets an actor:
const Order = defineActor("order", {
schema: OrderData,
key: "orderId",
initial: "placed",
states: {
placed: { on: { [OrderConfirmedEvent.name]: { target: "confirmed", assign: ... } } },
confirmed: { on: { [OrderShippedEvent.name]: { target: "shipped", assign: ... } } },
shipped: { final: true },
},
})Aggregates that don't have meaningful state transitions can be skipped — just fold the events into a projection.
5. Identify projections + queries
Per-entity views: users-by-id, orders-by-customer, articles-by-tag. Each is a projection folding the relevant events. Queries read them:
const articlesByTag = defineQuery(ArticlesByTag, {
name: "articles.by-tag",
schema: z.object({ tag: z.string(), limit: z.number().default(20) }),
execute: (state, { tag, limit }) =>
(state.byTag[tag] ?? []).slice(0, limit),
})6. Identify reactions
Cross-cutting policies. "When a user verifies their email, send a welcome notification." "When a payment fails, retry the next day." Each is a when():
const sendWelcomeOnVerify = when(
UserEmailVerifiedEvent,
async (event, ctx) => ctx.send(sendNotification, { to: event.email, template: "welcome" }),
{ dispatches: [sendNotification] },
)7. External boundaries → orchestrator primitives
Every fetch(...) / SDK call to an external service → defineExternalCall:
const chargeStripe = defineExternalCall({
name: "stripe.charge",
target: { provider: "stripe", endpoint: "/v1/payment_intents" },
request: ChargeRequest,
response: PaymentIntent,
idempotencyKey: (req) => `charge-${req.orderId}-${req.amount}`,
slo: { p95LatencyMs: 600, successRate: 0.995 },
retry: { max: 3, backoff: "exponential" },
})Webhooks → defineInboundWebhook. Crons → defineCron. Background queues → defineQueueWorker (@nwire/queue + adapter).
8. Wire it up
const usersModule = defineModule("users", {
actions: [createUser, verifyEmail],
events: [UserCreatedEvent, UserEmailVerifiedEvent],
actors: [User],
projections: [UsersById],
queries: [getUserById, listUsers],
reactions: [sendWelcomeOnVerify],
externalCalls: [chargeStripe],
routes: defineRoute({
"POST /users": createUser,
"POST /users/:id/verify-email": verifyEmail,
"GET /users/:id": getUserById,
"GET /users": listUsers,
}),
})
const myApp = defineApp("api", { modules: [usersModule, /* … */] })
await httpInterface({
port: 3000,
inspect: true,
openapi: { info: { title: "My API", version: "1.0.0" } },
})
.wire(myApp)
.run()Patterns Express devs ask about
"Where does my error-handling middleware go?"
Errors thrown by handlers go through Nwire's retry+DLQ pipeline. The HTTP wire returns a 4xx/5xx envelope automatically — you don't write a catch. For custom error shaping, use @nwire/errors + the wire's envelopeFromRequest hook.
"Where do my interceptors / before-each hooks go?"
runtime.use(middleware). Runs around every dispatch (HTTP, queue, CLI). For HTTP-only concerns (CORS, rate limiting, etc.) use Koa middleware on the wire's koa instance.
"What about WebSockets?"
Today Nwire doesn't ship a WebSocket wire. Run a separate ws server, dispatch into the Nwire runtime from ws message handlers. A first-class wire is on the roadmap.
"What about file uploads?"
Standard Koa body parser handles multipart. Files become inputs to actions like any other field. For large uploads + storage, use defineExternalCall to S3/GCS — Studio surfaces the upload as a boundary sticky.
"How do I migrate incrementally?"
Run Nwire side-by-side with Express:
import express from "express"
import { httpInterface } from "@nwire/http"
const express_app = express()
const nwire = await httpInterface({ port: 0 /* internal */ })
.wire(partialApp)
.compile()
express_app.use("/api/v2", (req, res, next) => nwire.koa.callback()(req, res))
express_app.listen(3000)/api/v2/* goes to Nwire; everything else stays on Express. Migrate one controller at a time. Once Express is empty, drop the wrapper.
See also
- Concepts → Why Nwire — the philosophy in long form
- Five-minute tour — every primitive
- Recipes → Express boilerplate — hagopj13's boilerplate ported to Nwire (coming)