Skip to content

chinmayto/aws-cdk-constructs-python

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

4 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

AWS CDK Constructs Explained: L1, L2, and L3 with Python

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.


Background: What Is AWS CDK?

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.

What Are CDK Constructs?

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.


The Example: A Hello World API

To make the comparison concrete, we'll build the same thing three times — once per construct level:

alt text

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.


Prerequisites

Install the required packages

pip install -r requirements.txt

L1 — Raw CloudFormation Wrappers

L1 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 — Curated Constructs with Defaults

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 — Reusable Patterns

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.


Side-by-Side Comparison

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

Deploying

# 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 deploy

Test the endpoint:

L1 Construct: alt text

L2 Construct: alt text

L3 Construct: alt text

Check logs in CloudWatch:

  • Lambda logs: /aws/lambda/l2-hello-world
  • API Gateway logs: /aws/apigateway/l2-hello-world

Cleanup

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 destroy

CDK will prompt for confirmation before deleting.

Note: The CDK bootstrap stack (CDKToolkit) is not removed by cdk destroy. You can delete it manually from the CloudFormation console if you no longer need CDK in that account/region.


Conclusion

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.


References

About

No description, website, or topics provided.

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages