Right, let’s talk about the three-tiered cake of abstraction that AWS CDK offers. It’s crucial you understand this, because picking the wrong layer for the job is how you end up with a Rube Goldberg machine of a cloud architecture—impressive to look at, but a nightmare to fix when the hamster powering it gets tired.

At its core, the CDK is a genius compiler that turns your lovely, typed object-oriented code into a gnarly, verbose CloudFormation template. The three layers—L1, L2, and L3—represent how much of that CloudFormation ugliness you, the developer, have to stare at directly.

The L1 (Level 1) Constructs: The Raw Wires

These are the CfnXxx constructs. That Cfn prefix is your clue. It stands for “CloudFormation,” and it means you’re working at the metal. An L1 construct is a direct, one-to-one mapping of a CloudFormation resource. No magic, no helpers, no safety nets.

You use an L1 when:

  1. A spanking-new AWS service just dropped, and the CDK team hasn’t built a friendly L2 for it yet.
  2. You need to configure a property that the higher-level L2 construct, in its infinite wisdom, has decided to abstract away from you (more on this frustration later).
  3. You’re a masochist who finds the verbosity of YAML comforting.

Here’s the stark difference. Creating an S3 bucket with an L1 construct feels like assembling IKEA furniture with the included hex key:

import * as s3 from 'aws-cdk-lib/aws-s3';

// Behold, the raw power of CFN. Enjoy configuring every... single... property.
const myRawBucket = new s3.CfnBucket(this, 'MyCfnBucket', {
  bucketName: 'my-unique-and-manual-bucket-name',
  versioningConfiguration: {
    status: 'Enabled'
  },
  // Want a website? Better type out the whole damn configuration block.
  websiteConfiguration: {
    indexDocument: 'index.html',
    errorDocument: 'error.html'
  }
});

See what I mean? You’re basically writing CloudFormation JSON, but with TypeScript/Java/Python/etc. syntax. It’s powerful because it gives you total control, but it’s verbose and you’re responsible for getting every detail right. Forget a required property? Enjoy the inevitable CloudFormation rollback failure.

The L2 (Level 2) Constructs: The Blessed Abstractions

This is where the CDK starts to earn its keep. L2 constructs are the “intelligent” defaults and best-practice wrappers around the L1s. They provide a much more elegant, object-oriented interface.

The same S3 bucket from above, as an L2 construct:

const mySaneBucket = new s3.Bucket(this, 'MySaneBucket', {
  bucketName: 'my-automatically-unique-bucket-name', // CDK can auto-gen a unique name, thank god
  versioned: true, // A simple boolean! What a concept!
  publicReadAccess: false, // They actively prevent you from shooting your foot off
  websiteIndexDocument: 'index.html', // Nice, simple, dedicated property.
});

Why this is better: The L2 does a ton of work for you. It provides sensible defaults (like blocking public access by default), adds helper methods (bucket.grantRead(user)), and often creates ancillary resources you didn’t even know you needed. It’s not just a wrapper; it’s a best-practice enforcer.

The Rough Edge: Sometimes, the L2 designers are too clever. They’ll hide a CloudFormation property you desperately need because they decided it wasn’t “the right way” to do things. When you hit this wall, you have two options: 1) drop down to the L1 level using myL2Construct.node.defaultChild and cast it to the CfnXxx type to override properties, or 2) weep softly. I usually choose option 1.

The L3 (Level 3) Constructs: The Solutions

Also known as Solutions Constructs, these are multi-service patterns. An L3 construct doesn’t represent a single resource; it creates a whole architecture pattern composed of multiple L2s and L1s.

Think of it as: “I need an API Gateway that writes to a DynamoDB table.” An L3 construct does that in one line, creating not just the API and the table, but also the IAM roles, the mapping templates, and the logging—everything wired together correctly and securely.

import { aws_apigateway, aws_dynamodb } from 'aws-cdk-lib';
import { ApiToDynamoDB } from '@aws-solutions-constructs/aws-apigateway-dynamodb';

// One line to rule them all.
new ApiToDynamoDB(this, 'ApiToDynamoDBPattern', {
  allowCreateOperation: true,
  dynamoTableProps: {
    partitionKey: { name: 'id', type: aws_dynamodb.AttributeType.STRING }
  }
});

This single call creates an API Gateway REST API, a DynamoDB table, a Lambda function for the PUT integration, and all the necessary IAM permissions. It’s breathtakingly powerful.

The Pitfall: The abstraction is a black box. You get what you get. Customizing the specific properties of the underlying API Gateway or the DynamoDB table can be tricky. You have to rely on the parameters the Solutions Constructs authors exposed. They’re fantastic for getting a proven pattern up and running quickly, but for deep, custom work, you’ll often find yourself deconstructing the pattern and building it manually with L2s.

The Best Practice: Start with L2s. Always. They are the sweet spot of power, safety, and clarity. Use L1s to escape hatches when the L2 is being stubborn. Use L3s when you need to implement a well-known pattern quickly and aren’t picky about the minute details. Your goal isn’t to stay purely in one layer—it’s to know how to move between them when the situation demands it.