DEV Community

Cover image for Solving NestJS Dependency Injection Issues on AWS Lambda with CDK
Gibsons Gibson
Gibsons Gibson

Posted on

Solving NestJS Dependency Injection Issues on AWS Lambda with CDK

If you're deploying NestJS to AWS Lambda using CDK's NodejsFunction and getting Cannot read properties of undefined errors on your injected services, the issue maybe that esbuild doesn't support TypeScript's emitDecoratorMetadata. The solution is to point your Lambda entry to pre-compiled JavaScript from nest build instead of TypeScript source files.


The Problem

I was deploying a NestJS application to AWS Lambda using AWS CDK's NodejsFunction construct. Everything worked perfectly in local development, but after deployment, then met encountered this cryptic error:

TypeError: Cannot read properties of undefined (reading 'register')
    at TenantAuthController.register (/var/task/index.js:1140:40809)
Enter fullscreen mode Exit fullscreen mode

The register method existed in the TenantAuthService, and the service was properly decorated with @Injectable() and registered in our module. So why was this.tenantAuthService undefined?

The Controller Code

@Controller('tenant')
export class TenantAuthController {
  constructor(private readonly tenantAuthService: TenantAuthService) {}

  @Post('register')
  @Public()
  async register(@Body() dto: TenantRegisterDto) {
    return this.tenantAuthService.register(dto); // πŸ’₯ tenantAuthService is undefined!
  }
}
Enter fullscreen mode Exit fullscreen mode

The Module Configuration

@Module({
  imports: [EventEmitterModule, MailingModule],
  controllers: [TenantAuthController],
  providers: [
    TenantAuthService,
    CognitoService,
    // ... other providers
  ],
  exports: [CognitoService, TenantAuthService],
})
export class AuthModule {}
Enter fullscreen mode Exit fullscreen mode

Everything looked correct. The service was registered, the controller was declared, and it worked locally. What was going wrong?


Root Cause: esbuild Doesn't Support Decorator Metadata

AWS CDK's NodejsFunction uses esbuild under the hood for bundling TypeScript. While esbuild is incredibly fast, it has a fundamental limitation: it does not support TypeScript's emitDecoratorMetadata compiler option.

NestJS relies heavily on decorator metadata for its dependency injection system. When you write:

constructor(private readonly tenantAuthService: TenantAuthService) {}
Enter fullscreen mode Exit fullscreen mode

TypeScript (with emitDecoratorMetadata: true) emits metadata that tells NestJS "this constructor parameter should be an instance of TenantAuthService". This metadata is generated using the Reflect.metadata API.

When esbuild processes your TypeScript files, it:

  1. βœ… Transpiles TypeScript to JavaScript
  2. βœ… Handles decorators (the @Injectable(), @Controller() syntax)
  3. ❌ Does NOT emit the reflection metadata that NestJS needs for DI

Without this metadata, NestJS cannot determine what dependencies to inject, resulting in undefined being passed to your constructors.

Our CDK Configuration (Before)

const AuthFunction = new lambdaNodejs.NodejsFunction(this, 'Function', {
  runtime: lambda.Runtime.NODEJS_20_X,
  entry: path.join(__dirname, '../../src/lambda.ts'), // ❌ TypeScript source
  handler: 'handler',
  bundling: {
    minify: true,
    externalModules: [
      '@aws-sdk/*',
      '@nestjs/microservices',
      '@nestjs/websockets',
      // ...
    ],
  },
});
Enter fullscreen mode Exit fullscreen mode

The Solution: Pre-compile with nest build

The solution is elegantly simple: let NestJS compile your TypeScript first, then have esbuild bundle the pre-compiled JavaScript.

Step 1: Update Your CDK Stack

Change the entry point from .ts to the pre-compiled .js file:

const AuthFunction = new lambdaNodejs.NodejsFunction(this, 'Function', {
  runtime: lambda.Runtime.NODEJS_20_X,
  // βœ… Point to pre-compiled JS from nest build
  entry: path.join(__dirname, '../../dist/lambda.js'),
  handler: 'handler',
  bundling: {
    minify: true,
    externalModules: [
      '@aws-sdk/*',
      '@nestjs/microservices',
      '@nestjs/websockets',
      '@nestjs/websockets/socket-module',
      '@nestjs/microservices/microservices-module',
      'class-transformer/storage',
    ],
  },
});
Enter fullscreen mode Exit fullscreen mode

Step 2: Update Your Deploy Script

Ensure nest build runs before CDK deploy:

{
  "scripts": {
    "deploy:dev": "nest build && cdk deploy --app \"npx ts-node cdk/bin/app.ts\" --require-approval never"
  }
}
Enter fullscreen mode Exit fullscreen mode

