L3 Constructs with Builder Patterns: My Favorite CDK Design Approach

11 min read Original article ↗

Erin Duvall

Press enter or click to view image in full size

As a senior software developer who moved into infrastructure, I’ve experimented with various design patterns in AWS CDK using TypeScript. While there are many approaches that work well, I’ve found myself gravitating toward one pattern in particular, L3 constructs that implement builder patterns.

There are certainly other design patterns that work great in CDK, including factory patterns, abstract base classes, composition over inheritance, and more. But after implementing dozens of constructs and stacks, the builder pattern has become my personal favorite approach for creating L3 constructs.

Why I Prefer Builder Patterns for L3 Constructs

When I first started writing CDK code, I experimented with different approaches for creating reusable L3 constructs. My early attempts often resulted in massive constructors with dozens of parameters, boolean flags scattered everywhere, and code that didn’t feel much different from writing CloudFormation or SAM templates.

Here’s what one of my typical early L3 constructs looked like:

const database = new RdsDatabase(this, 'MyDatabase', {
instanceType: 'db.t3.micro',
engine: 'postgres',
multiAz: false,
backupRetention: 7,
enableEncryption: true,
enablePerformanceInsights: false,
enableDeletionProtection: true,
monitoringInterval: 60,
maxConnections: 100,
// ... 15 more parameters
});t

This approach worked, but it never felt quite right to me. It didn’t leverage my strengths as a software developer or create the kind of infrastructure code that’s easy to maintain long-term.

My Solution: L3 Builder Constructs

What I’m calling the “builder pattern” has transformed how I approach L3 constructs in CDK. To be precise, I’m using a simplified adaptation of the formal Builder pattern, essentially a fluent interface with method chaining that feels natural in the CDK context. Instead of static configuration objects, I create expressive APIs that mirror how I actually think about infrastructure. Rather than front-loading all decisions into a constructor, I can build resources step by step, with each method call representing a deliberate choice about the infrastructure.

Here’s how that same database looks with a builder pattern:

const database = new DatabaseService(this, 'database-service', {
app: this.app,
env: this.env,
vpc: this.vpc
})
.setEngine('postgres', '13.7')
.setInstanceType('db.t3.micro')
.enableEncryption()
.enableDeletionProtection()
.setBackupRetention(7)
.enablePerformanceInsights(60)
.build();

This builder approach creates a narrative flow that mirrors how I actually think about designing systems. Start with the basics, layer on features, configure the details, then build.

Simplified vs. Formal Builder Pattern

I should clarify that what I’m showing here is a simplified adaptation of the formal Builder pattern. The classic Gang of Four Builder pattern involves a separate Builder class, a Director, and a Product. For my CDK L3 constructs, I’ve found this simpler approach works fine:

My Simplified Approach:

  • The construct itself acts as both builder and product
  • Methods return `this` for chaining (I love chaining)
  • A final `build()` method creates the actual AWS resources
  • Configuration is stored as instance properties

Why I Prefer the Simplified Approach for CDK:

  • Less boilerplate, no need for separate builder classes
  • Natural CDK integration, works with existing construct patterns
  • Easier testing, configure methods without creating AWS resources
  • Better IDE experience, intellisense works naturally with the methods

The formal pattern has its place and would work just fine. But for my constructs, I find this simplified approach strikes the right balance between flexibility and simplicity.

What I Love About This Approach

Conditional Logic That Actually Makes Sense!

One of my favorite aspects of this approach is how elegantly it handles conditional infrastructure. Instead of complex constructor logic, I can use method chaining with conditionals that feel natural:

const ec2Service = new Ec2ServerService(this, 'web-server', {
app: this.app,
env: this.env,
vpc: this.vpc
})
.setInstanceName(`${this.app}-web-${this.env}`)
.setRole() // Creates default role if none provided

// Only add monitoring in production
if (this.env === 'prod') {
ec2Service.setRoleCustom(
`${this.app}-${this.env}-web-role`,
['ec2.amazonaws.com'],
['CloudWatchAgentServerPolicy', 'AmazonSSMManagedInstanceCore']
);
}

// Only configure key pair if provided
if (config.keyPair) {
ec2Service.setKeyPair(config.keyPair);
}

const instance = ec2Service.build(config.serverProps);

Environment Specific Configuration That Feels Natural

I’ve found that builder-pattern L3 constructs handle environment differences really elegantly. Each environment can add its own configuration layer without duplicating the base setup:

