skip to content

In the AWS CDK, what are L1, L2 and L3 constructs, how do you recognise an L1 class in code, and what do you give up by dropping to one?

level: juniorimportance: must knowfreq 78%

answer

  1. three rungs, not three products
  2. the Cfn prefix means generated
  3. L2 adds defaults and grant helpers
  4. L3 patterns emit a whole solution
  5. drop to L1 when coverage is missing

basics

~20 s

AWS CDK constructs come in three levels: L1 Cfn classes mirror CloudFormation resources one-to-one with no defaults, L2 constructs add sensible defaults and helper methods such as the grant helpers, and L3 patterns assemble many resources into one opinionated component.

solid answer

~40 s

CDK constructs sit on three rungs. **L1** constructs are generated from the CloudFormation resource specification: the class name starts with `Cfn`, its props mirror the CloudFormation properties one-for-one, nothing is defaulted, and there are no helper methods. **L2** constructs are hand-written wrappers — `s3.Bucket`, `lambda.Function` — that pick sensible defaults, create the supporting resources you always need such as a Lambda execution role, and expose intent methods like `bucket.grantRead(fn)`. **L3** constructs, usually called patterns, compose a whole solution: `ApplicationLoadBalancedFargateService` from `aws-ecs-patterns` emits a cluster, service, task definition, load balancer, target group and listener from a handful of props. You drop to L1 when no L2 exists yet or a property has not been surfaced, and you accept that you are writing CloudFormation with types: every required field by hand, and no grant helpers.

code

typescript · 22 lines
typescript
import * as cdk from 'aws-cdk-lib';
import * as s3 from 'aws-cdk-lib/aws-s3';
import { Construct } from 'constructs';

export class LevelsStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    // L1: mirrors AWS::S3::Bucket; nothing is defaulted for you
    new s3.CfnBucket(this, 'RawBucket', {
      versioningConfiguration: { status: 'Enabled' },
    });

    // L2: defaults plus intent methods
    const data = new s3.Bucket(this, 'DataBucket', {
      versioned: true,
      removalPolicy: cdk.RemovalPolicy.RETAIN,
    });

    new cdk.CfnOutput(this, 'DataBucketName', { value: data.bucketName });
  }
}

go deeper

for a junior

Be able to name the three levels and spot an L1 by its Cfn prefix. Say plainly that L2 constructs such as s3.Bucket give you defaults and helper methods that the generated classes do not.

for a middle

Explain where L1 classes come from — generated from the CloudFormation resource specification — and show that a single L2 construct often synthesizes into several template resources, such as a function plus its execution role.

for a senior

Show judgment about inherited defaults. An ec2.Vpc with no props bills a NAT gateway per Availability Zone, and a bucket is orphaned rather than deleted on stack destroy. Name the defaults you routinely override in a real account.

for a principal

Own the policy for the estate: which levels teams may use, whether L3 patterns are adopted or discouraged, and how you keep moving when a newly launched service has only an L1. Frame it as a support-cost decision, not a taste one.

