What Spring Boot and NestJS are, and why both are opinionated

Spring Boot is a layer on top of the Spring Framework for building stand-alone, production-grade applications that you can "just run." NestJS is a Node.js framework, written in TypeScript, for building server-side applications. Their runtimes and languages differ, but both are built around a dependency injection container that makes structural decisions for you. Both are opinionated in the same way: they decide how code is organised and wired so teams don't have to agree on it themselves.

Spring Boot expresses its opinions through starter dependencies and auto-configuration. When you add a web starter, Spring Boot detects it on the classpath, configures an embedded server, sets up JSON serialization and registers sensible defaults. You only write configuration when you need to override a default. Spring Boot generates no code and needs no XML.

NestJS expresses its opinions through structure. An application is a tree of modules. Each module declares its controllers, which handle requests, and its providers, which hold injectable services. Decorators such as @Module, @Controller and @Injectable give the container the metadata it needs to build that tree.

In practice the two look alike. A Spring @RestController receives a @Service through its constructor. A NestJS @Controller receives an @Injectable() provider the same way. In both, a mapped method returns the result. The framework features mostly match, so the real choice depends on runtime behaviour, ecosystem depth and what your team already knows.

// ---- Spring Boot (Java) ----
// The container finds @Service and @RestController via component scanning.
@Service
class GreetingService {
    String greet(String name) { return "Hello, " + name; }
}

@RestController
class GreetingController {
    private final GreetingService service;

    // Constructor injection: Spring supplies the GreetingService bean
    GreetingController(GreetingService service) { this.service = service; }

    @GetMapping("/greet/{name}")
    String greet(@PathVariable String name) { return service.greet(name); }
}

// ---- NestJS (TypeScript) ----
// @Injectable() marks the class as a provider the container can inject.
@Injectable()
export class GreetingService {
  greet(name: string): string { return `Hello, ${name}`; }
}

@Controller('greet')
export class GreetingController {
  // Constructor injection: Nest resolves GreetingService from the module
  constructor(private readonly greetingService: GreetingService) {}

  @Get(':name')
  greet(@Param('name') name: string): string { return this.greetingService.greet(name); }
}

// Unlike Spring's classpath scanning, Nest wires only what a module declares
@Module({ controllers: [GreetingController], providers: [GreetingService] })
export class AppModule {}

The same GET /greet/{name} endpoint in Spring Boot and NestJS, each with a controller that receives a service through constructor injection (imports omitted for brevity).

How dependency injection works in each framework

Spring builds one application context per application. Component scanning registers every @Component, @Service or @Configuration class under the base package, and constructor injection resolves dependencies by their Java or Kotlin type. Any bean in the context can be injected anywhere. Nothing stops a web controller from reaching into a persistence bean in an unrelated package, so boundaries depend on team convention.

NestJS makes those boundaries explicit. A provider is visible only inside its own module unless that module lists it in exports and the consuming module lists the module in imports.

Concern Spring Boot NestJS
Container Single application context Tree of modules
Visibility Global to the context Module-scoped, opt-in exports
Default scope Singleton Singleton
Request scope Proxy-injected request-scoped bean Scope.REQUEST, propagates to every dependent provider
Circular dependency Constructor cycles fail at context startup Must be broken with forwardRef() on both sides
Type resolution Class types available via reflection Emitted decorator metadata, or explicit @Inject() tokens

The type resolution row has the largest practical consequences. TypeScript erases types when it compiles, so NestJS reads constructor parameter types from metadata. That metadata is emitted only when emitDecoratorMetadata is enabled and the class has a decorator. Interfaces have no runtime form at all, so they need string or symbol tokens. The same erasure means request payloads are not checked against their declared types, which is why NestJS uses ValidationPipe to validate input at runtime. In Java and Kotlin, a constructor parameter's class is still there at runtime, so the container can resolve it without extra compiler settings.

Each approach has its own way of failing. In NestJS, a missing export or a misconfigured compiler shows up as an injection error at bootstrap or at first resolution. In Spring, wiring is usually correct, but conditional auto-configuration can quietly create or skip a bean depending on the classpath and properties. When that happens, the conditions evaluation report is often the only way to see why.

