DEV Community

Cover image for Spring Boot to NestJS: A Mental Model for Java Developers
Gabriel Anhaia
Gabriel Anhaia

Posted on

Spring Boot to NestJS: A Mental Model for Java Developers


A Spring engineer joins a Node team. They open the new repo and the file tree looks suspicious: users.controller.ts, users.service.ts, users.module.ts. They click into the controller:

@Controller('users')
export class UsersController {
  constructor(private readonly users: UsersService) {}

  @Get(':id')
  show(@Param('id') id: string) {
    return this.users.findById(id);
  }
}
Enter fullscreen mode Exit fullscreen mode

That is Spring vocabulary. @Controller, constructor injection, a service collaborator. The Spring engineer reads twenty more lines and the muscle memory holds: @Injectable, @Module, @Get, @Post, @Body. NestJS picked the names on purpose.

Then the analogy breaks. The module file lists every provider by hand. There is no classpath scan. The decorators run at startup, not at compile time. And by the time a fix lands in production, an import from a CommonJS package has done something strange to the dependency graph that no Spring container would do.

NestJS is the one Node framework where a Java engineer's instincts are mostly right. A mental model makes the mostly-right parts cheap and the where-it-breaks parts visible. Current major is NestJS 11 (11.1.x as of April 2026). A v12 release with broader ESM support is on the project's public roadmap.

Spring to NestJS, term by term

Read the right column as the nearest equivalent. It is not a rename. The differences in the next section matter.

Spring NestJS What it is
@Component / @Service / @Repository @Injectable() A class the container can construct and inject
@Controller @Controller(path) HTTP entry point
@Autowired constructor parameter Dependency injection
@Qualifier / @Primary @Inject(TOKEN) Pick one of N implementations
ApplicationContext Module system Wiring graph
@Configuration + @Bean factory provider in a Module Programmatic provider
@ConfigurationProperties ConfigModule + ConfigService Typed config
Spring AOP / aspects Interceptor + Guard + Pipe + ExceptionFilter Cross-cutting concerns
Spring Data JPA TypeORM / Drizzle / Prisma Persistence
@Valid + JSR-380 ValidationPipe + class-validator (or Zod via a pipe) Input validation
@Scheduled @nestjs/schedule @Cron Cron jobs
Spring Security Guard + Passport strategies via @nestjs/passport Auth
WebApplicationContext per request REQUEST-scoped providers Per-request beans
application.yml profiles NODE_ENV + per-env .env Environments
Micrometer @nestjs/terminus + OpenTelemetry SDK Metrics & health

If a Spring concept is not on this list, it probably has a translation, just one that requires more code on the NestJS side. Four interesting ones land in the next section: modules, AOP, validation, persistence.

A small Spring controller, line by line in NestJS

Take a tiny endpoint: GET /users/{id} returns the user, POST /users creates one. Spring first.

@RestController
@RequestMapping("/users")
public class UsersController {

    private final UsersService users;

    public UsersController(UsersService users) {
        this.users = users;
    }

    @GetMapping("/{id}")
    public UserDto show(@PathVariable String id) {
        return users.findById(id);
    }

    @PostMapping
    public ResponseEntity<UserDto> create(
            @Valid @RequestBody CreateUserRequest body) {
        UserDto created = users.create(body);
        return ResponseEntity
            .status(HttpStatus.CREATED)
            .body(created);
    }
}
Enter fullscreen mode Exit fullscreen mode
@Service
public class UsersService {

    private final UsersRepository repo;

    public UsersService(UsersRepository repo) {
        this.repo = repo;
    }

    public UserDto findById(String id) {
        return repo.findById(id)
            .map(UserDto::from)
            .orElseThrow(() -> new NotFoundException(id));
    }

    public UserDto create(CreateUserRequest req) {
        UserEntity saved = repo.save(UserEntity.from(req));
        return UserDto.from(saved);
    }
}
Enter fullscreen mode Exit fullscreen mode

The NestJS port:

import {
  Body, Controller, Get, HttpCode, HttpStatus,
  NotFoundException, Param, Post,
} from '@nestjs/common';
import { UsersService } from './users.service';
import { CreateUserDto } from './dto/create-user.dto';

@Controller('users')
export class UsersController {
  constructor(private readonly users: UsersService) {}

  @Get(':id')
  show(@Param('id') id: string) {
    return this.users.findById(id);
  }

  @Post()
  @HttpCode(HttpStatus.CREATED)
  create(@Body() body: CreateUserDto) {
    return this.users.create(body);
  }
}
Enter fullscreen mode Exit fullscreen mode
import { Injectable, NotFoundException } from '@nestjs/common';
import { UsersRepository } from './users.repository';
import { CreateUserDto } from './dto/create-user.dto';

@Injectable()
export class UsersService {
  constructor(private readonly repo: UsersRepository) {}

  async findById(id: string) {
    const user = await this.repo.findById(id);
    if (!user) throw new NotFoundException(`user ${id}`);
    return user;
  }

  create(dto: CreateUserDto) {
    return this.repo.save(dto);
  }
}
Enter fullscreen mode Exit fullscreen mode

Walk it line by line. @RestController collapses into @Controller (every NestJS controller is REST by default; render templates are an opt-in). @RequestMapping("/users") is the argument to @Controller. @GetMapping("/{id}") becomes @Get(':id') and the path-variable syntax shifts from braces to colons because that is the path-to-regexp shape Express uses.

@PathVariable and @RequestBody become @Param and @Body. ResponseEntity.status(...) collapses into the @HttpCode decorator plus a plain return. NestJS serializes whatever the handler returns and infers the success status from the HTTP method (201 for POST, 200 for the rest).

