Zac Fukuda
081

Props vs. Methods: How to Connect AWS CDK Resources the Right Way

The AWS Cloud Development Kit (AWS CDK) is an open-source software development framework for defining cloud infrastructure in code and provisioning it through AWS CloudFormation.

Cloud infrastructure comprises of many resources. One resource rarely exists alone without any communication to one or more of other resources. CDK provides two ways to associate resources(constructs):.

  1. Constructor props
  2. Construct methods

In a long run, these two solutions give developers their own preferences and tremendous flexibility. In a short run, it, however, brings a confusion. Which one to use?.

Think about the internal team code guideline for a moment. Two options means one developer could code with constructor props; another developer could code with construct methods. Co-existence of two options within one project might lead to chaos. Code guideline is more important than ever in the age of AI. A good code guideline results good outcomes from the AI. Regarding constructor props and construct methods, any team would end up the one of the following two rules:

  1. Always prefer constructor props
  2. Always prefer construct methods

I chose the term “prefer” intentionally, not “use”. Sometimes one construct accepts either of two options. Having used “use” we would be deadlock by the guideline. That’s pity.

Some people might not like it but the empirical people would says “it depends on the context whether you use constructor props or construct methods because they are fundamentally different.”

In this article, I will program CDK resources in both constructor props and methods for the following association cases, then compare which approach is better, or find out when to use which.

  1. S3 – Lambda – S3
  2. VPC – ALB – EC2
  3. WAF – API Gateway – Lambda

What Gemini(AI) Says

Before I diving to compare two options on my own, I’d like to introduce what Gemini said on the issue.

Core Concept

  • Constructor Props: Strict "Up-Front" Binding
  • Construct Methods: Fluid Intent-Based Connection

Construct methods are the practical winner because of three reasons:

  • Least Privilege Built-In: For example, calling .grantRead() doesn't just pass references—it automatically synthesizes the precise under-the-hood IAM policy elements for the execution role behind the scenes.
  • Prevents Circular Dependencies: When resource A needs resource B, and resource B needs to log back to resource A, initializing them upfront with props causes a "chicken-and-egg" compilation error. Association methods break this loop by wiring paths sequentially.
  • Readable Top-to-Bottom Flow: Code reads like a book. Instantiate main blocks at the top, and declare interactions and permissions cleanly at the bottom.

On the other hand, constructor props are immutable blueprints. Constructor props are to be used when the dependency is structural and unchangeable. For instance, An ECS cluster cannot physically exist on AWS without a VPC network container boundary. Therefore, passing VPC via props is correct because VPC is a fundamental requirement of the ECS's existence.

Comparison

The table below is a further summary of constructor props v.s. construct methods by AI.

FeatureConstructor PropsConstruct Methods
Dependency StyleStructuralBehavioral
Circular Dependency RiskHighLow
Code FlexibilityRigidHigh

I am not kind of person who simply buys what someone says. I want to test premise. I hope you, too. Having understood the concepts of the props and methods in CDK, let’s take look at the real use cases.

S3 – Lambda – S3

S3 bucket – Lambda function – S3 bucket

In this section, we will create an S3 bucket and a Lambda function. The 'OBJECT_CREATED' event in the bucket triggers the function that acts on the data just created.

The great example of this S3–Lambda infrastructure is responsive image generation. When a user uploads images, those images will be stored in an S3 bucket. The file sizes of these are usually big, over 1 MB even they are taken by an iPhone. Users do not upload the optimized images for the Internet. But optimized files improves the overall user experience. The Lambda function generates optimized images for you upon the upload of original images, and the store newly created optimized files back into the S3 bucket. The S3 bucket that stores optimized images doesn’t have to be the same bucket that triggers an Lambda event. In order to prevent recursive Lambda invocation, it is recommended to have the different buckets for the source and the target.

In the age of AI, copying the knowledge files to the different bucket or to the different path in a bucket might also an application of S3–Lambda. Allowing AIs or AI frameworks to access all objects in the bucket that is used for day-to-day operation is insecure because AI could learn something confidential and someone could learn that secret information through AI. The Day-to-day bucket and the knowledge base bucket must be separated. To make separation automatic, the Lambda function could be used.

The following subsections shows the example code that implements S3–Lambda. The first subsection for props and the second for the methods. The Lambda code is omitted in the example.

Constructor Props

