DEV Community

Cover image for Manage Node.js Dependencies in a Multi-Lambda CDK Project
Anne for AWS Community Builders

Posted on AI-assisted

Manage Node.js Dependencies in a Multi-Lambda CDK Project

Managing dependencies in a CDK application with multiple Node.js Lambdas can get messy quickly.

I ran into this problem and tried a few different approaches. Each had its own pros and cons, so I wanted to share what I tried, what didn't work well, and what I eventually settled on.

Option 1: externalModules

NodejsFunction allows you to exclude dependencies from the bundle:

new NodejsFunction(this, 'UsersFunction', {
  entry: 'lambda/users/handler.ts',
  bundling: {
    externalModules: ['zod'],
  },
});
Enter fullscreen mode Exit fullscreen mode

The problem is local development. If zod isn't installed in the CDK project, your IDE will complain: Cannot find module 'zod'

You could add it as a dev-dependency to the root package.json, but now the dependency is declared in two different places conceptually: the Lambda needs it, while the CDK project owns the declaration.

I'd use externalModules only when I actually want a module to remain external to the Lambda bundle, not as a dependency-management strategy.

Option 2: One package.json for all Lambdas

You could create one package for all Lambda dependencies:

lambda/
├── package.json
├── users/
├── orders/
└── payments/
Enter fullscreen mode Exit fullscreen mode

This works, but destroys an important boundary: different Lambdas often have different dependencies. If users needs zod and orders needs stripe, there's little reason for both dependencies to belong to the same Lambda project. As the number of functions grows, this approach becomes harder to maintain and can lead to unnecessary dependencies.

Option 3: package.json per Lambda

A cleaner structure is:

lambda/
├── users/
│   ├── handler.ts
│   └── package.json
├── orders/
│   ├── handler.ts
│   └── package.json
└── payments/
    ├── handler.ts
    └── package.json
Enter fullscreen mode Exit fullscreen mode

For example:

{
  "name": "users-lambda",
  "private": true,
  "dependencies": {
    "zod": "^4.0.0"
  }
}
Enter fullscreen mode Exit fullscreen mode

This is a much better dependency model: each Lambda owns exactly what it needs.

The downside is operational overhead. Without anything else, you'd have separate lock files and need to run npm install in every Lambda directory.

The sweet spot: npm workspaces

The solution is to combine per-Lambda package.json files with npm workspaces. The project becomes:

cdk-app/
├── bin/
├── lib/
├── lambda/
│   ├── users/
│   │   ├── handler.ts
│   │   └── package.json
│   ├── orders/
│   │   ├── handler.ts
│   │   └── package.json
│   └── payments/
│       ├── handler.ts
│       └── package.json
├── package.json
└── package-lock.json
Enter fullscreen mode Exit fullscreen mode

The root package.json defines the workspaces:

{
  "name": "my-cdk-app",
  "private": true,
  "workspaces": [
    "lambda/*"
  ],
  "devDependencies": {
    "aws-cdk-lib": "^2.0.0",
    "constructs": "^10.0.0",
    "typescript": "^5.0.0"
  }
}
Enter fullscreen mode Exit fullscreen mode

Each Lambda keeps its own dependencies:

{
  "name": "users-lambda",
  "private": true,
  "dependencies": {
    "zod": "^4.0.0"
  }
}
Enter fullscreen mode Exit fullscreen mode

Now you can simply run npm install at the root. npm manages all workspaces and keeps everything in the root package-lock.json. When you need to add a dependency to a specific Lambda:

npm install zod --workspace=users-lambda

This gives us the best of both worlds: isolated dependencies with centralized installation and locking.

Configuring CDK

There's one important detail when using this setup with NodejsFunction: make the Lambda directory the bundling working directory.

import * as path from 'node:path';
import { Runtime } from 'aws-cdk-lib/aws-lambda';
import { NodejsFunction } from 'aws-cdk-lib/aws-lambda-nodejs';

new NodejsFunction(this, 'UsersFunction', {
  runtime: Runtime.NODEJS_22_X,
  entry: path.join(__dirname, '../lambda/users/handler.ts'),

  bundling: {
    forceDockerBundling: true,
    workingDirectory: path.join(__dirname, '../lambda/users'),
  },
});
Enter fullscreen mode Exit fullscreen mode

For each Lambda, workingDirectory points to its workspace.

I also recommend using forceDockerBundling: true. It keeps the bundling environment consistent and avoids relying too much on what's installed in the developer's local environment.

Conclusion

For a CDK application with multiple Node.js Lambdas, my preferred setup is one package.json per Lambda + npm workspaces + Docker bundling.

You get:

  • Dependency isolation ✅
  • IDE support ✅
  • Single package-lock.json ✅
  • Single npm install ✅
  • No unnecessary dependency declarations ✅

Each Lambda owns its dependencies, while the root project handles installation and locking. For me, that's a good balance: you get clean dependency boundaries without turning every Lambda into a completely independent npm project.

Top comments (0)