The service is more boring. @Service becomes @Injectable(). The constructor is the same idea with a different language: TypeScript's private readonly shorthand assigns and types the field in one move, the way Lombok's @RequiredArgsConstructor does.

So far, easy. Now the parts that look the same and aren't.

Where the analogy breaks (four places)

1. There is no classpath scan. Modules are explicit.

Spring's @ComponentScan walks your classpath, finds every @Component / @Service / @Repository, and registers them. You add a class, you do not edit any wiring. The container finds it.

NestJS does not do that. Every provider has to be listed in a Module:

@Module({
  imports: [DatabaseModule],
  controllers: [UsersController],
  providers: [UsersService, UsersRepository],
  exports: [UsersService],
})
export class UsersModule {}
Enter fullscreen mode Exit fullscreen mode

Add a service, edit the module. Forget to list it, you get a runtime error at boot: Nest can't resolve dependencies of UsersController (?). Please make sure that the argument UsersService at index [0] is available in the UsersModule context. That message is the new "404 on a route you forgot to register".

The upside is that the dependency graph is in source. You can read a Module and see exactly what is wired and what is exported to the rest of the app. There is no startup-time surprise where a classpath-scanned library auto-registered itself. The downside is six lines of bookkeeping per feature module.

2. Decorator metadata is reflective at startup, not annotation processing at compile time.

Spring's annotations are partly compile-time (the annotation processor generates code), partly runtime reflection over class files. The container walks classes once at boot and builds the wiring. Any error that survives compilation usually shows up at startup.

NestJS decorators run in JavaScript at module-load time. They use reflect-metadata to read the constructor parameter types TypeScript emits when emitDecoratorMetadata is on. That has two consequences a Spring engineer should know:

  1. Decorators are TC39 Stage 3 in the language, but NestJS still uses the legacy decorators (the experimentalDecorators flag). The migration to standard decorators has been tracked for a long time on the project's issue tracker. The shipping advice for NestJS 11 in 2026 is still experimentalDecorators: true and emitDecoratorMetadata: true in tsconfig.json. Do not turn either off until NestJS announces it. The runtime DI still depends on the metadata that flag emits.
  2. Type-only injection does not work. If a dependency is imported with import type, the metadata is stripped at compile time and the container has nothing to look up. Use @Inject(SOME_TOKEN) with a string or symbol token when the constructor parameter is typed against an interface or abstract class with no runtime symbol. Spring's container has the bytecode; NestJS only has whatever survived the TypeScript erasure.

3. AOP becomes a small constellation, not one mechanism.

Spring AOP is one feature with many advice types. NestJS splits the same job across four primitives, each with a clearer scope:

  • Guard — answers "is this request allowed in?". Auth, role checks, feature flags. Returns true / false or throws.
  • Interceptor — wraps the handler. Logging, caching, transforming the response, tracing spans. Like an @Around aspect.
  • Pipe — transforms or validates a single argument. The famous one is ValidationPipe, which runs class-validator over the DTO.
  • ExceptionFilter — converts a thrown error into a response. Like Spring's @ControllerAdvice + @ExceptionHandler.

You apply them with decorators (@UseGuards, @UseInterceptors, @UsePipes, @UseFilters), globally in the bootstrap, on a controller, or on a single handler. The split takes a week to internalize; once you have it, "this is a guard, not an interceptor" becomes a useful design conversation.

4. Persistence is a choice, not a default.

Spring Data JPA gives you a repository interface and the framework writes the queries. NestJS does not pick a persistence layer. The three you'll see in 2026 are TypeORM, Drizzle, and Prisma. TypeORM is the one whose API copies JPA most directly (entities as classes, decorators on fields, a repository pattern). Drizzle is a thin SQL builder with a typed schema; closer to jOOQ in spirit. Prisma is its own thing. It has a separate schema file, a generated client, and a query API that is neither JPA nor SQL.

Pick one early. The temptation to "use whichever fits the feature" is the same temptation that produces a Spring app with three persistence styles, and the same answer applies: don't.

ESM/CJS interop matters in 2026

One Spring habit that does not translate. On the JVM, the classpath is a flat namespace. In Node, the module system has been split between CommonJS and ECMAScript Modules for years, and packages on npm now ship in different combinations.

NestJS 11 itself still ships CJS by default. Some libraries you'll pull in (newer OpenTelemetry packages, node-fetch v3, several test runners) are ESM-only. The mismatch shows up as:

Error [ERR_REQUIRE_ESM]: require() of ES Module ... is not supported.
Enter fullscreen mode Exit fullscreen mode

Three fixes, in order of cost:

  1. Pin the dependency to its last CJS-compatible major.
  2. Use a dynamic import() inside an async function. Node has supported dynamic import() from CommonJS for years.
  3. Migrate the project to ESM ("type": "module" in package.json, .mjs extensions or explicit ones in imports). NestJS supports it; see the official samples for the tsconfig and package.json shape.

NestJS has signaled broader ESM support for a future major. Until that lands, expect to do this dance once per non-trivial project.

Wire your first NestJS module today and the muscle memory transfers in an afternoon. The four breaks above are the only places it won't.


If this was useful

The Java to TypeScript move is full of patterns that look identical and aren't. Kotlin and Java to TypeScript — A Bridge for JVM Developers is the chapter-by-chapter version of this post: nullability, sealed classes to discriminated unions, generics and variance, coroutines mapped to async/await, Spring habits that survive the trip versus the ones that should be retired.

If you want the broader set, the five-book TypeScript Library collection covers the type system, the foundations, the JVM and PHP bridges, and production tooling in one place. Pick the bridge if you're coming from the JVM; add the type-system book once you're past the syntax; the production volume is for anyone shipping TS at work.

All five books ship in ebook, paperback, and hardcover.

The TypeScript Library — the 5-book collection

Top comments (0)