Skip to content

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

NestJSNwire
@Controller("users")app.wire(post("/users", { body }), action)
@Post() @UsePipes(ValidationPipe) + DTOdefineAction({ schema }) (zod, validation built in)
UsersService.createUser()defineAction({ handler }) — the action IS the handler
Service-injects-servicectx.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 transportSplit topology + @nwire/nats / @nwire/amqp

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",
  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:

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

ts
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 CQRSNwire
ICommand + @CommandHandlerdefineAction({ handler }) — one primitive
IEvent + @EventHandlerdefineEvent + when(Event, fn)
Saga (Observable<ICommand>)reaction that ctx.execute()
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. Each service is its own wire file that imports the shared modules and wires NATS for cross-service event flow:

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

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-reloadnwire dev hot-reloads file changes; there are no classes to re-instantiate, so there's nothing to restart for

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.execute.
  6. Convert external calls to defineExternalCall.
  7. Wire HTTP routes on the App via app.wire(binding, action).
  8. Write the test harnessharness({ app }) from @nwire/test-kit replaces Nest's Test.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:

ts
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

MIT licensed.