If you've ever looked at two CDK stacks doing the same thing and wondered why one is 200 lines and the other is 20, the answer is almost always which level of construct the author chose. This post breaks down all three levels — L1, L2, and L3 — using a consistent real-world example so you can see exactly what each abstraction buys you.
The AWS Cloud Development Kit (CDK) is an open-source framework that lets you define cloud infrastructure using familiar programming languages — Python, TypeScript, Java, and more. Under the hood, CDK synthesizes your code into AWS CloudFormation templates, which are then used to provision resources.
The key advantage over raw CloudFormation YAML is that you get the full power of a programming language: loops, conditionals, type checking, reusable classes, and IDE support. Instead of copying and pasting 300 lines of YAML every time you need a Lambda function, you write a Python class once and reuse it everywhere.
CDK apps are organized around three concepts:
- App — the root of your CDK application
- Stack — maps to a CloudFormation stack; a unit of deployment
- Construct — the building block. Everything in CDK is a construct.
Constructs are the core abstraction in CDK. Every resource you define — a Lambda function, an S3 bucket, an API Gateway — is a construct. They form a tree: your App contains Stacks, Stacks contain constructs, and constructs can contain other constructs.
CDK ships with three levels of constructs, each offering a different trade-off between control and convenience:
| Level | Class prefix | What it represents | Boilerplate |
|---|---|---|---|
| L1 | Cfn* |
Raw CloudFormation resource, 1:1 mapping | Maximum |
| L2 | e.g. Function, RestApi |
Curated resource with defaults and helpers | Medium |
| L3 | Custom class or aws-solutions-constructs |
Opinionated, reusable multi-resource pattern | Minimum |
Understanding when to use each level is one of the most practical skills you can develop as a CDK user.
To make the comparison concrete, we'll build the same thing three times — once per construct level:
A REST API with a single GET /hello endpoint, backed by a Lambda function, with CloudWatch Logs capturing both API Gateway access logs and Lambda execution logs.
Install the required packages
pip install -r requirements.txtL1 constructs are the lowest level. Every class is prefixed with Cfn (short for CloudFormation) and maps directly to a CloudFormation resource type. If a property exists in CloudFormation, it exists on the L1 construct — nothing more, nothing less.
Working with L1 feels a lot like writing CloudFormation YAML, just in Python. You are responsible for everything: IAM roles, resource dependencies, Lambda invoke permissions, deployment ordering.
When to reach for L1:
- A property you need isn't exposed by the L2 construct yet
- You're migrating an existing CloudFormation template to CDK
- You need precise control over every resource attribute
# Every piece is explicit — IAM role, log group, function, permissions...
lambda_role = iam.CfnRole(
self, "LambdaRole",
assume_role_policy_document={
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": {"Service": "lambda.amazonaws.com"},
"Action": "sts:AssumeRole",
}],
},
managed_policy_arns=[
"arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
],
)
lambda_log_group = CfnLogGroup(
self, "LambdaLogGroup",
log_group_name="/aws/lambda/l1-hello-world",
retention_in_days=7,
)
lambda_fn = CfnFunction(
self, "HelloFunction",
runtime="python3.12",
handler="index.handler",
role=lambda_role.attr_arn,
code=CfnFunction.CodeProperty(zip_file="..."),
logging_config=CfnFunction.LoggingConfigProperty(
log_group=lambda_log_group.log_group_name,
log_format="JSON",
),
)
lambda_fn.add_dependency(lambda_log_group) # you manage ordering manually
# API Gateway — every piece by hand
rest_api = CfnRestApi(self, "HelloApi", name="l1-hello-world-api")
hello_resource = CfnResource(
self, "HelloResource",
rest_api_id=rest_api.ref,
parent_id=rest_api.attr_root_resource_id,
path_part="hello",
)
# + CfnMethod, CfnDeployment, CfnStage, CfnPermission, CfnAccount...Pros: Total control. No hidden behavior. Great for auditing exactly what gets deployed.
Cons: Extremely verbose. You own every IAM permission, every add_dependency(), every Fn.sub() for ARN construction. Easy to miss something.
L2 constructs are the sweet spot for most CDK users. They wrap L1 constructs with sensible defaults, helper methods, and automatic wiring. CDK generates IAM roles, adds Lambda invoke permissions, links log groups, and creates deployments — all without you asking.
The mental model shifts from "how do I configure this resource" to "what do I want this resource to do."
When to reach for L2:
- Day-to-day infrastructure work (this should be your default)
- You want secure defaults without writing boilerplate
- You want IDE autocomplete and type safety on resource props
# CDK creates the execution role automatically
hello_fn = _lambda.Function(
self, "HelloFunction",
runtime=_lambda.Runtime.PYTHON_3_12,
handler="index.handler",
code=_lambda.Code.from_inline("..."),
timeout=Duration.seconds(10),
log_group=lambda_log_group, # just pass the log group, CDK links it
)
# LambdaIntegration automatically adds the resource-based policy
api = apigw.RestApi(
self, "HelloApi",
deploy_options=apigw.StageOptions(
access_log_destination=apigw.LogGroupLogDestination(apigw_log_group),
access_log_format=apigw.AccessLogFormat.json_with_standard_fields(...),
logging_level=apigw.MethodLoggingLevel.INFO,
),
)
hello_resource = api.root.add_resource("hello")
hello_resource.add_method("GET", apigw.LambdaIntegration(hello_fn))Pros: Dramatically less code. Secure defaults. Typed props with IDE support. Handles the boring stuff.
Cons: Occasionally a property isn't exposed yet. When that happens, you escape to L1 via .node.default_child — a bit awkward but workable.
L3 constructs are where CDK really shines for teams. They're custom constructs you build by composing L2 constructs into a higher-level, opinionated pattern. The goal is to encode your team's standards — logging, security, tagging, naming conventions — once, and reuse them everywhere.
Think of L3 as your internal infrastructure library. Instead of every developer wiring up Lambda + API Gateway + CloudWatch from scratch, they instantiate your LambdaApiService and get all of that for free.
AWS also ships a set of official L3 patterns via the AWS Solutions Constructs library — pre-built, well-architected combinations like aws-apigateway-lambda or aws-lambda-dynamodb.
When to reach for L3:
- You find yourself copy-pasting the same L2 pattern across multiple stacks
- You want to enforce team standards (logging, retention, tagging) in one place
- You're building a platform that other teams consume
class LambdaApiService(Construct):
"""
One construct = Lambda + API Gateway + CloudWatch Logs.
All wiring is internal. Consumers just pass props.
"""
def __init__(self, scope: Construct, id: str, *, props: LambdaApiServiceProps):
super().__init__(scope, id)
# All the L2 wiring happens here, once, for everyone
lambda_log_group = logs.LogGroup(self, "LambdaLogs", ...)
self._function = _lambda.Function(self, "Function", log_group=lambda_log_group, ...)
apigw_log_group = logs.LogGroup(self, "ApiGwLogs", ...)
self._api = apigw.RestApi(self, "Api", deploy_options=apigw.StageOptions(
access_log_destination=apigw.LogGroupLogDestination(apigw_log_group), ...
))
resource = self._api.root.add_resource(props.path)
resource.add_method("GET", apigw.LambdaIntegration(self._function))
self.endpoint_url = f"{self._api.url}{props.path}"Using it in a stack becomes trivial:
class L3HelloWorldStack(Stack):
def __init__(self, scope: Construct, id: str, **kwargs):
super().__init__(scope, id, **kwargs)
# One line gets you Lambda + API GW + CloudWatch, fully wired
service = LambdaApiService(
self, "HelloService",
props=LambdaApiServiceProps(
function_name="l3-hello-world",
handler_code=HANDLER_CODE,
api_name="l3-hello-world-api",
path="hello",
),
)
CfnOutput(self, "ApiUrl", value=service.endpoint_url)Pros: Maximum reuse. New services inherit logging, IAM, and API Gateway automatically. Standards enforced at the construct level, not by convention.
Cons: Upfront design investment. Over-abstraction can hide details that matter. Needs versioning if shared across teams.
| L1 | L2 | L3 | |
|---|---|---|---|
| Lines of code (this example) | ~130 | ~60 | ~40 stack + ~60 construct |
| IAM roles | Manual | Auto-generated | Auto-generated |
| Lambda permissions | Manual CfnPermission |
LambdaIntegration handles it |
Encapsulated |
| Log group linking | Manual add_dependency |
log_group= prop |
Encapsulated |
| Reusability | None | Low | High |
| Escape hatch needed | Never | Rarely | Rarely |
| Best for | Migration / edge cases | Day-to-day work | Platform / shared libraries |
# Bootstrap your AWS account (once per account/region)
# Run from inside any of the construct folders
cd l2_construct $&& cdk bootstrap
# Deploy the constructs
cd l1_construct && cdk deploy
cd l2_construct && cdk deploy
cd l3_construct && cdk deployTest the endpoint:
Check logs in CloudWatch:
- Lambda logs:
/aws/lambda/l2-hello-world - API Gateway logs:
/aws/apigateway/l2-hello-world
To avoid ongoing charges, destroy the stacks when you're done:
cd l1_construct && cdk destroy
cd l2_construct && cdk destroy
cd l3_construct && cdk destroyCDK will prompt for confirmation before deleting.
Note: The CDK bootstrap stack (
CDKToolkit) is not removed bycdk destroy. You can delete it manually from the CloudFormation console if you no longer need CDK in that account/region.
CDK constructs aren't a hierarchy where L3 is always better — they're tools for different situations. L1 gives you the full CloudFormation surface area with no magic. L2 handles the 80% case cleanly with sensible defaults. L3 is where you invest once and multiply the value across your entire team.
A practical rule of thumb: start with L2, drop to L1 when you hit a wall, and promote repeated L2 patterns into L3 constructs when you find yourself copy-pasting. That progression naturally leads to a clean, maintainable infrastructure codebase.
The real power of CDK isn't any single construct level — it's that all three levels compose together seamlessly in the same stack.
- GitHub repo with all code from this post: https://github.com/chinmayto/aws-cdk-constructs-python
- AWS CDK Python API Reference
- AWS CDK Construct Library
- AWS Solutions Constructs — official L3 patterns
- CDK Patterns — community-contributed patterns