const bucket = new Bucket(this, 'Bucket', {
  bucketName: 'some-bucket',
  removalPolicy: RemovalPolicy.DESTROY,
});
const s3EventSource = new S3EventSourceV2(bucket, {
  events: [EventType.OBJECT_CREATED],
  filters: [{ prefix: 'original/', suffix: '.txt' }],
});
const s3Function = new NodejsFunction(this, 'S3Function', {
  functionName: 'some-function',
  entry: path.join(__dirname, 'some-function/index.mts'),
  handler: 'handler',
  events: [s3EventSource]
});

bucket.grantReadWrite(s3Function);

Construct Methods

const bucket = new Bucket(this, 'Bucket', {
  bucketName: 'some-bucket',
  removalPolicy: RemovalPolicy.DESTROY,
});
const s3Function = new NodejsFunction(this, 'S3Function', {
  functionName: 'some-function',
  entry: path.join(__dirname, 'some-function/index.mts'),
  handler: 'index.handler',
});
const s3EventSource = new S3EventSourceV2(bucket, {
  events: [EventType.OBJECT_CREATED],
  filters: [{ prefix: 'original/', suffix: '.txt' }],
});

bucket.grantReadWrite(s3Function);
s3Function.addEventSource(s3EventSource);

Comparison

The difference is subtle. We can add event sources of Lambda function after the constructor of NodejsFunction. In the S3–Lambda infrastructure, the main resources are a bucket and a NodejsFunction. You know what they say “First thing first.” Preferring the constructor props, the S3EventSourceV2 needs to be constructed before NodejsFunction. This is not what every one wants.

Regardless whether we use props or methods, the resources that would be created are identical. The difference only exists in code, and our thought. In the method example, the relation between S3 and Lambda are equal. In the case of props, the S3 is like a sole important resource, and the Lambda function is like a servant to the S3—requiring the function what the bucket asks to do.

This relational comparison is exactly what Gemini says: “Constructor props are structural; Construct methods are behavioral.” The problem here is not that behavioral relation is better than structural relation or vice versa. Each team ought to decide which one to use based on their intention. “Do we use a Lambda function solely for S3? Or, can we use the same Lambda function for across other resources?” That’s the question.

VPC – ALB – EC2

VPC – ALB – EC2

Our next use case of infrastructure is ALB and EC2, which lie under the same VPC. Networking from ALB to EC2 is classic infrastructure for web application.

Each example blow creates only one EC2 instance. The ALB transfers all traffic to that instance—which makes ALB with no purpose. In real life many instances of EC2 would be created. The example, nonetheless, is intended to show the point of props and methods. Each example looks up the default VPC in the region by calling .fromLookup() method instead of creating a new one.

Constructor Props

const vpc = Vpc.fromLookup(this, 'Vpc', { isDefault: true });
const userData = UserData.forLinux();
const instanceSecurityGroup = new SecurityGroup(this, 'InstanceSecurityGroup', {
  securityGroupName: 'some-instance-security-group',
  vpc,
  allowAllOutbound: true,
});
const albSecurityGroup= new SecurityGroup(this, 'AlbSecurityGroup', {
  securityGroupName: 'some-alb-security-group',
  vpc,
});
const instance = new Instance(this, 'Instance', {
  instanceName: 'some-instance',
  vpc,
  vpcSubnets: { subnetType: SubnetType.PUBLIC },
  instanceType: InstanceType.of(InstanceClass.T4G, InstanceSize.NANO),
  machineImage: MachineImage.latestAmazonLinux2023({
    cachedInContext: false,
    cpuType: AmazonLinuxCpuType.ARM_64,
  }),
  userData,
  securityGroup: instanceSecurityGroup,
});
const alb = new ApplicationLoadBalancer(this, 'Alb', {
  loadBalancerName: 'some-alb',
  vpc,
  vpcSubnets: { subnetType: SubnetType.PUBLIC },
  internetFacing: true,
  securityGroup: albSecurityGroup,
});
const targetGroup = new ApplicationTargetGroup(this, 'ApplicationTargetGroup', {
  targetGroupName: 'some-alb-target-group',
  port: 80,
  targets: [new InstanceTarget(instance)],
  vpc,
});
const listener = new ApplicationListener(this, 'ApplicationListener', {
  loadBalancer: alb,
  port: 80,
  open: true,
  defaultTargetGroups: [targetGroup]
});