import { Body, Controller, Inject, Injectable, Module, Post } from '@nestjs/common';
import { IsString, Length } from 'class-validator';

// Interfaces are erased at compile time, so a symbol acts as the runtime injection token.
export interface PaymentGateway { charge(orderId: string): Promise<void>; }
export const PAYMENT_GATEWAY = Symbol('PAYMENT_GATEWAY');

@Injectable()
class StubGateway implements PaymentGateway {
  async charge(orderId: string): Promise<void> { /* call the payment provider */ }
}

@Module({
  providers: [{ provide: PAYMENT_GATEWAY, useClass: StubGateway }],
  exports: [PAYMENT_GATEWAY], // without this, importing modules cannot resolve the token
})
export class PaymentsModule {}

// Declared types are not checked at runtime; class-validator decorators are.
class CheckoutDto {
  @IsString() @Length(1, 64) orderId!: string;
}

@Controller('checkout')
class CheckoutController {
  // A class-typed parameter could be resolved from emitted metadata; an interface needs @Inject.
  constructor(@Inject(PAYMENT_GATEWAY) private readonly gateway: PaymentGateway) {}

  @Post()
  async checkout(@Body() dto: CheckoutDto): Promise<void> {
    await this.gateway.charge(dto.orderId);
  }
}

// Removing PaymentsModule from imports causes a runtime injection error at bootstrap.
@Module({ imports: [PaymentsModule], controllers: [CheckoutController] })
export class OrdersModule {}

// main.ts: app.useGlobalPipes(new ValidationPipe({ whitelist: true }));

A NestJS provider registered under a symbol token is exported from one module, imported by another, injected with @Inject() because interfaces have no runtime form, and its request input is checked by ValidationPipe.

JVM vs Node: request handling, blocking work and startup

A default Spring MVC application on an embedded servlet container assigns each request to a thread from a pool. A blocking JDBC call parks that thread while the others continue. Node runs your JavaScript on one event-loop thread. Network I/O is non-blocking, and libuv's small thread pool handles work that has no async OS primitive, such as file system access, DNS lookups and some crypto.

Thread-per-request (JVM)        Event loop (Node)

req1 -> [thread 1] --DB-->      req1 \
req2 -> [thread 2] --DB-->      req2 --> [event loop] --> callbacks
req3 -> [thread 3] --CPU-->     req3 /        |
                                         [libuv pool]

The event-loop model has a clear weakness. Any synchronous CPU work in a NestJS handler stalls every other request on that process. Common causes are large JSON parsing, image resizing and synchronous hashing. You can move that work to worker_threads or to a separate service behind a queue. To use more cores, you run several Node processes: cluster mode, multiple containers or more pods. A single JVM uses all cores on its own, and on recent Java versions Spring Boot can run handlers on virtual threads, which makes blocking calls cheaper.

JVM services have historically started slowly and used a lot of memory. Classloading, bytecode interpretation followed by JIT compilation, and Spring's reflection-heavy context setup all run at startup, and the heap and metaspace add to memory use. Spring Boot's AOT processing moves bean-definition work to build time. Compiling the result into a GraalVM native image shortens startup and lowers memory use substantially. The cost is longer builds and a closed-world assumption: reflection, proxies and resources must be known or declared up front.

THREAD-PER-REQUEST (JVM, Spring MVC)  EVENT LOOP (Node, NestJS)

+--------------------------------+    req1 --+
| servlet thread pool            |    req2 --+--> +-----------------+
|                                |    req3 --+    | event loop      |
| req1 -> [thread 1] -> DB wait  |                | (one JS thread) |
| req2 -> [thread 2] -> DB wait  |                +-----------------+
| req3 -> [thread 3] -> CPU work |                |      |       |
|                                |                v      |       v
| a blocked thread parks; the    |      non-blocking     |    +-------------+
| other threads keep serving     |      network I/O      |    | libuv pool  |
+--------------------------------+                       |    | fs, DNS,    |
                |                                        |    | some crypto |
                v                                        |    +-------------+
  one JVM process uses all cores                         v
                                             +------------------------+
                                             | CPU-bound work moved   |
                                             | to worker_threads or a |
                                             | separate service       |
                                             +------------------------+