## Why there are levels at all A CDK app is a program that emits a CloudFormation template. AWS ships new resource types and new properties on existing types continuously, and no hand-written wrapper library can keep pace. The CDK's answer is two generations of construct in the same package: a machine-generated layer that covers everything, and a curated layer that covers the common things well. The levels are not product tiers — they are different authoring economics, and a normal stack mixes them. ## L1: generated, complete, unopinionated L1 constructs are code-generated from the CloudFormation resource specification. There is one class per CloudFormation resource type, its name is the type with a `Cfn` prefix (`AWS::S3::Bucket` becomes `CfnBucket`, `AWS::DynamoDB::Table` becomes `CfnTable`), and its props are the CloudFormation properties in lowerCamelCase. What CloudFormation requires, the class requires. What CloudFormation defaults, CloudFormation still defaults — the CDK adds nothing. ```ts new s3.CfnBucket(this, 'RawBucket', { versioningConfiguration: { status: 'Enabled' }, }); ``` The upside is coverage and predictability: an L1 is available as soon as the resource type is public, and the template it produces is exactly the properties you typed. The downside is that you are writing CloudFormation in a typed language. There is no `grantRead`, no automatic security group, no sane default — those all live one rung up. ## L2: curated, defaulted, with intent methods L2 constructs are written by hand for the services people actually use. They do three things an L1 does not. They choose defaults, so `new s3.Bucket(this, 'Data')` is a usable bucket rather than a compile error about missing properties. They create the supporting resources that always go with the main one — a `lambda.Function` creates the execution role and attaches the `AWSLambdaBasicExecutionRole` managed policy so the function can write logs, which is why one L2 construct routinely synthesizes into several template resources. And they expose methods that say what you mean instead of what CloudFormation stores: `bucket.grantRead(fn)`, `queue.grantSendMessages(fn)`, `table.grantReadWriteData(fn)`. L2s also carry static import methods such as `s3.Bucket.fromBucketName(...)` for referring to resources this app does not create. ## L3: patterns L3 constructs — the docs call them patterns — are opinionated compositions of L2s aimed at a use case rather than a resource. `ApplicationLoadBalancedFargateService` takes a container image and gives you the cluster, service, task definition, Application Load Balancer, listener and target group already wired together. They are excellent for a demo, a spike, or a fleet of genuinely uniform services, and they get uncomfortable the moment your requirements diverge from the pattern author's assumptions. ## Mixing levels is normal Nothing forces one level per stack. A typical real stack is mostly L2, with an L1 for the service that has no wrapper yet, and possibly one L3 that nobody has needed to bend. Every L2 is itself built from L1s: the construct tree beneath an `s3.Bucket` contains a `CfnBucket`, reachable as its default child, which is why you can always fall back to raw CloudFormation semantics without abandoning the L2. ## The defaults are decisions someone else made The convenience of L2 is also its trap: a default you never typed is still a default you deployed. `new ec2.Vpc(this, 'Vpc')` with no props spans up to three Availability Zones with public and private subnets and, by default, **one NAT gateway per Availability Zone** — three NAT gateways on the bill for a dev environment. An `s3.Bucket` defaults to being orphaned rather than deleted when the stack is destroyed, which is friendly for production data and confusing when a throwaway stack keeps leaving buckets behind. The senior answer to "which level do you use" is therefore not "L2 always". It is: prefer L2 because the helper methods and supporting resources are where the real time is saved, read the synthesized template at least once so you know what the defaults chose, use L1 without embarrassment where coverage is missing, and treat L3 patterns as a decision with a cost rather than a shortcut with none.

  • Where do L1 constructs come from, and why does that matter for a service AWS launched last week?
    They are code-generated from the CloudFormation resource specification, one class per resource type. That means an L1 exists as soon as the resource type is public, while a curated L2 has to be written and released by hand. For a brand-new service the L1 is often the only option, and reaching for it is normal rather than a workaround.
  • Can a single L2 construct produce more than one resource in the template?
    Routinely. A `lambda.Function` emits the function plus an IAM role with the basic execution managed policy attached; an `s3.Bucket` with an event notification emits the bucket, a notification configuration and the resources needed to wire it. That is why reading the synthesized template once, before your first real deploy, is worth the ten minutes.
  • What is the risk of accepting L2 defaults without reading them?
    You deploy decisions you never made. `new ec2.Vpc(this, 'Vpc')` gives you a NAT gateway per Availability Zone — three of them by default — which is a real monthly bill in a dev account. An `s3.Bucket` is orphaned rather than deleted when the stack goes away. Defaults are chosen for a sensible production case, not for your case.

L1 is the parts bin, L2 is a pre-assembled component with the fasteners included, and L3 is the flat-pack kit that arrives as one box and expects you to want exactly that piece of furniture.

saying these in an interview costs you the question

  • Claims L1 constructs offer grant helpers like L2 constructs do
  • Says L3 patterns are just L2 constructs with more props
  • Assumes one construct always equals one template resource
  • Cannot say what an L1 gives up: no defaults, no methods
  • Treats mixing construct levels in one stack as wrong

context