// Base configuration that applies everywhere
const loadBalancer = new ApplicationLoadBalancerService(this, 'alb', {
app: this.app,
env: this.env,
vpc: this.vpc,
})
.setSubnets(publicSubnets)
.setSecurityGroup(albSecurityGroup)
.build();

// Production gets additional certificates and logging
if (this.env === 'prod') {
loadBalancer
.setExternal()
.createCertificate(hostedZone, 'api.example.com')
.addLoadBalancerListener(true, 443)
.addLoadBalancerListener(false, 80);
} else {
// Non-prod uses simpler HTTP setup
loadBalancer.addLoadBalancerListener(false, 80);
}

Composability I Actually Enjoy

What I really appreciate about these constructs is how they create natural composition boundaries. Complex infrastructure becomes a series of builders that I can mix and match:

// Each service is independently configurable
const cacheService = new ElasticCacheService(this, 'cache', {
app: this.app,
env: this.env,
vpc: this.vpc,
})
.createElasticCacheSecurityGroup(true)
.createSubnetGroup(
cidrs: ['10.110.4.0/24', '10.110.5.0/24'],
subnetType: SubnetType.PRIVATE_WITH_EGRESS
)
.createElasticCacheCluster(
`${this.app}-${this.env}-cache`,
'cache.t3.micro',
1,
'redis'
);

const database = new DatabaseService(this, 'database', {
app: this.app,
env: this.env,
vpc: this.vpc
})
.setEngine('postgres', '13.7')
.enableEncryption()
.build();

// Application servers can connect to both cache and database
const appServer = new Ec2ServerService(this, 'app-server', {
app: this.app,
env: this.env,
vpc: this.vpc
})
.setRole()
.build(serverProps);

// Allow app server to connect to both services
cacheService.getSecurityGroup().addIngressRule(
appServer.getSecurityGroup(),
Port.tcp(6379),
'Allow app server to connect to Redis'
);

database.getSecurityGroup().addIngressRule(
appServer.getSecurityGroup(),
Port.tcp(5432),
'Allow app server to connect to database'
);

Simplified Testing That Actually Works Well

I’ve found that when I use this pattern my constructs are much easier to test because I can instantiate and configure them without actually building the underlying resources:

describe('Ec2ServerService', () => {
test('should configure default role correctly', () => {
const service = new Ec2ServerService(mockStack, 'test', {
app: 'test-app',
env: 'test',
vpc: mockVpc
}).setRole();

expect(service.getRole()).toBeDefined();
expect(service.getRole().roleName).toContain('test-app-test');
});

test('should handle custom security groups', () => {
const service = new Ec2ServerService(mockStack, 'test', {
app: 'test-app',
env: 'test',
vpc: mockVpc
}).setSecurityGroup(mockSecurityGroup);

expect(service.getSecurityGroup()).toBe(mockSecurityGroup);
});
});

Keeping CDK Codebase DRY and SOLID

One of the aspects I appreciate most about using the builder pattern in constructs is how naturally they support DRY (Don’t Repeat Yourself) and SOLID principles. When I need to adapt a construct for specific application needs, I can simply extend the base construct and override the methods that need to change.

Keeping things DRY through Inheritance. Instead of copying and modifying entire constructs, I can create specialized versions that inherit all the base functionality and only customize what’s necessary:

// Base construct with common functionality
export class BaseEc2ServerService extends Construct {
// All the standard builder methods we've seen...

protected getDefaultSecurityGroupRules(): any[] {
return [
{ port: 22, protocol: 'tcp', description: 'SSH access' },
{ port: 80, protocol: 'tcp', description: 'HTTP access' }
];
}

public setRole(): this {
// Standard role creation logic
this.role = new RoleService(this, `${this.app}-${this.env}-role`, {
// Standard configuration
}).createServiceRole(
`${this.app}-${this.env}-ec2-role`,
['ec2.amazonaws.com'],
['AmazonSSMManagedInstanceCore']
);
return this;
}
}

// Specialized construct for database servers
export class DatabaseServerService extends BaseEc2ServerService {
protected getDefaultSecurityGroupRules(): any[] {
// Override to add database-specific rules
return [
...super.getDefaultSecurityGroupRules(),
{ port: 5432, protocol: 'tcp', description: 'PostgreSQL access' },
{ port: 3306, protocol: 'tcp', description: 'MySQL access' }
];
}

public setRole(): this {
// Override to add database-specific permissions
this.role = new RoleService(this, `${this.app}-${this.env}-db-role`, {
// Database-specific configuration
}).createServiceRole(
`${this.app}-${this.env}-db-ec2-role`,
['ec2.amazonaws.com'],
[
'AmazonSSMManagedInstanceCore',
'CloudWatchAgentServerPolicy',
'AmazonRDSEnhancedMonitoringRole' // Database-specific
]
);
return this;
}
}

