Migrating from NestJS
NestJS and Nwire share a surprising amount: modules as the unit of composition, dependency injection, decorator-free TS-first ergonomics (NestJS via decorators, Nwire via functions). The shift is from class-based controllers/services to declarative actions and events.
Translation table
| NestJS | Nwire |
|---|---|
@Controller("users") | defineRoute({ "POST /users": createUser }) |
@Post() @UsePipes(ValidationPipe) + DTO | defineAction({ schema }) (zod, validation built in) |
UsersService.createUser() | defineHandler(createUser, fn) or inline handler |
| Service-injects-service | ctx.request(otherAction, input) or shared modules |
@Injectable() @Inject() | @nwire/container-awilix for DI; ctx.resolve("name") |
@Module({ providers, imports }) | defineModule(name, { actions, actors, events, … }) |
@CommandHandler (CQRS package) | defineHandler(action, fn) |
@EventHandler (CQRS package) | when(Event, fn, { dispatches }) |
@Saga() | Reactions that ctx.request() further actions |
@Cron() | defineCron({ schedule, dispatches: action }) |
| WebSocket gateway | (not yet — coming) |
| Microservice transport | Split topology + @nwire/bus-nats |
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",
schema: CreateUserSchema, // zod, replaces DTO + ValidationPipe
emits: [UserCreatedEvent],
handler: async (input, ctx) => UserCreated({ ...input, id: ctx.resolve("idGenerator").next() }),
})
defineModule("users", {
actions: [createUser],
routes: defineRoute({ "POST /users": 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 { createContainer, asClass, asValue } from "awilix"
import { Awilix } from "@nwire/container-awilix"
const container = new Awilix(createContainer({ injectionMode: "PROXY" })
.register({
db: asValue(mongoClient),
idGenerator: asClass(IdGenerator).singleton(),
mailer: asClass(MailerService).singleton(),
}))
const app = createApp({ container, modules: [...] })
// in a handler:
defineHandler(createUser, async (input, ctx) => {
const ids = ctx.resolve("idGenerator")
const mailer = ctx.resolve("mailer")
await mailer.send({ to: input.email, template: "welcome" })
return UserCreated({ ...input, id: ids.next() })
})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 + defineHandler |
IEvent + @EventHandler | defineEvent + when(Event, fn) |
Saga (Observable<ICommand>) | reaction that ctx.request() |
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 + split topology:
// NestJS: per-service main.ts with createMicroservice(...)
// Nwire: one topology manifest with topology="split"
export default {
apps: ["users", "billing", "notifications"],
topology: "split",
providers: { bus: "nats" },
publishToBus: true,
transport: { http: { port: 3000, splitBasePort: 3000 } },
} satisfies TopologyManifestSee the Multi-service guide 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 — vite-node
--watchhandles file changes but not class re-instantiation; restart on .module.ts changes
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.request. - Convert external calls to
defineExternalCall. - Wire HTTP routes via
defineRoute({ ... }). - Write the test harness —
harness({ app })replaces Nest'sTest.createTestingModule.
Side-by-side run
Same pattern as Express — mount Nwire's Koa under a path in your NestJS app, migrate one module at a time, retire NestJS when empty.
const nestApp = await NestFactory.create(AppModule)
const nwire = await httpInterface({ port: 0 })
.wire(partialApp)
.compile()
nestApp.use("/api/v2", nwire.koa.callback())
await nestApp.listen(3000)See also
- Concepts → Why Nwire — the philosophy
- Five-minute tour
- @nwire/container — DI surface
- Recipes → NestJS samples — sample apps ported