AWS CDK Stack Design: There's No Single Right Answer - 3 Strategies to Lead Your Project to Success
Have you ever experienced that moment of panic when running cdk list only to see dozens of stacks flooding your terminal? Or perhaps you've waited over 10 minutes for a simple Lambda function update to deploy after running cdk deploy? All these situations ultimately come down to your stack management strategy.
Anyone working with AWS CDK eventually faces this dilemma: Should I break everything down into fine-grained micro stacks organized by function? Or is it wiser to manage one large monolithic stack effectively?
In this article, we'll analyze the clear advantages and disadvantages of these two fundamental approaches, and then explore the most ideal solution that CDK offers: the CDK Pipelines and Stage pattern. My goal is to help you find the optimal strategy that fits your project scale and team structure.
Strategy 1: Speed and Stability Through Separation - Micro Stack Architecture
The micro stack architecture treats infrastructure like microservices (MSA), dividing it into independent stacks by functional units. Each AWS service or logical component like VPC, WAF, Lambda, and RDS gets its own separate stack.
// Example: Micro Stack Structure
// vpc-stack.ts
export class VpcStack extends Stack {
public readonly vpc: ec2.Vpc;
constructor(scope: Construct, id: string, props?: StackProps) {
super(scope, id, props);
this.vpc = new ec2.Vpc(this, 'MainVpc', {
maxAzs: 2
});
// Export for use by other stacks
new CfnOutput(this, 'VpcId', {
value: this.vpc.vpcId,
exportName: 'MainVpcId'
});
}
}
// lambda-stack.ts
export class LambdaStack extends Stack {
constructor(scope: Construct, id: string, props?: StackProps) {
super(scope, id, props);
// Import VPC created by VPC stack
const vpcId = Fn.importValue('MainVpcId');
const vpc = ec2.Vpc.fromVpcAttributes(this, 'ImportedVpc', {
vpcId: vpcId,
availabilityZones: ['us-east-1a', 'us-east-1b'] // ⚠️ Warning: hardcoded values
});
new lambda.Function(this, 'MyFunction', {
runtime: lambda.Runtime.NODEJS_18_X,
handler: 'index.handler',
code: lambda.Code.fromAsset('lambda'),
vpc: vpc
});
}
}
Pro Tip: When using fromVpcAttributes, you need to specify properties like availabilityZones directly. If these values differ from the actual resources, deployment will fail. Consider using Vpc.fromLookup() or sharing more information between stacks through SSM Parameter Store for a more robust approach.
Key Advantages of Micro Stacks
1. Rapid Partial Deployments You can selectively deploy only the stacks that have changes. If you only modified Lambda function code, you just need to deploy the LambdaStack. This significantly reduces deployment time, especially for large projects.
2. Minimized Blast Radius Issues in one stack don't affect others. If your WAF configuration goes wrong and requires rollback, your database and compute resources remain untouched.
3. Clear Ownership Teams can have clear responsibility for their respective areas by separating stacks by team or function. The frontend team handles the CloudFront stack, while the backend team manages the Lambda stack.
Critical Disadvantages of Micro Stacks
1. Dependency Hell Sharing resources between stacks is more complex than you might think. As shown in the example above, you need to use CfnOutput and Fn.importValue, and managing these becomes increasingly difficult as they multiply.
// Example of complex dependency management
export class DatabaseStack extends Stack {
constructor(scope: Construct, id: string, props?: StackProps) {
super(scope, id, props);
// Import values exported by multiple stacks
const vpcId = Fn.importValue('MainVpcId');
const privateSubnetIds = [
Fn.importValue('PrivateSubnet1Id'),
Fn.importValue('PrivateSubnet2Id')
];
const securityGroupId = Fn.importValue('DatabaseSecurityGroupId');
// Configure RDS with imported values...
// The more code like this you have, the harder it becomes to manage.
}
}
2. Management Fragmentation In real-world scenarios, stacks like waf-tony, waf-anne, lambda-dev-tony, and lambda-prod-anne tend to proliferate. When each developer creates their own test environments, the number of stacks grows exponentially.
Bottom Line: Advantageous when you have large teams and clearly separated services, but you must accept the cost of complexity.
Strategy 2: The Beauty of Simplicity - Monolithic Stack with Class-Based Management
The monolithic stack architecture places all resources in a single stack while logically modularizing them at the code level using Construct or Class. This is a very wise approach that maximally leverages CDK's object-oriented characteristics.
// Example: Monolithic Stack with Logical Separation
export class MyAppStack extends Stack {
constructor(scope: Construct, id: string, props?: StackProps) {
super(scope, id, props);
// 1. Networking components
const networking = new NetworkingConstruct(this, 'Networking');
// 2. Security components
const security = new SecurityConstruct(this, 'Security', {
vpc: networking.vpc
});
// 3. Compute components
const compute = new ComputeConstruct(this, 'Compute', {
vpc: networking.vpc,
securityGroup: security.lambdaSecurityGroup
});
// 4. Storage components
const storage = new StorageConstruct(this, 'Storage', {
vpc: networking.vpc,
securityGroup: security.databaseSecurityGroup
});
}
}
// Individual Construct classes
class NetworkingConstruct extends Construct {
public readonly vpc: ec2.Vpc;
constructor(scope: Construct, id: string) {
super(scope, id);
this.vpc = new ec2.Vpc(this, 'Vpc', {
maxAzs: 2
});
}
}
class SecurityConstruct extends Construct {
public readonly lambdaSecurityGroup: ec2.SecurityGroup;
public readonly databaseSecurityGroup: ec2.SecurityGroup;
constructor(scope: Construct, id: string, props: { vpc: ec2.Vpc }) {
super(scope, id);
this.lambdaSecurityGroup = new ec2.SecurityGroup(this, 'LambdaSG', {
vpc: props.vpc,
description: 'Security group for Lambda functions'
});
this.databaseSecurityGroup = new ec2.SecurityGroup(this, 'DatabaseSG', {
vpc: props.vpc,
description: 'Security group for RDS database'
});
}
}
Key Advantages of Monolithic Stacks
1. Overwhelming Simplicity CDK automatically resolves all dependencies for you. You can define relationships between resources through simple object references without complex constructs like CfnOutput or Fn.importValue.
2. One-Click Deployment You can deploy your entire infrastructure with a single cdk deploy MyAppStack command. No need to worry about inter-stack dependencies or deployment order.
3. Centralized Management It's easy to get a complete picture of your architecture. Just open one stack file to immediately understand which resources are connected and how.
Critical Disadvantages of Monolithic Stacks
1. Expanding Blast Radius Small mistakes can lead to entire system rollbacks. A simple environment variable misconfiguration in one Lambda function could trigger a rollback affecting everything from VPC to database.
2. CloudFormation Resource Limits AWS CloudFormation supports a maximum of 500 resources per stack. Large applications quickly hit this limit.
3. Increasing Deployment Time As resources multiply, the time CloudFormation takes to calculate and execute changes grows longer. Even small modifications require reviewing the entire stack.
Bottom Line: Most efficient and fast for small projects or single-developer environments, but may hit limits as projects grow.
Middle Ground: Hybrid Strategy for Gradual Evolution
When monolithic stack limitations start showing, many teams naturally choose a hybrid strategy. This approach first separates Core stacks for infrastructure that doesn't change frequently and is shared across services (like VPC or EKS clusters), while managing each application as separate application stacks.
// 1. Core infrastructure stack (low change frequency)
export class CoreInfraStack extends Stack {
public readonly vpc: ec2.Vpc;
public readonly cluster: eks.Cluster;
constructor(scope: Construct, id: string, props?: StackProps) {
super(scope, id, props);
this.vpc = new ec2.Vpc(this, 'SharedVpc', {
maxAzs: 3
});
this.cluster = new eks.Cluster(this, 'SharedEksCluster', {
vpc: this.vpc,
version: eks.KubernetesVersion.V1_28
});
}
}
// 2. Application-specific stack (high change frequency)
export class UserServiceStack extends Stack {
constructor(scope: Construct, id: string, props: {
vpc: ec2.Vpc,
cluster: eks.Cluster
} & StackProps) {
super(scope, id, props);
// Only user service related resources
const userApi = new lambda.Function(this, 'UserApi', { ... });
const userDb = new rds.DatabaseInstance(this, 'UserDb', { ... });
}
}
The hybrid strategy provides an excellent compromise between micro stack complexity and monolithic limitations. It's naturally chosen when teams grow to 2-5 people or when you need to separate Dev/Prod environments.
The Ultimate Strategy: Taking Only the Best of Both Worlds - CDK Pipelines and Stage Pattern
The CDK Pipelines and Stage pattern is a CDK-native approach that solves all the disadvantages of the previous two strategies. It introduces the concept of Stage, which is a deployment unit one level above 'stacks' - think 'application deployment units'.
How It Works
Step 1: Define Stacks Create functional stacks (WafStack, LambdaStack, DatabaseStack, etc.) just like in the micro stack approach.
// infrastructure/stacks/networking-stack.ts
export class NetworkingStack extends Stack {
public readonly vpc: ec2.Vpc;
constructor(scope: Construct, id: string, props?: StackProps) {
super(scope, id, props);
this.vpc = new ec2.Vpc(this, 'Vpc', {
maxAzs: 2
});
}
}
// infrastructure/stacks/lambda-stack.ts
export class LambdaStack extends Stack {
constructor(scope: Construct, id: string, props: { vpc: ec2.Vpc } & StackProps) {
super(scope, id, props);
new lambda.Function(this, 'MyFunction', {
runtime: lambda.Runtime.NODEJS_18_X,
handler: 'index.handler',
code: lambda.Code.fromAsset('lambda'),
vpc: props.vpc
});
}
}
Step 2: Bundle into a Stage Instantiate all these stacks within a single cdk.Stage class to define one complete 'application' set.
// infrastructure/stages/my-app-stage.ts
export class MyAppStage extends Stage {
constructor(scope: Construct, id: string, props?: StageProps) {
super(scope, id, props);
// Create networking stack
const networkingStack = new NetworkingStack(this, 'Networking');
// Create Lambda stack (referencing VPC from networking stack)
new LambdaStack(this, 'Lambda', {
vpc: networkingStack.vpc
});
// Create WAF stack
new WafStack(this, 'Waf');
// Create database stack
new DatabaseStack(this, 'Database', {
vpc: networkingStack.vpc
});
}
}
Step 3: Deploy Through Pipeline Create as many instances of this Stage as needed in your CodePipeline.
// infrastructure/pipeline-stack.ts
export class PipelineStack extends Stack {
constructor(scope: Construct, id: string, props?: StackProps) {
super(scope, id, props);
const pipeline = new CodePipeline(this, 'Pipeline', {
pipelineName: 'MyAppPipeline',
synth: new ShellStep('Synth', {
input: CodePipelineSource.gitHub('my-org/my-app', 'main'),
commands: [
'npm ci',
'npm run build',
'npx cdk synth'
]
})
});
// Developer sandbox environments
pipeline.addStage(new MyAppStage(this, 'Dev-Tony', {
env: { account: '123456789012', region: 'us-east-1' }
}));
pipeline.addStage(new MyAppStage(this, 'Dev-Anne', {
env: { account: '123456789012', region: 'us-east-1' }
}));
// QA environment
const qaStage = pipeline.addStage(new MyAppStage(this, 'QA', {
env: { account: '123456789012', region: 'us-east-1' }
}));
// Production environment (requires manual approval)
const prodStage = pipeline.addStage(new MyAppStage(this, 'Prod', {
env: { account: '987654321098', region: 'us-east-1' }
}), {
pre: [new ManualApprovalStep('PromoteToProd')]
});
}
}
Critical Problem Resolution
The most powerful aspect of this pattern is that stack naming conflicts and management overhead are completely resolved. CDK automatically generates stack names like:
- Dev-Tony-Networking
- Dev-Tony-Lambda
- Dev-Tony-Waf
- Dev-Anne-Networking
- Dev-Anne-Lambda
- Dev-Anne-Waf
- QA-Networking
- QA-Lambda
- Prod-Networking
- Prod-Lambda
Each Stage is completely isolated, allowing you to manage developer sandboxes, QA, and Prod environments consistently and safely. No matter what experiments Tony runs in his development environment, it won't affect Anne or production environments at all.
Additionally, since deployments happen at the Stage level, you get:
- Micro stack advantages: Each stack is still managed independently
- Monolithic stack advantages: Deploy entire applications with a single command
- Bonus features: Git-based automated deployment, environment-specific configurations, approval workflows, etc.
Bottom Line: This is exactly what CDK envisions as the modern approach to application infrastructure management.
Conclusion: Choosing the Right Strategy for Your Project
To summarize our three strategies:
- Micro Stacks: Completely separated independent stacks organized by function
- Monolithic Stack: Logically modularized structure within a single stack
- CDK Pipelines + Stage: Provides both stack independence and application-level unified deployment
Selection Guide
Single Developer / Small Prototype
- Recommended: Monolithic Stack (with logical separation)
- Reason: Minimizes complexity, enables rapid prototyping
2-5 Person Team / Dev, Prod Environment Separation
- Recommended: Hybrid (Core stacks + Application stacks)
- Reason: Appropriate separation with manageable complexity
Multiple Developers / Multi-environment (Sandbox, QA)
- Recommended: CDK Pipelines + Stage (Strongly recommended)
- Reason: Environment isolation, automation, scalability
Legacy Migration (Gradual Adoption)
- Recommended: Micro Stacks
- Reason: Ensures independence from existing systems
Evolving Architecture
The important thing is that there's no single right answer to stack design. Projects grow, teams change, and requirements continuously evolve. What starts as a simple monolithic stack might naturally evolve to micro stacks as the team grows, and ultimately to CDK Pipelines with Stage patterns.
The most important thing is choosing the strategy that best fits your current situation while leaving room to adapt flexibly as needs change. CDK's power lies in naturally supporting this kind of evolution at the code level.