userData.addCommands(
  'sudo dnf update -y',
  'sudo dnf install nginx -y',
  'sudo systemctl start nginx',
  'sudo systemctl enable nginx'
);
instanceSecurityGroup.addIngressRule(Peer.anyIpv4(), Port.tcp(22));
instanceSecurityGroup.addIngressRule(Peer.securityGroupId(albSecurityGroup.securityGroupId), Port.tcp(80));
albSecurityGroup.addIngressRule(Peer.anyIpv4(), Port.tcp(80));

Construct Methods

const vpc = Vpc.fromLookup(this, 'Vpc', { isDefault: true });
const instance = new Instance(this, 'Instance', {
  instanceName: 'some-instance',
  vpc,
  vpcSubnets: { subnetType: SubnetType.PUBLIC },
  instanceType: InstanceType.of(InstanceClass.T4G, InstanceSize.NANO),
  machineImage: MachineImage.latestAmazonLinux2023({
    cachedInContext: false,
    cpuType: AmazonLinuxCpuType.ARM_64,
  }),
});
const alb = new ApplicationLoadBalancer(this, 'Alb', {
  loadBalancerName: 'some-alb',
  vpc,
  vpcSubnets: { subnetType: SubnetType.PUBLIC },
  internetFacing: true,
});
const targetGroup = new ApplicationTargetGroup(this, 'ApplicationTargetGroup', {
  targetGroupName: 'some-alb-target-group',
  port: 80,
  vpc,
});

instance.addUserData(
  'sudo dnf update -y',
  'sudo dnf install nginx -y',
  'sudo systemctl start nginx',
  'sudo systemctl enable nginx'
);
alb.addListener('Listener', {
  port: 80,
  open: true,
  defaultTargetGroups: [targetGroup]
});
targetGroup.addTarget(new InstanceTarget(instance));

const instanceSecurityGroup = new SecurityGroup(this, 'InstanceSecurityGroup', {
  securityGroupName: 'some-instance-security-group',
  vpc,
  allowAllOutbound: true,
});
const albSecurityGroup= new SecurityGroup(this, 'AlbSecurityGroup', {
  securityGroupName: 'some-alb-security-group',
  vpc,
});
instance.addSecurityGroup(instanceSecurityGroup);
alb.addSecurityGroup(albSecurityGroup);
instanceSecurityGroup.addIngressRule(Peer.anyIpv4(), Port.tcp(22));
instanceSecurityGroup.addIngressRule(Peer.securityGroupId(albSecurityGroup.securityGroupId), Port.tcp(80));
albSecurityGroup.addIngressRule(Peer.anyIpv4(), Port.tcp(80));

Comparison

The difference between props and methods is much more than in the case of S3-Lambda. This is because more resources(constructs) are involved and those resources require associations.

Both EC2 instance and ALB require VPC to be created. Hence the VPC must be passed as constructor props. Besides VPC, the associations to the other related resources for EC2 instance and ALB can be added by methods later.

One of benefits preferring methods to the props is that we won’t have to care about the order of construction. First we can construct main resources like VPC, EC2 instance, and ALB. Afterwards we can create secondary assistive resources like target groups and security groups.

Being sloppy, we would find ourselves unable to figure out where the associations occur in the code. The methods gives us flexibility. Nevertheless, method invocation to connect two resources shall happen near to construction.

WAF – API Gateway – Lambda

WAF – API Gateway REST API – Lambda function

The last use case of our infrastructure is API Gateway that integrates with Lambda functions. As a bonus we add WAF to increase the number of resource and association—and security.

The combination of API Gateway and Lambda is a common use case for serverless infrastructure. Going serverless, developes will be able to spend less time for server management and more time for coding—if they still code as engineers.

The example creates a RestApi. The API has /booksresource and /books/{id} resource. The both resources accept only GET method. The GET methods of the resources are integrated with the same Lambda function. The Lambda code is omitted from the example for the sake of simplicity. The WAF service in CDK does not yet have official L2 constructs. To create a WebACL we need CfnWebACL. The WebACL doesn’t have any rule, and it allows all traffic to the REST API. WAF, in the example, has no functional purpose but only illustrational purpose.

Constructor Props