sync CPU work left on the loop stalls every request in that process;
to use more cores, run more processes (cluster, containers, pods)

On the JVM each request holds its own thread, so a blocked call stalls only that thread. In Node every request shares one event-loop thread, which hands file system, DNS and some crypto work to the libuv pool, so CPU-bound work has to move to worker threads or a separate service.

Production readiness and ecosystem depth

Spring Boot documents its production features as part of the framework: "metrics, health checks, and externalized configuration" [1]. Adding the Actuator starter gives you /actuator/health and /actuator/metrics. Metrics go through Micrometer, which is also a Spring project. Configuration resolves from properties files, environment variables and profiles in a documented precedence order. Over HTTP, only health is exposed by default, so you widen exposure deliberately with management.endpoints.web.exposure.include=health,metrics.

A NestJS team builds the same baseline from separate pieces [2]. @nestjs/config handles environment loading. @nestjs/terminus provides a HealthCheckService, which you wire into a controller with indicators for the database, memory or downstream HTTP services. Metrics usually come from a community Prometheus client. The pieces work well, but your team chooses, versions and maintains each one.

Concern Spring Boot NestJS
Health Actuator, built in @nestjs/terminus
Config Built in @nestjs/config
Metrics Micrometer, first-party Community library
Data, security, messaging Spring Data, Security, Kafka, Integration, Batch, Cloud Official ORM/auth/microservice adapters, plus npm

The difference that matters is who owns compatibility. Spring Boot manages a curated set of dependency versions. A Boot upgrade therefore moves Data, Security and Kafka support together, and each part is tested against the others.

NestJS's official packages wrap third-party libraries that have their own maintainers and release schedules. Upgrading NestJS often means reconciling several independent changelogs.

Supply-chain review also scales differently. A typical npm tree includes many transitive packages from many small maintainers. Lockfile auditing and provenance checks are ongoing operational work, not a one-time setup step.

// Spring Boot side (application.yml), shown for comparison:
//   management:
//     endpoints:
//       web:
//         exposure:
//           include: health,metrics   # only health is exposed over HTTP by default
//     endpoint:
//       health:
//         show-details: when-authorized
// The Actuator starter provides /actuator/health and /actuator/metrics.