Step 3: Verify Your tsconfig.json

Ensure decorator metadata emission is enabled:

{
  "compilerOptions": {
    "emitDecoratorMetadata": true,
    "experimentalDecorators": true,
    "module": "commonjs",
    "target": "ES2021",
    "outDir": "./dist"
  }
}
Enter fullscreen mode Exit fullscreen mode

Why This Works

The compilation flow is now:

  1. nest build runs TypeScript compiler (tsc) with your tsconfig.json
  2. tsc sees emitDecoratorMetadata: true and generates reflection metadata
  3. Output goes to dist/ as JavaScript with metadata intact
  4. CDK/esbuild bundles the JavaScript (not TypeScript)
  5. esbuild doesn't strip the already-generated metadata
  6. Lambda receives properly bundled code with DI metadata
  7. NestJS can resolve dependencies correctly

Alternative Solutions I Tried (And Why They Didn't Work)

1. preCompilation: true in Bundling Options

bundling: {
  preCompilation: true, // ❌ Causes pnpm symlink issues
}
Enter fullscreen mode Exit fullscreen mode

This option tells CDK to run tsc before esbuild. However, if you're using pnpm as your package manager, this causes "ELOOP: too many symbolic links encountered" errors because the bundler follows pnpm's symlinked node_modules structure recursively.

2. Command Hooks for Pre-compilation

bundling: {
  commandHooks: {
    beforeBundling(inputDir: string): string[] {
      return [`cd ${inputDir} && npx tsc`];
    },
  },
}
Enter fullscreen mode Exit fullscreen mode

This approach runs tsc in a hook before bundling. The problem is that the inputDir doesn't always point where you expect, and the compiled output isn't automatically used by esbuild.

3. esbuild Plugins

The NodejsFunction construct doesn't support arbitrary esbuild plugins. Solutions like @anatine/esbuild-decorators require API access that CDK doesn't expose.


Additional Considerations

SSL Certificates for Database Connections

If your Lambda connects to RDS with SSL, you might encounter another issue: file system access for CA certificates. Lambda's bundled environment may not have your certificate files.

Solution: Embed certificates directly in your code:

const RDS_CA_CERT = `-----BEGIN CERTIFICATE-----
MIID/zCCAuegAwIBAgIRAPVSMfFitmM5Phm...
-----END CERTIFICATE-----`;

const pool = new Pool({
  connectionString: process.env.DATABASE_URL,
  ssl: process.env.DATABASE_SSL === 'true'
    ? { ca: RDS_CA_CERT, rejectUnauthorized: true }
    : false,
});
Enter fullscreen mode Exit fullscreen mode

External Modules

Keep these modules external to avoid bundling issues:

externalModules: [
  '@aws-sdk/*',                           // Lambda runtime provides these
  '@nestjs/microservices',                // Optional NestJS modules
  '@nestjs/websockets',
  '@nestjs/websockets/socket-module',
  '@nestjs/microservices/microservices-module',
  'class-transformer/storage',            // Can cause DI issues if bundled
]
Enter fullscreen mode Exit fullscreen mode

Summary

Approach Works? Notes
Direct .ts entry with esbuild ❌ No decorator metadata
preCompilation: true ⚠️ Breaks with pnpm
Command hooks ⚠️ Complex, unreliable paths
Pre-compiled .js entry βœ… Simple, reliable

The key insight is that esbuild is a bundler, not a full TypeScript compiler. For frameworks like NestJS that depend on TypeScript's reflection capabilities, you need to use tsc (via nest build) first, then let esbuild handle the bundling of the resulting JavaScript.


Final Working Configuration

cdk-stack.ts:

import * as lambdaNodejs from 'aws-cdk-lib/aws-lambda-nodejs';
import * as lambda from 'aws-cdk-lib/aws-lambda';
import * as path from 'path';

const nestFunction = new lambdaNodejs.NodejsFunction(this, 'NestFunction', {
  runtime: lambda.Runtime.NODEJS_20_X,
  entry: path.join(__dirname, '../../dist/lambda.js'), // Pre-compiled!
  handler: 'handler',
  memorySize: 1024,
  timeout: cdk.Duration.seconds(30),
  bundling: {
    minify: true,
    sourceMap: false,
    externalModules: [
      '@aws-sdk/*',
      '@nestjs/microservices',
      '@nestjs/websockets',
      '@nestjs/websockets/socket-module',
      '@nestjs/microservices/microservices-module',
      'class-transformer/storage',
    ],
  },
});
Enter fullscreen mode Exit fullscreen mode

package.json:

{
  "scripts": {
    "build": "nest build",
    "deploy": "nest build && cdk deploy"
  }
}
Enter fullscreen mode Exit fullscreen mode

References

Top comments (0)