Skip to content

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

NestJSNwire
@Controller("users")defineRoute({ "POST /users": createUser })
@Post() @UsePipes(ValidationPipe) + DTOdefineAction({ schema }) (zod, validation built in)
UsersService.createUser()defineHandler(createUser, fn) or inline handler
Service-injects-servicectx.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 transportSplit topology + @nwire/bus-nats

The biggest shift

NestJS gives you classes; Nwire gives you data. A NestJS UsersController becomes:

ts
// NestJS:
@Controller("users")
class UsersController {
  constructor(private users: UsersService) {}
  @Post()
  @UsePipes(new ValidationPipe())
  async create(@Body() dto: CreateUserDto) {
    return this.users.create(dto)
  }
}
ts
// 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:

ts
// 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 CQRSNwire
ICommand + @CommandHandlerdefineAction + defineHandler
IEvent + @EventHandlerdefineEvent + when(Event, fn)
Saga (Observable<ICommand>)reaction that ctx.request()
IQuery + @QueryHandlerdefineQuery(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:

ts
// 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 TopologyManifest

See the Multi-service guide for the full pattern.

Pipes / Guards / Interceptors

NestJSNwire
ValidationPipezod schema on defineAction
AuthGuardpolicy tag + @nwire/auth
LoggingInterceptor@nwire/observability plugin
CacheInterceptorcacheable: true on defineQuery (read-side only)
Custom interceptorruntime.use(middleware)

What Nwire doesn't have (yet)

  • WebSocket gateways — no first-class wire; run a separate ws server
  • GraphQL — no @nwire/graphql wire; use Apollo + dispatch into the runtime
  • @Injectable() reflection — explicit container instead
  • Module hot-reload — vite-node --watch handles file changes but not class re-instantiation; restart on .module.ts changes

Migrating a module

For each NestJS module:

  1. Identify the bounded context. Most NestJS modules already map to one BC.
  2. Promote each controller method to an defineAction. Decorators become fields.
  3. Demote each service class into pure functions. State that lived in instance vars goes to defineActor (if it's per-entity state) or defineProjection (if it's a read view) or a DI-resolved factory (if it's stateless utility).
  4. Convert @EventHandler to when(). Add dispatches for declared intent.
  5. Convert @Saga to reactions that chain via ctx.request.
  6. Convert external calls to defineExternalCall.
  7. Wire HTTP routes via defineRoute({ ... }).
  8. Write the test harnessharness({ app }) replaces Nest's Test.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.

ts
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

MIT licensed.