Right, let’s get our hands dirty with the building blocks of the CDK. Forget the dry, academic definitions for a moment. Think of it like this: you’re not just writing configuration; you’re writing an application whose sole purpose is to synthesize the most mind-bogglingly complex CloudFormation templates you’ve ever seen, so you never have to look at them directly. It’s a beautiful act of delegation.

At its core, the CDK has a hierarchy. You start with the big picture and drill down into the specifics. Getting this structure right from the beginning saves you from a world of pain later.

The App: Your Application’s Container

Everything begins with the App. It’s the root of your CDK universe, the container that holds all your infrastructure code. You don’t do much with it directly; its main job is to act as a logical boundary and to be the entry point for the CDK CLI to figure out what it needs to deploy.

When you run cdk deploy, the CLI looks for that App instance and says, “Alright, show me what you’ve got inside.” You’ll usually only see it once in your code, right at the top of your main entry point. It feels almost too simple, but that’s the point.

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

// This is it. This is the App. No fanfare, just a new instance.
const app = new cdk.App();

// Now we'll start adding Stacks to it (which we'll cover next).
new MySuperCoolStack(app, 'MySuperCoolStack');

The App object is responsible for the synthesis process, which is just a fancy word for “converting all your beautiful TypeScript/Python/whatever code into a JSON template that CloudFormation can actually understand.” You can hook into this process for advanced use cases, but for 95% of what you’ll do, just creating it and forgetting about it is the right move.

Stacks: The Units of Deployment

If the App is the box, a Stack is a deployable item inside that box. This is the most important concept to grasp early on. A Stack maps directly to a CloudFormation stack. When you deploy, you deploy a Stack. This means everything within a single Stack is deployed, updated, or deleted together.

Why should you care? Because CloudFormation has hard limits. The most famous one is the 500-resource limit per stack. Hitting this limit is a rite of passage for CDK developers. You’ll be merrily defining infrastructure, cdk deploy will be humming along, and then… bam. A deployment failure because you tried to create one too many S3 buckets. The solution? Break your monster infrastructure into multiple, smaller Stacks.

Here’s how you define one. Notice the scope (this) and the unique ID. The ID is what will show up in the CloudFormation console, so make it meaningful.

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

class MyWebsiteStack extends cdk.Stack {
  constructor(scope: cdk.App, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    // All your resource definitions will go here.
    // This is where the magic happens.
  }
}

const app = new cdk.App();
new MyWebsiteStack(app, 'ProdWebsiteStack');

The optional StackProps is your key to configuring where this stack gets deployed, which brings us to our next point.

Environments: Where Does This Thing Deploy?

This is where everyone gets tripped up at first. In CDK, an “Environment” isn’t some object you create. It’s simply a property on a Stack, defined by the env property within StackProps. An environment is uniquely identified by an AWS Account and Region.

The most important best practice I can give you: Always explicitly define your environment for production stacks. If you don’t, you get what’s called “environment-agnostic” stacks. These are a nightmare for certain operations because CloudFormation can’t accurately determine if a “physical” resource (like an S3 bucket name) already exists in a different account. Just be explicit. Trust me.

// Be specific. Your future self will thank you.
new MyWebsiteStack(app, 'ProdWebsiteStack', {
  env: {
    account: '123456789012',
    region: 'us-east-1',
  },
});

// You can use environment variables or context to avoid hardcoding.
// This is a much better idea.
new MyWebsiteStack(app, 'ProdWebsiteStack', {
  env: {
    account: process.env.CDK_DEFAULT_ACCOUNT,
    region: process.env.CDK_DEFAULT_REGION,
  },
});

The CDK CLI is smart. It will look at your current AWS credentials and populate CDK_DEFAULT_ACCOUNT and CDK_DEFAULT_REGION for you, making it easy to deploy to the account you’re logged into without hardcoding values into your app.

Constructs: The Real Workhorses

Now for the fun part. Constructs are the basic building blocks of AWS infrastructure. They represent one or more AWS resources. A Bucket is a construct. A Function is a construct. They can be low-level (L1), fine-grained (L2), or patterns (L3).

  • L1 Constructs: These are direct, un-opinionated representations of CloudFormation resources. Their names are prefixed with Cfn, like CfnBucket. You have to configure every single property, and it feels a lot like writing YAML, but in code. Use these when the higher-level constructs don’t expose a setting you absolutely need.
  • L2 Constructs: This is the CDK’s sweet spot. These are the intelligent, opiniated constructs you’ll use most often. They come with sensible defaults, boilerplate security best practices, and grant methods that make IAM a slightly less painful experience. An L2 Bucket creates not just the S3 bucket but also, if you use bucket.grantRead(), the IAM policies needed to read from it.
import * as s3 from 'aws-cdk-lib/aws-s3';
import * as lambda from 'aws-cdk-lib/aws-lambda';

// L2 Constructs are a delight.
const myBucket = new s3.Bucket(this, 'MyBucket', {
  versioned: true,
  encryption: s3.BucketEncryption.S3_MANAGED,
});

const myFunction = new lambda.Function(this, 'MyFunction', {
  runtime: lambda.Runtime.NODEJS_18_X,
  handler: 'index.handler',
  code: lambda.Code.fromAsset('lambda'),
});

// See? Magic. This one line creates the IAM policy allowing the function to read the bucket.
myBucket.grantRead(myFunction);

The beauty of constructs is that they are composable. You can group a set of related resources into your own custom construct—like a UserService that creates a DynamoDB table and a Lambda function—and then reuse it across stacks or even across projects. This is how you truly move from writing infrastructure as code to writing infrastructure from code.