const apiFunction = new NodejsFunction(this, 'ApiFunction', {
  functionName: 'some-api-function',
  entry: path.join(__dirname, 'apigateway-function/index.mjs'),
});
const restApi = new RestApi(this, 'RestApi', {
  restApiName: 'some-rest-api',
});
const webAcl = new CfnWebACL(this, 'WebACL', {
  name: 'some-web-acl',
  defaultAction: { allow: {} },
  scope: 'REGIONAL',
  visibilityConfig: {
    cloudWatchMetricsEnabled: false,
    metricName: 'some-web-acl',
    sampledRequestsEnabled: false,
  }
});
const association = new CfnWebACLAssociation(this, 'WebACLAssociation', {
  resourceArn: restApi.deploymentStage.stageArn,
  webAclArn: webAcl.attrArn,
});
const booksResource = new Resource(this, 'BooksResource', {
  parent: restApi.root,
  pathPart: 'books',
});
const bookIdResource = new Resource(this, 'BookIdResource', {
  parent: booksResource,
  pathPart: '{id}',
});
const lambdaIntegration = new LambdaIntegration(apiFunction);

new Method(this, 'Any', {
  httpMethod: 'ANY',
  resource: restApi.root,
});
new Method(this, 'GetBook', {
  httpMethod: 'GET',
  resource: booksResource,
  integration: lambdaIntegration,
});
new Method(this, 'GetBookId', {
  httpMethod: 'GET',
  resource: bookIdResource,
  integration: lambdaIntegration,
});

Construct Methods

const apiFunction = new NodejsFunction(this, 'ApiFunction', {
  functionName: 'some-api-function',
  entry: path.join(__dirname, 'apigateway-function/index.mjs'),
});
const restApi = new RestApi(this, 'RestApi', {
  restApiName: 'some-rest-api',
});
const webAcl = new CfnWebACL(this, 'WebACL', {
  name: 'some-web-acl',
  defaultAction: { allow: {} },
  scope: 'REGIONAL',
  visibilityConfig: {
    cloudWatchMetricsEnabled: false,
    metricName: 'some-web-acl',
    sampledRequestsEnabled: false,
  }
});
const association = new CfnWebACLAssociation(this, 'WebACLAssociation', {
  resourceArn: restApi.deploymentStage.stageArn,
  webAclArn: webAcl.attrArn,
});

const books = restApi.root.addResource('books');
const bookId = books.addResource('{id}');
const lambdaIntegration = new LambdaIntegration(apiFunction);
books.addMethod('GET', lambdaIntegration);
bookId.addMethod('GET', lambdaIntegration);

Comparison

Here, whether to prefer props or methods doesn’t make much difference. Either way, a Resource requires a parent Resource, and a Method requires a Resource. We won’t have much control over the order of construction or method invocation. The choice depends on the personal preference. I assume many engineers prefer the latter.

If there were one thing that constructor props are better than methods, that would be that new keywords are more explicit about some resources were being actually created. For instance, we would never know if the .addResource() method returns a Resource instance or void unless we have type hint from IDEs or read document.

Conclusion

General conclusion is this: Prefer construct methods to associate resources if possible.

Most of the time, following the example code from the official library documentation leads to the right track. I often forgot to read the overview page in each service from the doc, during the development, struggling how to define infrastructure, and the answer is right in the overviews. Not just reading the construct pages, read the overview pages. The overview pages provide many use cases of constructs.

At the same time overviews are just overviews. We must also read the Methods section in each construct page. When I was writing the example code above, I needed to check the official document, for each construct, to see if one construct has .add() method to create resource association. This work is time consuming and error-prone to code against the internal guideline. The additional caution is necessary.

One of pitfalls of preferring methods is the ambiguity in when the association occurs. S3 or RDS are services that could shared by many resources. Connection to those resources could easily lost from the team members. Even using construct methods, construct methods are encouraged to be invoked before the sight on the construction of instance would be lost.

Time to use constructor props is when two resources are tight-coupling—when one resource cannot exists without another. EC2 instance requires VPC, ECS service requires task definition, and so on. The dependency must be constructed before dependant, even though the dependency is not our primary concern in the infrastructure.

Next

What’s most important in CDK development is governance. Replacing constructor props with construct methods wouldn’t improve the readability of code drastically. With the best efforts of the best engineers, the CDK codes would be complicated, not because they are lazy or stupid but because the infrastructure is complicated.

In my option, the first step towards governance in CDK project is directory structure. Service based directory structure sooner or later will fail. To oppose I came up with Clean Scalable Directory Structure, which readers might be interested.

Resources