Migrating from NestJS
NestJS and Nwire share a surprising amount: a bounded context as the unit of composition, dependency injection, TS-first ergonomics (NestJS via decorators, Nwire via functions). The shift is from class-based controllers/services to declarative handlers and events — and where NestJS has modules, Nwire has apps composed with appCompose.
Translation table
| NestJS | Nwire |
|---|---|
@Controller("users") | app.wire(post("/users", { body }), action) |
@Post() @UsePipes(ValidationPipe) + DTO | defineAction({ schema }) (zod, validation built in) |
UsersService.createUser() | defineAction({ handler }) — the action IS the handler |
| Service-injects-service | ctx.execute(otherAction, input) (envelope propagates) |
@Injectable() @Inject() | definePlugin("name", ({bind}) => bind("svc", ...)); ctx.resolve("svc") |
@Module({ providers, imports }) | Each bounded context is its own createApp(...); compose via appCompose(...) |
@CommandHandler (CQRS package) | defineAction({ handler }) |
@EventHandler (CQRS package) | defineWorkflow(({when, send}) => when(Event, ...)) |
@Saga() | defineWorkflow with data + states for stateful sagas |
@Cron() | defineCron({ schedule, dispatches }) + createCronWire() |
| WebSocket gateway | @nwire/ws (early) or Express ws + dispatcher |
| Microservice transport | Split topology + @nwire/nats / @nwire/amqp |
The biggest shift
NestJS gives you classes; Nwire gives you data. A NestJS UsersController becomes:
// NestJS:
@Controller("users")
class UsersController {
constructor(private users: UsersService) {}
@Post()
@UsePipes(new ValidationPipe())
async create(@Body() dto: CreateUserDto) {
return this.users.create(dto)
}
}// Nwire:
const createUser = defineAction({
name: "users.create",
input: CreateUserSchema, // zod, replaces DTO + ValidationPipe
emits: [UserCreatedEvent],
handler: async (ctx) => {
const { input } = ctx
return UserCreated({ ...input, id: ctx.resolve("idGenerator").next() })
},
})
// Wire on the App — no controller class.
app.wire(
post("/users", { body: createUser.input }),
createUser,
)The handler IS the controller method. The action IS the contract. No class needed.
Dependency injection
NestJS uses tsyringe-style reflection. Nwire uses an explicit DI container — same idea, no decorators:
// app boot:
import { createApp, definePlugin } from "@nwire/app"
const app = createApp({
appName: "my-app",
plugins: [
definePlugin("services", ({ bind }) => {
bind("db", mongoClient)
bind("idGenerator", new IdGenerator())
bind("mailer", new MailerService())
}),
// ...forgePlugins(), etc.
],
handlers: [createUser],
})
// the action carries its handler — register it via createApp({ handlers }):
const createUser = defineAction({
name: "users.create",
input: CreateUserInput,
handler: async (ctx) => {
const { input } = ctx
const ids = ctx.resolve<IdGenerator>("idGenerator")
const mailer = ctx.resolve<MailerService>("mailer")
await mailer.send({ to: input.email, template: "welcome" })
return UserCreated({ ...input, id: ids.next() })
},
})For more advanced wiring (Awilix-style PROXY injection, lifetime scopes), the @nwire/container/awilix sub-path drops in:
import { createAwilixContainer } from "@nwire/container/awilix"
const container = createAwilixContainer({ injectionMode: "PROXY" })
container.register("db", mongoClient)
// pass `container` to createApp({ container })For most cases (stateless utilities, stores), you don't need DI — pass deps as factory args. DI helps when you have a tree of services that share state.
CQRS — built-in, not a separate package
NestJS's @nestjs/cqrs is a separate library: CommandHandler / EventHandler / Saga / QueryHandler. In Nwire the canonical shape IS CQRS:
| NestJS CQRS | Nwire |
|---|---|
ICommand + @CommandHandler | defineAction({ handler }) — one primitive |
IEvent + @EventHandler | defineEvent + when(Event, fn) |
Saga (Observable<ICommand>) | reaction that ctx.execute() |
IQuery + @QueryHandler | defineQuery(projection, ...) |
The wins: built-in actor state machines (NestJS CQRS has no equivalent), declared emits/dispatches intent (drives Studio), telemetry stream (NestJS has nothing comparable).
Microservices → split topology
NestJS's microservices transports (TCP, Redis, NATS, RMQ, gRPC) map to Nwire's bus adapters. Each service is its own wire file that imports the shared modules and wires NATS for cross-service event flow:
// apps/users/main.ts
import { endpoint } from "@nwire/endpoint";
import { httpKoa } from "@nwire/koa";
import { natsBus } from "@nwire/nats";
import { buildUsersApp } from "../packages/users";
const app = buildUsersApp({
bus: natsBus({ servers: process.env.NATS_URL!.split(",") }),
});
await app.start();
await endpoint("users", { port: 3001 })
.use(httpKoa())
.mount(app)
.run();Each service is its own App with its own container; same domain code, different processes. See Multi-transport and Cross-service events for the full pattern.
Pipes / Guards / Interceptors
| NestJS | Nwire |
|---|---|
ValidationPipe | zod schema on defineAction |
AuthGuard | policy tag + @nwire/auth |
LoggingInterceptor | @nwire/observability plugin |
CacheInterceptor | cacheable: true on defineQuery (read-side only) |
| Custom interceptor | runtime.use(middleware) |
What Nwire doesn't have (yet)
- WebSocket gateways — no first-class wire; run a separate ws server
- GraphQL — no
@nwire/graphqlwire; use Apollo + dispatch into the runtime @Injectable()reflection — explicit container instead- Module hot-reload —
nwire devhot-reloads file changes; there are no classes to re-instantiate, so there's nothing to restart for
Migrating a module
For each NestJS module:
- Identify the bounded context. Most NestJS modules already map to one BC.
- Promote each controller method to an
defineAction. Decorators become fields. - Demote each service class into pure functions. State that lived in instance vars goes to
defineActor(if it's per-entity state) ordefineProjection(if it's a read view) or a DI-resolved factory (if it's stateless utility). - Convert
@EventHandlertowhen(). Adddispatchesfor declared intent. - Convert
@Sagato reactions that chain viactx.execute. - Convert external calls to
defineExternalCall. - Wire HTTP routes on the App via
app.wire(binding, action). - Write the test harness —
harness({ app })from@nwire/test-kitreplaces Nest'sTest.createTestingModule.
Side-by-side run
NestJS uses Express under the hood. Mount Nwire wires as an Express Router on Nest's underlying Express instance via nwireToExpressRouter:
import { NestFactory } from "@nestjs/core"
import { NestExpressApplication } from "@nestjs/platform-express"
import { createApp } from "@nwire/app"
import { nwireToExpressRouter } from "@nwire/express"
import { AppModule } from "./app.module"
const nestApp = await NestFactory.create<NestExpressApplication>(AppModule)
const nwireApp = createApp({ appName: "v2" })
// ... nwireApp.wire(...) for the migrated routes ...
await nwireApp.start()
const expressInstance = nestApp.getHttpAdapter().getInstance()
expressInstance.use("/api/v2", nwireToExpressRouter(nwireApp))
await nestApp.listen(3000)/api/v2/* goes to Nwire; everything else stays on NestJS. Migrate one module at a time. See examples/nest-interop for the working end-to-end pattern.
See also
- Why Nwire — the philosophy
- Your first app — the smallest working program
- Middleware + plugins — what guards / interceptors map to
- NestJS samples — the cats and auth-jwt samples ported, in the repo