Back to Blog
|7 min read

How NestJS Changed the Way I Think About Backend Architecture

Before NestJS, I spent weeks stitching together TypeScript, Express, Jest, and project structure by hand. The hard part was not the configuration — it was realising what I was actually learning.

NestJSNode.jsTypeScriptArchitectureBackend

The Cost Nobody Talks About

When I started building the backend for Internverse, I was confident in the fundamentals. I understood HTTP, had TypeScript experience, and had shipped enough frontend work to have a clear mental model of an API boundary. Setting up a production-ready Node.js backend felt like a day of configuration before the real engineering could begin.

Three weeks later, I was still configuring.

The tsconfig.json alone took two days — not because the settings are inherently difficult, but because the interaction between moduleResolution, esModuleInterop, outDir, and path aliases is non-obvious, and each wrong assumption produces a build that compiles but fails at runtime in genuinely confusing ways. Then came Jest's complicated relationship with TypeScript: choosing between ts-jest and Babel, replicating path aliases in moduleNameMapper with different syntax than the TypeScript config, writing setup files that run in the right order.

Then project structure. I started with a type-grouped layout — controllers/, services/, models/ — that made sense at five files and fell apart at fifty. It required a full refactor to a feature-grouped structure around week four:

src/
  users/
    users.controller.ts
    users.service.ts
    users.repository.ts
    users.test.ts
  internships/
    ...
  shared/
    middleware/
    utils/
  app.ts

This worked. But I arrived at it through two rewrites and three weeks of yak shaving. The module boundaries, the separation of controller from service from repository, the decision to group by feature rather than type — I worked all of this out by making mistakes. That is not a productive way to learn architecture. It is a very productive way to lose time.

Five Minutes

When I started Vehicash, I ran nest new vehicash-api mostly out of curiosity. I had loosely associated NestJS with enterprise-framework complexity — the kind of thing that gives you a lot before you know what you need.

In under five minutes, I had a TypeScript server with a working build pipeline, Jest configured, path aliases resolved, and a logical project structure. The structure was the feature-grouped architecture I had eventually landed on in Internverse. Except NestJS had a name for the pattern, a CLI to generate it, and a module system that enforced it structurally rather than through individual discipline.

The three weeks I had spent configuring infrastructure was a problem NestJS had already solved — and solved correctly, with defaults I would not have improved on even with all the prior work.

The Part That Actually Matters

The visible feature of NestJS is its decorator-based syntax: @Controller('users'), @Get(':id'), @Post(). This is convenient, but it is not the important thing.

The important thing is the dependency injection container.

In a hand-rolled Express backend, dependencies between services are managed through imports and either singleton instantiation or instance passing. This works until you try to test a service in isolation. At that point you either implement dependency injection by hand with significant boilerplate, or use Jest's module mocking system, which is fragile to any refactoring that changes import paths.

NestJS's DI container solves this at the framework level. Every provider is declared in a module, injected through constructor parameters, and replaceable in tests without boilerplate:

typescript
const module = await Test.createTestingModule({
  providers: [
    CampaignService,
    {
      provide: CampaignRepository,
      useValue: {
        findById: jest.fn(),
        create: jest.fn(),
      },
    },
    {
      provide: EscrowService,
      useValue: {
        lockFunds: jest.fn(),
        releaseFunds: jest.fn(),
      },
    },
  ],
}).compile();

This pattern is not unique to NestJS — it is standard dependency injection. What the framework provides is ubiquity: every service is testable in the same way, and every new module is naturally structured to be testable by default. The discipline is architectural rather than individually enforced.

The CLI tooling reinforces this. nest generate module campaigns, nest generate service campaigns, nest generate controller campaigns — each command creates a correctly structured file and updates the module declaration automatically. The scaffolding enforces module boundaries at the moment of creation, before any code is written.

Beyond DI, NestJS ships with pipes for input validation, guards for authentication and authorization, interceptors for logging and response transformation, and exception filters for structured error handling. These are patterns every backend application needs. Having them as framework conventions means they are implemented consistently across the codebase rather than assembled differently under different time pressures by different developers.

Conventions Are Not Constraints

The Rails community has a phrase for this: "convention over configuration." The idea is that a framework with sensible defaults for every common decision frees developers to focus on what is actually specific to their application.

Before Internverse, I broadly believed that maximum flexibility was preferable — that experienced developers should not need guardrails. Building a backend without a framework changed my view.

The absence of convention does not produce freedom. It produces a different constraint: the cost of making every decision yourself, the inconsistency that accumulates when those decisions are made at different times under different pressures, and the technical debt from every slightly-wrong call made in a hurry. My tsconfig.json answers were not as good as NestJS's answers, and I could not have known that until the project was large enough to reveal the weaknesses.

NestJS's opinions reflect accumulated thinking about what backend applications require and how to structure them as complexity grows. You can disagree with specific choices. But they are defensible choices made by people who thought about them carefully, and that raises the floor significantly compared to starting from scratch.

The Laravel Parallel

After shipping the Vehicash backend, I did some contract work that required PHP. I had not written serious PHP since university. I picked up Laravel and within an hour had the same feeling as running nest new for the first time: the CLI, the service providers, the middleware system, the model-controller naming conventions, the testing utilities.

Laravel and NestJS are solving the same problem for different language ecosystems. NestJS modules map closely to Laravel's service providers. NestJS guards correspond to Laravel's middleware and policies. NestJS pipes correspond to Laravel's form requests and validators. The metaphors differ, the syntax differs, but the underlying architectural philosophy is essentially identical.

Learning one makes the other significantly easier — not because they share implementation, but because they share the same convictions about what a framework should do.

Whether this is parallel evolution or direct inspiration is an interesting question. What is clear is that both frameworks converged on similar answers because the underlying problems are the same.

What the Time Actually Costs

Every hour spent configuring tsconfig paths and Jest module mappers on Internverse was an hour not spent on the matching algorithm, the data model, or the API design — the parts of the work that are actually specific to that product.

Good tooling does not make interesting engineering problems easier. It removes the accidental complexity so that interesting problems receive the attention they deserve. That is the value proposition of NestJS, and it is the value proposition of any opinionated framework that has been built thoughtfully.

The three weeks I spent before Internverse was not wasted — it is why I understand what NestJS actually provides. But it was not an efficient way to arrive at that understanding, and given the choice I would not repeat it.

That is not a constraint. That is leverage.