The builder pattern naturally aligns with SOLID principles:

Single Responsibility Principle: Each method in the builder has one clear purpose. `setRole()` handles IAM, `setSecurityGroup()` handles networking, `setSubnets()` handles placement.

Open/Closed Principle: The base constructs are open for extension but closed for modification. I can add new functionality through inheritance without touching the original code.

Liskov Substitution Principle: Specialized constructs can be used anywhere the base construct is expected, maintaining the same interface.

Here’s a practical example of extending functionality for a specific application:

// Application-specific extension
export class WebAppServerService extends BaseEc2ServerService {
private loadBalancerArn?: string;

// Extend with new functionality
public setLoadBalancer(albArn: string): this {
this.loadBalancerArn = albArn;
return this;
}

// Override build method to add load balancer integration
public build(ec2Props: Ec2Server.ServerProps): this {
// Call parent build first
super.build(ec2Props);

// Add application-specific logic
if (this.loadBalancerArn && this.instance) {
// Register instance with load balancer
this.registerWithLoadBalancer();
}

return this;
}

private registerWithLoadBalancer(): void {
// Load balancer registration logic
// This is specific to web app servers
}

// Override security group setup for web apps
protected getDefaultSecurityGroupRules(): any[] {
return [
...super.getDefaultSecurityGroupRules(),
{ port: 443, protocol: 'tcp', description: 'HTTPS access' },
{ port: 8080, protocol: 'tcp', description: 'Application port' }
];
}
}

Sometimes I need to compose different behaviors rather than extend them. This builder pattern adaptation accommodates this nicely:

export class MonitoredEc2ServerService extends BaseEc2ServerService {
private monitoringService?: CloudWatchMonitoringService;

public enableDetailedMonitoring(config: MonitoringConfig): this {
this.monitoringService = new CloudWatchMonitoringService(this, 'monitoring', {
app: this.app,
env: this.env,
...config
});
return this;
}

public build(ec2Props: Ec2Server.ServerProps): this {
super.build(ec2Props);

// Compose monitoring functionality
if (this.monitoringService && this.instance) {
this.monitoringService
.attachToInstance(this.instance)
.createDashboard()
.setupAlerts();
}

return this;
}
}

The Maintenance Win

This approach has significantly reduced the maintenance burden on my infrastructure code. When I need to update how EC2 instances are configured globally, I modify the base class. When I need application-specific behavior, I extend only what I need to change.

The result is a hierarchy of constructs where each level adds value without duplicating code, and specialized behavior is isolated to where it’s actually needed. This has made our infrastructure codebase much more maintainable as it grows.

Infrastructure for the Whole Team, Using Stage Files and Abstraction

One of the unexpected benefits of using builder-pattern in our constructs with stage file configurations is how it opens up infrastructure management to the whole team. When the complex business logic is encapsulated in the constructs and the configuration is externalized to YAML (or JSON) stage files, system administrators and junior developers can build and modify infrastructure without needing to understand all the underlying CDK complexity.

This pattern creates a natural abstraction layer between the infrastructure logic and its configuration. Here’s how this plays out in practice:

// Complex logic lives in the construct (written once by senior developers)
export class Ec2ServerService extends BaseEc2ServerService {
public build(ec2Props: Ec2Server.ServerProps): this {
// All the complex instance configuration
this.instance = new Instance(this, this.getInstanceName(), {
vpc: this.getVpc(),
role: this.getRole(),
securityGroup: this.securityGroup,
instanceName: this.getInstanceName(),
instanceType: InstanceType.of(
InstanceClass[ec2Props.InstanceClass as keyof typeof InstanceClass],
InstanceSize[ec2Props.InstanceSize as keyof typeof InstanceSize],
),
machineImage: this.getLinuxMachineImage(ec2Props.Linux!),
vpcSubnets: this.subnets,
blockDevices: this.getBlockDevices(),
userData: this.generateUserData(ec2Props)
});

// Complex post-creation logic
this.configureMonitoring();
this.setupAutomatedPatching();
this.configureBackups();

return this;
}
}

