Structuring and Refactoring AWS CDK Projects: Best Practices and Patterns

Robert Dahlborg

--

In the world of Infrastructure as Code (IaC), the AWS Cloud Development Kit (CDK) has emerged as a powerful tool for defining cloud infrastructure using familiar programming languages. However, as CDK projects grow in complexity, maintaining a clean, modular, and scalable codebase becomes increasingly challenging. In this post, we’ll explore best practices for structuring and refactoring CDK projects to ensure they remain maintainable and extensible over time.

AWS Cloud Development Kit (AWS CDK)
AWS Cloud Development Kit (AWS CDK)

Understanding CDK Project Structure

A well-structured CDK project typically follows a clear organizational pattern. Let’s examine the key components:

1. The Entry Point (bin directory)

The entry point of a CDK application serves as the orchestrator, instantiating and connecting the various stacks. It should remain concise and focused on composition rather than implementation details.

// Example entry point (bin/app.ts)
const app = new cdk.App();
const env = { region: 'eu-north-1' };

// Create stacks with clear dependencies
const authStack = new AuthStack(app, 'MyAppAuthStack', { env });
const storageStack = new StorageStack(app, 'MyAppStorageStack', { env });
const apiStack = new ApiStack(app, 'MyAppApiStack', {
env,
userPool: authStack.userPool,
bucket: storageStack.bucket,
userTable: storageStack.userTable,
});

// Explicit dependencies
apiStack.addDependency(authStack);
apiStack.addDependency(storageStack);

2. Stack Definitions (lib directory)

Each stack should be defined in its own file and focus on a specific aspect of your infrastructure. This promotes the single responsibility principle and makes your codebase easier to navigate.

3. Implementation Code (src directory)

Lambda functions and other implementation code should be organized in a separate directory, keeping infrastructure definition separate from application logic.

4. Managing Stack Dependencies Explicitly

When working with multiple stacks that depend on each other, it’s crucial to manage these dependencies explicitly. CDK offers two complementary approaches:

Implicit Dependencies via Props

When you pass a resource from one stack to another via props, CDK automatically creates an implicit dependency:

const apiStack = new ApiStack(app, 'MyAppApiStack', {
userPool: authStack.userPool, // Creates an implicit dependency
bucket: storageStack.bucket, // Creates an implicit dependency
userTable: storageStack.userTable,
});

Explicit Dependencies

While implicit dependencies work for most cases, sometimes you need to explicitly define dependencies between stacks, especially when:

  • One stack needs to be fully deployed before another, even if no resources are directly referenced
  • You want to control the deployment order more precisely
  • There are dependencies that aren’t captured by resource references

To add explicit dependencies:

// Add explicit dependencies in your entry point file
apiStack.addDependency(authStack);
apiStack.addDependency(storageStack);

This ensures that the API stack will only be deployed after both the auth and storage stacks are successfully deployed, regardless of resource references.

Best Practices for CDK Project Structure

1. Embrace the Principle of Separation of Concerns

One of the most effective ways to structure a CDK project is to separate your infrastructure into logical stacks based on their purpose:

  • Authentication Stack: Manages user authentication resources (Cognito, IAM)
  • Storage Stack: Handles data persistence (S3, DynamoDB)
  • API Stack: Defines API Gateway and related resources
  • Compute Stack: Contains Lambda functions and other compute resources

This separation makes your codebase more navigable and allows team members to work on different aspects of the infrastructure simultaneously.

2. Use Props Interfaces for Stack Communication

When stacks need to share resources, use well-defined props interfaces:

interface ApiStackProps extends cdk.StackProps {
userPool: cognito.UserPool;
bucket: s3.Bucket;
userTable: dynamodb.Table;
}

This approach creates a clear contract between stacks and makes dependencies explicit.

3. Export Resources Judiciously

Only export resources that truly need to be shared across stacks:

export class AuthStack extends cdk.Stack {
public readonly userPool: cognito.UserPool;
// Other resources remain private
}

4. Consistent Tagging Strategy

Implement a consistent tagging strategy across all resources:

// Global tags at the app level
cdk.Tags.of(app).add('service', 'myapp');
cdk.Tags.of(app).add('repo', 'https://github.com/myorg/myapp-iac.git');