// NestJS side: assemble the same baseline from separate packages.
import { Controller, Get, Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { HttpModule } from '@nestjs/axios';
import {
  TerminusModule, HealthCheck, HealthCheckService,
  HttpHealthIndicator, MemoryHealthIndicator,
} from '@nestjs/terminus';

@Controller('health')
export class HealthController {
  constructor(
    private health: HealthCheckService,
    private http: HttpHealthIndicator,
    private memory: MemoryHealthIndicator,
  ) {}

  @Get()
  @HealthCheck()
  check() {
    return this.health.check([
      // Each indicator is chosen and wired explicitly by the team
      () => this.http.pingCheck('payments-api', ''),
      () => this.memory.checkHeap('heap', 300 * 1024 * 1024), // fail above ~300 MB
    ]);
  }
}

@Module({
  imports: [ConfigModule.forRoot({ isGlobal: true }), TerminusModule, HttpModule],
  controllers: [HealthController],
})
export class HealthModule {} // metrics still need a separate Prometheus client

Spring Boot exposes Actuator health and metrics through a few lines of configuration, while a NestJS app combines @nestjs/config, @nestjs/terminus and @nestjs/axios to get a comparable health endpoint, and still needs a separate library for metrics.

Packaging, deployment and CI pipelines

For Spring Boot, the build produces one artifact. Maven or Gradle packages the application, its dependencies and an embedded server (Tomcat, Jetty or Undertow) into an executable JAR that runs with java -jar. A NestJS build produces compiled JavaScript in dist/. At runtime that output also needs a node_modules tree and a Node binary. The deployable unit is a directory and a runtime, not a single file.

In containers, both setups use multi-stage builds, for different reasons. The Node Dockerfile runs npm ci and the TypeScript compile in a builder stage. A second dependency install with npm ci --omit=dev keeps compilers, test runners and type definitions out of the final image. The runtime stage copies only dist/, the production node_modules and package.json. The Spring Boot Dockerfile builds the JAR with a full JDK and then copies it onto a JRE-only base image, so the runtime image carries no compiler or build tool.

Caching depends on layer order. In the Node image, copy the lockfile and install dependencies before copying source, so code changes don't invalidate the npm ci layer. Spring Boot can split the JAR into layers, with dependencies separate from application classes. A typical commit then rebuilds only a small top layer instead of the whole multi-megabyte JAR. In CI, also cache ~/.m2 or the Gradle cache, or the npm cache, between runs.

GraalVM native images change this trade-off. They start faster and use less memory, and the binary can run on a minimal base image. However, ahead-of-time compilation is CPU- and memory-intensive and much slower than a JAR build. Many teams therefore run native builds only on release branches rather than on every pull request.

Impact on development cost and engineering time

Most of the cost of either framework comes from people, not from the runtime. A team fluent in Java will ship and debug Spring Boot faster than it will learn NestJS's module system and Node's event loop, and the reverse holds for a TypeScript team. Language expertise usually outweighs any difference in throughput or memory.

Where each framework saves time

Spring Boot saves time through what it already includes. Health checks, metrics, externalized configuration and first-party integrations for security, data access and messaging mean less glue code and fewer third-party libraries to evaluate and maintain. NestJS saves time by letting frontend and backend engineers share TypeScript skills, types and tooling, and by giving a fast inner loop where restarts take seconds.

Where the cost accumulates

Spring Boot major-version upgrades can involve package namespace changes, dependency realignment and renamed configuration properties, repeated across every service. npm upgrades are spread across many small packages that have to be moved together.

Illustrative estimate (assumed numbers, not a benchmark): migration effort = services × hours per service. With 30 services at an assumed 12 hours each, that is 360 engineer-hours, or about nine engineer-weeks at 40 hours a week. Replace both inputs with figures from your own fleet.

When both are too much

For a small webhook receiver or a single-endpoint service, modules, decorators and starters add more structure than the code needs. Consider Express or Fastify on Node, or Micronaut or Quarkus on the JVM.

Decision checklist: when to use each and when to avoid them

Once you've ruled out the edge cases, the choice comes down to four inputs: what your team already knows, what kind of work the service does, which integrations it needs, and how it runs in production.

Factor Favors Spring Boot Favors NestJS
Team skills Java or Kotlin experience, JVM tuning knowledge TypeScript experience, shared code with a frontend
Workload shape CPU-heavy work, parallel computation I/O-bound work: proxying, fan-out calls, event handling
Integrations Messaging, batch jobs, LDAP, complex security rules HTTP, GraphQL, common queues through community packages
Operational limits Long-running services where warm-up cost is spread over time Tight memory budgets, frequent scale-to-zero

Avoid Spring Boot for tiny functions and short-lived jobs. JVM startup and memory overhead can take up most of the cost of a run that only lasts seconds. Ahead-of-time native compilation reduces that overhead, but it adds build complexity. Also avoid it when nobody on the team has run a JVM in production. Garbage-collector behavior, heap sizing and classpath conflicts are hard to learn during an outage.

Avoid NestJS for CPU-heavy workloads. Node runs JavaScript on one event-loop thread, so a long computation stalls every request in flight unless you move it to worker threads or a separate service. Also avoid it when you need deep first-party enterprise integration. Spring Security, Spring Batch and Spring for Apache Kafka are maintained together with the framework. On Node, the equivalent pieces come from separate packages you have to vet and keep aligned yourself.

Either works for typical I/O-bound CRUD and API services. Most request time is spent waiting on a database or downstream calls, so the runtime matters less than team experience. Pick the one your team can debug at 3 a.m.

How OneEnv can help

Disclosure: OneEnv is the product behind this blog.

OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.

Applied to spring boot vs nestjs, this is where it helps:

Saves time

  • Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
  • One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
  • Reviewers see the exact diff and the list of affected services up front, so approval is a decision rather than an investigation.

Saves cost

  • Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
  • Less duplicated configuration to maintain: one shared definition inherited by many services.
  • Per-service approve/reject means a bad shared value can be refused where it would break something, before it is live.
  • OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.

These are qualitative benefits, not measured results — how much you save depends on how many services share the value.

Want to try it? Start at oneenv.io.