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'],
},
});
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/
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
For example:
{
"name": "users-lambda",
"private": true,
"dependencies": {
"zod": "^4.0.0"
}
}
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
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"
}
}
Each Lambda keeps its own dependencies:
{
"name": "users-lambda",
"private": true,
"dependencies": {
"zod": "^4.0.0"
}
}
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'),
},
});
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)