// Stack-specific tags
cdk.Tags.of(this).add('service', 'myapp');

Refactoring Strategies for Growing CDK Projects

As your CDK project grows, you’ll likely need to refactor to maintain clarity and manageability. Here are some effective strategies:

1. Extract Constructs for Reusable Patterns

When you notice repeated patterns across your stacks, extract them into custom constructs:

// Before refactoring
new lambda.Function(this, 'Function1', {
runtime: lambda.Runtime.PYTHON_3_13,
architecture: lambda.Architecture.ARM_64,
handler: 'index.lambda_handler',
code: lambda.Code.fromAsset('src/function1'),
});

new lambda.Function(this, 'Function2', {
runtime: lambda.Runtime.PYTHON_3_13,
architecture: lambda.Architecture.ARM_64,
handler: 'index.lambda_handler',
code: lambda.Code.fromAsset('src/function2'),
});

// After refactoring - create a custom construct
class PythonLambda extends Construct {
public readonly function: lambda.Function;

constructor(scope: Construct, id: string, path: string) {
super(scope, id);

this.function = new lambda.Function(this, 'Function', {
runtime: lambda.Runtime.PYTHON_3_13,
architecture: lambda.Architecture.ARM_64,
handler: 'index.lambda_handler',
code: lambda.Code.fromAsset(path),
});
}
}

// Usage
const function1 = new PythonLambda(this, 'Function1', 'src/function1').function;
const function2 = new PythonLambda(this, 'Function2', 'src/function2').function;

2. Split Overgrown Stacks

When a stack becomes too large or handles too many concerns, split it into multiple stacks:

// Before: One large API stack handling multiple concerns

// After: Split into specialized stacks
class ApiGatewayStack extends cdk.Stack {
public readonly api: apigateway.RestApi;
// API Gateway definition
}

class LambdaFunctionsStack extends cdk.Stack {
public readonly authorizerFn: lambda.Function;
public readonly userCrudFn: lambda.Function;
// Lambda function definitions
}

class ApiRoutesStack extends cdk.Stack {
// API routes and integrations
}

3. Implement Aspect-Oriented Patterns

Use CDK Aspects to apply cross-cutting concerns across your infrastructure:

// Define an aspect to enforce encryption
class EnforceEncryption implements cdk.IAspect {
public visit(node: cdk.IConstruct): void {
if (node instanceof s3.Bucket) {
if (!node.encryptionKey) {
node.addPropertyOverride('BucketEncryption', {
ServerSideEncryptionConfiguration: [{
ServerSideEncryptionByDefault: {
SSEAlgorithm: 'AES256'
}
}]
});
}
}
}
}

// Apply the aspect to your entire app
cdk.Aspects.of(app).add(new EnforceEncryption());

4. Implement Environment-Based Configuration

Refactor your code to support multiple environments:

// Define environment-specific configurations
const environments = {
dev: {
region: 'eu-north-1',
stage: 'dev',
domainPrefix: 'dev-myapp',
callbackUrls: ['http://localhost:3000/callback'],
},
prod: {
region: 'eu-north-1',
stage: 'prod',
domainPrefix: 'myapp',
callbackUrls: ['https://myapp.com/callback'],
}
};

// Select environment based on context or parameter
const envName = app.node.tryGetContext('env') || 'dev';
const envConfig = environments[envName];

// Use configuration in stacks
const authStack = new AuthStack(app, `${envConfig.stage}-MyAppAuthStack`, {
env: { region: envConfig.region },
domainPrefix: envConfig.domainPrefix,
callbackUrls: envConfig.callbackUrls,
});

Conclusion

A well-structured CDK project is easier to understand, maintain, and extend. By following the principles of separation of concerns, clear interfaces between stacks, and judicious refactoring as your project grows, you can keep your infrastructure code clean and manageable.Remember that refactoring is an ongoing process. As your application evolves, your infrastructure needs will change, and your CDK code should adapt accordingly. Regular refactoring sessions help prevent technical debt and ensure your infrastructure remains aligned with your application’s needs.By applying these structuring and refactoring strategies, you’ll be well-equipped to manage CDK projects of any size, from simple applications to complex enterprise systems.

--

--

Responses (1)