# Simple configuration (managed by system administrators)
# stages/prod.yml
servers:
- name: "web-server"
instanceClass: "T3"
instanceSize: "MEDIUM"
keyPair:
name: "prod-web-key"
subnets:
- "10.0.1.0/24"
- "10.0.2.0/24"
monitoring: true
backups:
retention: 30
schedule: "daily"
- name: "api-server"
instanceClass: "C5"
instanceSize: "LARGE"
keyPair:
name: "prod-api-key"
subnets:
- "10.0.3.0/24"
- "10.0.4.0/24"
monitoring: true
backups:
retention: 7
schedule: "hourly"

The stack implementation then becomes declarative and accessible:

// Stack logic is simple and readable (accessible to junior developers)
export class ServerStack extends Stack {
constructor(scope: Construct, id: string, props: StackProps, config: Config) {
super(scope, id, props);

// Simple iteration over configuration
config.servers?.forEach(serverConfig => {
const server = new Ec2ServerService(this, `${serverConfig.name}-service`, {
app: this.app,
env: this.env,
vpc: this.vpc,
})
.setInstanceName(serverConfig.name)
.setKeyPair(serverConfig.keyPair)
.setSubnets(serverConfig.subnets, SubnetType.PRIVATE_WITH_EGRESS);

// Conditional configuration based on stage file
if (serverConfig.monitoring) {
server.enableDetailedMonitoring();
}

if (serverConfig.backups) {
server.configureBackups(serverConfig.backups);
}

server.build({
InstanceClass: serverConfig.instanceClass,
InstanceSize: serverConfig.instanceSize
});
});
}
}

This pattern has created several practical advantages for our team

System Administrators can:

  • Add new servers by copying and modifying YAML entries
  • Change instance sizes, subnets, or monitoring settings without touching TypeScript code
  • Deploy to different environments by modifying stage files
  • Understand what’s being deployed by reading the configuration files

Junior Developers can:

  • Learn infrastructure gradually by starting with stage file modifications
  • Focus on configuration rather than complex AWS service integration
  • Make environment-specific changes confidently
  • Contribute to infrastructure without deep CDK knowledge

Senior Developers can:

  • Focus on building robust, reusable constructs once
  • Easily review configuration changes in YAML
  • Ensure consistent patterns are applied across all infrastructure
  • Spend time on architecture rather than repetitive implementation

Configuration Validation

The builder pattern also makes it easier to add validation and helpful error messages:

export class Ec2ServerService extends BaseEc2ServerService {
public setSubnets(subnetCidrs: string[], subnetType: SubnetType): this {
if (!subnetCidrs?.length) {
throw new Error(`${this.name}: At least one subnet must be specified`);
}

if (subnetCidrs.length < 2 && this.env === 'prod') {
throw new Error(`${this.name}: Production deployments require at least 2 subnets for high availability`);
}

// Implementation...
return this;
}
}

This ensures that configuration errors are caught early with meaningful messages, making it easier for team members at all levels to work with the infrastructure code.

Extensible Stacks Are The Natural Evolution

Once you’ve embraced builder patterns in constructs, the next step is applying similar principles to stacks themselves. Instead of monolithic stack classes, extensible base stacks provide building blocks that can be composed and extended:

export abstract class BaseWebStack extends Stack {
protected vpc: IVpc;
protected securityGroups: Map<string, ISecurityGroup> = new Map();

constructor(scope: Construct, id: string, props: StackProps, config: Config) {
super(scope, id, props);
this.vpc = this.setupNetworking(config);
}

protected abstract setupNetworking(config: Config): IVpc;

protected createSecurityGroup(name: string, description: string): ISecurityGroup {
const sg = new SecurityGroup(this, `${name}-sg`, {
vpc: this.vpc,
description,
securityGroupName: `${this.app}-${this.env}-${name}`
});

this.securityGroups.set(name, sg);
return sg;
}
}

export class ApiStack extends BaseWebStack {
constructor(scope: Construct, id: string, props: StackProps, config: Config) {
super(scope, id, props, config);

// Each stack extension adds its own specialized resources
const apiSecurityGroup = this.createSecurityGroup('api', 'API server security group');

const apiService = new Ec2ServerService(this, 'api-server', {
app: this.app,
env: this.env,
vpc: this.setupNetworking(config)
})
.setSecurityGroup(apiSecurityGroup)
.setRole()
.build(config.apiServer);
}

protected setupNetworking(config: Config): IVpc {
return Vpc.fromLookup(this, 'vpc', { vpcId: config.vpcId });
}
}

The Broader Impact

This simple pattern adds a crucial dimension to CDK development, it makes CDK feel like a natural extension of my application development rather than a separate discipline.

When infrastructure as code follows the same patterns as application code, the cognitive load of switching between them disappears. The same design principles, testing strategies, and refactoring techniques all apply.