Skip to content

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.
js
// Express:
app.post("/submissions", auth, validate(submitSchema), async (req, res) => {
  const result = await submissionService.create(req.body, req.user)
  res.status(201).json(result)
})
ts
// 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:

ts
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 /usersusers.create action
  • POST /users/:id/verify-emailusers.verify-email action
  • GET /users/:idusers.get query
  • GET /usersusers.list query

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.createUserCreatedEvent
  • users.verify-emailUserEmailVerifiedEvent
  • orders.placeOrderPlacedEvent, maybe OrderConfirmedEvent if 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:

ts
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:

ts
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():

ts
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:

ts
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

ts
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:

ts
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

MIT licensed.