How to Resolve Tightly Coupled Dependencies in AWS CDK
October 16, 2025Introduction: The dependency hell
If you’ve ever worked with AWS CDK or CloudFormation, chances are high you’ve stumbled into a familiar problem: stack dependencies
One stack exports an output-value, another one imports it, you make a small innocent change…
and suddenly Boom 💥
Your deployment suddenly does not work anymore.
Yeah, I’ve been there too lately, it’s one of those moments where you realize how easy it is for tightly coupled stacks to create hidden deployment traps, circular dependencies, deadlocks, and redeploy nightmares.
In this post, I’ll walk you through an example of how to refactor your CDK code to make it more loosely coupled, reducing cross-stack dependencies and giving you more flexibility in deploying and evolving your infrastructure safely.
A hands-on example
Imagine following architecture…
You have a Stack called UserService which stores and processes User-Information. Suddenly there is a new requirement, you now need to process User-Information in your OrderService Stack too.
The simplest solution is to expose the DynamoDB table you’ve created, and forward it from one stack to another.
The line between the stacks indicates their dependency on the DynamoDB table.
In your code this would look like following:
bin/example-app.ts
import * as cdk from 'aws-cdk-lib';
import {UserServiceStack} from '../lib/user-service-stack';
import {OrderServiceStack} from '../lib/order-service-stack';
const app = new cdk.App();
const userServiceStack = new UserServiceStack(app, 'UserService');
new OrderServiceStack(app, 'OrderService', {
usersTable: userServiceStack.usersTable
});
lib/user-service-stack.ts
import * as cdk from 'aws-cdk-lib';
import {Construct} from 'constructs';
import {AttributeType, ITableV2, TableV2} from 'aws-cdk-lib/aws-dynamodb';
export class UserServiceStack extends cdk.Stack {
public readonly usersTable: ITableV2;
constructor(scope: Construct, id: string, props?: cdk.StackProps) {
super(scope, id, props);
this.usersTable = new TableV2(this, 'Users', {
tableName: 'Users',
partitionKey: {
name: 'PK',
type: AttributeType.STRING,
}
})
}
}
lib/order-service-stack.ts
import * as cdk from 'aws-cdk-lib';
import {ITableV2} from 'aws-cdk-lib/aws-dynamodb';
import {Construct} from 'constructs';
import {NodejsFunction} from 'aws-cdk-lib/aws-lambda-nodejs';
interface OrderServiceStackProps extends cdk.StackProps {
usersTable: ITableV2;
}
export class OrderServiceStack extends cdk.Stack {
constructor(scope: Construct, id: string, props: OrderServiceStackProps) {
super(scope, id, props);
const readUser = new NodejsFunction(this, 'readUser', {
entry: './lib/lambdas/readUser.ts',
environment: {
USER_TABLE_NAME: props.usersTable.tableName,
},
});
props.usersTable.grantReadData(readUser);
}
}
So far, so good. Everything’s working smoothly… right?
Right.
But then, imagine this:
A bug sneaks in. Suddenly, all your user data is corrupted. Disaster! Panic! Chaos!
Now we need to move fast to contain the damage.
Time to roll out the backup (you did make one, right?), update the import in UserService.
Redeploy and everything should be good again, right?
Nope.
Okay, okay… let’s slow down for a moment and take a closer look at what just happened.
What happened?
Since we restored the backup in AWS, a new DynamoDB table was created.
Now, we wanted to use that restored table because it still contains uncorrupted data.
To do this, we had to update our code. Instead of creating a new resource,
we are import the existing one.
We achieved this using the .fromTableAttributes()
method (or any other function that lets us reference an external
resource).
lib/user-service-stack.ts
import * as cdk from 'aws-cdk-lib';
import {Construct} from 'constructs';
import {AttributeType, ITableV2, TableV2} from 'aws-cdk-lib/aws-dynamodb';
export class UserServiceStack extends cdk.Stack {
public readonly usersTable: ITableV2;
constructor(scope: Construct, id: string, props?: cdk.StackProps) {
super(scope, id, props);
this.usersTable = TableV2.fromTableAttributes(this, 'Users', {
tableName: 'UsersBackup'
});
}
}
Why is this causing an issue?
Lets checkout the synthesized template cdk.out/OrderService.template.json
.
{
"Type": "AWS::Lambda::Function",
"Environment": {
"Variables": {
"USER_TABLE_NAME": {
"Fn::ImportValue": "UserService:ExportsOutputRefUsers0A0EEA89A1309EA5"
}
}
}
}
In the Environment definition of our Lambda function, we see that USER_TABLE_NAME
is not a string,
but a CloudFormation - intrinsic function.
This approach isn’t necessarily bad, but in our case it causes problems, because it tries to import an output from the
UserService
stack.
Since we changed the DynamoDB table (and its name), this setup now breaks due to an AWS constraint.
After another stack imports an output value,
you can't delete the stack that is exporting the output
value or modify the exported output value.
All the imports must be removed before you can delete
the exporting stack or modify the output value.
Read more about
Fn:ImportValue
and it’s constraints
How can we fix or even mitigate such issues?
Short answer:
Try to prevent dependencies between stacks wherever it is possible.
Since we only need the name of the DynamoDB table for importing and accessing the table, let’s use this as our dependency.
(Yes, it’s still a dependency, but now it’s less tight)
bin/example-app.ts
#!/usr/bin/env node
import * as cdk from 'aws-cdk-lib';
import {UserServiceStack} from '../lib/user-service-stack';
import {OrderServiceStack} from '../lib/order-service-stack';
const USERS_TABLE_NAME = 'UsersBackup';
const app = new cdk.App();
new UserServiceStack(app, 'UserService', {
usersTableName: USERS_TABLE_NAME,
});
new OrderServiceStack(app, 'OrderService', {
usersTableName: USERS_TABLE_NAME,
});
lib/user-service-stack.ts
import * as cdk from 'aws-cdk-lib';
import {Construct} from 'constructs';
import {TableV2} from 'aws-cdk-lib/aws-dynamodb';
interface UserServiceStackProps extends cdk.StackProps {
usersTableName: string;
}
export class UserServiceStack extends cdk.Stack {
constructor(scope: Construct, id: string, props: UserServiceStackProps) {
super(scope, id, props);
TableV2.fromTableAttributes(this, 'Users', {
tableName: props.usersTableName
});
}
}
lib/order-service-stack.ts
import * as cdk from 'aws-cdk-lib';
import {Construct} from 'constructs';
import {NodejsFunction} from 'aws-cdk-lib/aws-lambda-nodejs';
import {TableV2} from 'aws-cdk-lib/aws-dynamodb';
interface OrderServiceStackProps extends cdk.StackProps {
usersTableName: string;
}
export class OrderServiceStack extends cdk.Stack {
constructor(scope: Construct, id: string, props: OrderServiceStackProps) {
super(scope, id, props);
const userTable = TableV2.fromTableAttributes(this, 'Users', {
tableName: props.usersTableName
});
const readUser = new NodejsFunction(this, 'readUser', {
entry: './lib/lambdas/readUser.ts',
environment: {
USER_TABLE_NAME: userTable.tableName,
},
});
userTable.grantReadData(readUser);
}
}
Now our architecture looks like following:
… and if we take another look at cdk.out/OrderService.template.json
, we will see that the cross stack reference is gone and we are prepared for our next production incident! 🎉
{
"Type": "AWS::Lambda::Function",
"Environment": {
"Variables": {
"USER_TABLE_NAME": "UsersBackup"
}
}
}
I hope you found this walkthrough helpful and that it gave you some practical ideas for managing dependencies in AWS CDK.
Reducing tight coupling can save you a lot of headaches down the road, and even small changes in how you structure your stacks can make deployments smoother and safer.
Thanks for reading, and happy coding!