luizmachado.dev

PT EN

Session 005 — CDK: Constructs L1, L2, L3 — what they are and how to choose

Estimated duration: 60 minutes
Prerequisites: session-004 — CDK v2: setup, bootstrap and project structure


Objective

By the end, you will be able to distinguish L1 (CfnBucket), L2 (Bucket) and L3 constructs (patterns like ApplicationLoadBalancedFargateService), navigate the Construct Hub to evaluate third-party constructs, and explain why L2 is not always preferable to L1.


Context

[FACT] The three-layer abstraction model (L1/L2/L3) is the core of CDK's value proposition. It defines how the construct library is organized and determines the level of control vs. convenience in each situation.

[CONSENSUS] Most engineers starting with CDK adopt L2 by default and only drop down to L1 when something doesn't work. This approach works, but leaves gaps: you end up not understanding what L2 generates underneath, which makes debugging harder and escape hatches mysterious. Understanding all three levels — and when each is appropriate — is what separates superficial CDK usage from competent CDK usage.


Key concepts

1. The abstraction hierarchy

L3 — Patterns (alta abstração)
  Composição de múltiplos recursos pré-configurados
  Ex: ApplicationLoadBalancedFargateService
       └── cria: ECS Cluster + Fargate Service + ALB + Target Group
                + Listener + Security Groups + IAM Roles + Log Group

L2 — Intent-Based (abstração média)
  Um recurso AWS com defaults seguros e helpers
  Ex: s3.Bucket
       └── encapsula: AWS::S3::Bucket (L1)
       └── gera automaticamente: BucketPolicy, AutoDeleteObjects Lambda
       └── expõe: bucket.grantRead(), bucket.grantReadWrite(), etc.

L1 — CloudFormation Layer (sem abstração)
  Mapeamento direto da spec do CloudFormation
  Ex: s3.CfnBucket
       └── equivale exatamente a: AWS::S3::Bucket no YAML
       └── toda propriedade do CloudFormation disponível
       └── nenhum default, nenhum helper

2. L1 — CloudFormation Constructs (Cfn prefix)

[FACT] L1 constructs are automatically generated from the CloudFormation specification. If a resource exists in CloudFormation, a corresponding L1 exists in CDK — with the Cfn prefix.

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

// L1: equivale byte-a-byte ao CloudFormation YAML
const bucket = new s3.CfnBucket(this, 'MeuBucket', {
  bucketName: 'meu-bucket-prod',
  versioningConfiguration: {
    status: 'Enabled',
  },
  bucketEncryption: {
    serverSideEncryptionConfiguration: [
      {
        serverSideEncryptionByDefault: {
          sseAlgorithm: 'AES256',
        },
      },
    ],
  },
  publicAccessBlockConfiguration: {
    blockPublicAcls: true,
    blockPublicPolicy: true,
    ignorePublicAcls: true,
    restrictPublicBuckets: true,
  },
});

What L1 generates in CloudFormation:

{
  "MeuBucket": {
    "Type": "AWS::S3::Bucket",
    "Properties": {
      "BucketName": "meu-bucket-prod",
      "VersioningConfiguration": { "Status": "Enabled" },
      "BucketEncryption": { ... },
      "PublicAccessBlockConfiguration": { ... }
    }
  }
}

Exactly what you would write manually. Nothing more, nothing less.

L1 characteristics:
- Properties in camelCase (CDK) vs PascalCase (CloudFormation) — automatic conversion
- Strict types — TypeScript catches incorrect properties at compile time
- 100% coverage of the CloudFormation spec — if CloudFormation supports it, L1 supports it
- No defaults — you are responsible for every property


3. L2 — Intent-based constructs

L2 is where CDK delivers its greatest value. Beyond encapsulating L1, it adds:

a) Safe, opinionated defaults:

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

// L2: muito menos código, defaults sensatos
const bucket = new s3.Bucket(this, 'MeuBucket', {
  versioned: true,
  encryption: s3.BucketEncryption.S3_MANAGED,
  blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
  removalPolicy: cdk.RemovalPolicy.RETAIN,  // padrão já é RETAIN para S3
});
// O L2 aplica automaticamente: block public access completo por padrão (v2)

b) Helper methods (the biggest differentiator):

const bucket = new s3.Bucket(this, 'AppBucket');
const fn = new lambda.Function(this, 'Processor', { ... });
const role = new iam.Role(this, 'AppRole', { ... });

// Sem L2 (L1 puro): você escreveria a IAM Policy manualmente com o ARN correto
// Com L2: o construct sabe quais permissões são necessárias
bucket.grantRead(fn);           // s3:GetObject + s3:ListBucket na policy da fn
bucket.grantReadWrite(role);    // s3:GetObject + s3:PutObject + s3:DeleteObject + s3:ListBucket
bucket.grantPut(fn);            // apenas s3:PutObject

// Outros exemplos de helpers:
queue.grantSendMessages(fn);
table.grantReadData(fn);
topic.grantPublish(role);
fn.grantInvoke(role);

c) Cross-resource integrations:

import * as events from 'aws-cdk-lib/aws-events';
import * as targets from 'aws-cdk-lib/aws-events-targets';

const rule = new events.Rule(this, 'DailyRule', {
  schedule: events.Schedule.cron({ hour: '8', minute: '0' }),
});

// Adiciona a Lambda como target: cria a permissão e o target automaticamente
rule.addTarget(new targets.LambdaFunction(fn));

What L2 generates that you don't see directly:

// Quando você escreve:
new s3.Bucket(this, 'MeuBucket', {
  removalPolicy: cdk.RemovalPolicy.DESTROY,
  autoDeleteObjects: true,      // ← essa linha
});

// O CDK gera em CloudFormation:
// 1. AWS::S3::Bucket (o bucket em si)
// 2. AWS::IAM::Role (para a Lambda de auto-delete)
// 3. AWS::Lambda::Function (a Lambda que deleta os objetos)
// 4. AWS::Lambda::Permission (permissão para o CloudFormation invocar)
// 5. AWS::CloudFormation::CustomResource (que aciona a Lambda no delete)
// 6. AWS::S3::BucketPolicy (se você usar grants)

[CONSENSUS] This "side effect" of L2 is the main reason why L2 is not always the best choice. Sometimes you don't want those extra resources — especially in environments with strict security policies about automatically generated Lambdas and IAM roles.


4. When L2 is not preferable to L1

This is the counterintuitive part of the session. L2 is not always better.

Case 1: The property you need is not exposed in L2

L2 constructs are maintained by the AWS team and don't always keep up with the speed of new CloudFormation resource launches. When CloudFormation releases a new property, L1 exposes it automatically (generated from the spec), but L2 may take weeks or months to expose it via a method.

// Ex hipotético: nova propriedade lançada ontem no CloudFormation
// O L2 ainda não tem um prop para isso

// Opção A: usar L1 diretamente
const bucket = new s3.CfnBucket(this, 'Bucket', {
  novaPropriedade: 'valor',    // disponível no L1 imediatamente
});

// Opção B: usar L2 + escape hatch (veja seção 6)
const bucket = new s3.Bucket(this, 'Bucket');
const cfnBucket = bucket.node.defaultChild as s3.CfnBucket;
cfnBucket.addPropertyOverride('NovaPropriedade', 'valor');

Case 2: The extra resources from L2 are unwanted

// autoDeleteObjects gera uma Lambda + Role + CustomResource
// Em ambientes com SCPs que bloqueiam criação de Lambdas em certas regiões,
// ou com revisão obrigatória de IAM roles, isso é um problema operacional

// L1 puro: apenas o bucket, nada mais
const bucket = new s3.CfnBucket(this, 'Bucket', {
  bucketName: 'meu-bucket',
  // sem recursos extras
});

Case 3: Migration from existing CloudFormation

When importing an existing resource (via cloudformation import), you need the logical ID in CDK to match exactly the one from the original template. L1 with overrideLogicalId gives total control; L2 with hash may require more work.

Case 4: Template auditing and compliance

In organizations with security review of the CloudFormation template before deployment, L2 generates "implicit" resources that appear in the template and need to be justified. L1 produces predictable and auditable templates.

Rule of thumb:

Prefira L2 quando:
  → Você quer velocidade de desenvolvimento
  → Os defaults são adequados para o seu caso
  → Os helpers de permissão (grant*) simplificam muito o código
  → Você está em fase de prototipação

Prefira L1 quando:
  → Você precisa de uma propriedade que o L2 ainda não expõe
  → Os recursos extras gerados pelo L2 são indesejados
  → Você precisa de logical IDs fixos e previsíveis
  → O template final precisa ser auditável sem surpresas

5. L3 — Patterns

L3 constructs compose multiple L2s into a reusable architectural pattern. The canonical example is ApplicationLoadBalancedFargateService:

import * as ecs_patterns from 'aws-cdk-lib/aws-ecs-patterns';
import * as ecs from 'aws-cdk-lib/aws-ecs';

const cluster = new ecs.Cluster(this, 'Cluster', { vpc });

// Uma linha substitui ~150 linhas de CloudFormation
const service = new ecs_patterns.ApplicationLoadBalancedFargateService(this, 'Service', {
  cluster,
  cpu: 256,
  memoryLimitMiB: 512,
  desiredCount: 2,
  taskImageOptions: {
    image: ecs.ContainerImage.fromRegistry('nginx:latest'),
    containerPort: 80,
  },
  publicLoadBalancer: true,
});

// O L3 criou por baixo:
// - ECS Cluster (se não fornecido)
// - Task Definition + Container Definition
// - Fargate Service
// - Application Load Balancer
// - ALB Listener (porta 80/443)
// - Target Group com health check
// - Security Groups (ALB → Service)
// - IAM Roles (task role + execution role)
// - CloudWatch Log Group

Accessing the internal components of an L3:

// O L3 expõe os sub-constructs para customização
service.targetGroup.configureHealthCheck({
  path: '/health',
  healthyHttpCodes: '200',
});

service.loadBalancer.addSecurityGroup(mySG);
service.taskDefinition.addContainer('sidecar', { ... });

// Para customizações mais profundas: escape hatch no sub-recurso
const cfnService = service.service.node.defaultChild as ecs.CfnService;
cfnService.addPropertyOverride('DeploymentConfiguration.MaximumPercent', 200);

Other relevant native L3 patterns:

// Fila SQS + Lambda consumer pré-configurados
new sqs_event_sources.SqsEventSource(queue, { batchSize: 10 });

// API Gateway + Lambda integrado
new apigw.LambdaRestApi(this, 'Api', { handler: fn });

// SNS topic com email subscription
new sns_subscriptions.EmailSubscription('ops@empresa.com');

6. Escape hatches — dropping from L2 to L1

When an L2 doesn't expose what you need, you access the underlying L1 via escape hatch. This is the mechanism that ensures CDK never blocks you — you can always drop down one level.

const bucket = new s3.Bucket(this, 'Bucket', {
  versioned: true,
});

// Acessar o L1 subjacente
const cfnBucket = bucket.node.defaultChild as s3.CfnBucket;

// Modificar uma propriedade existente do L1
cfnBucket.lifecycleConfiguration = {
  rules: [
    {
      status: 'Enabled',
      transitions: [
        {
          storageClass: 'GLACIER',
          transitionInDays: 90,
        },
      ],
    },
  ],
};

// Adicionar uma propriedade que o L2 não expõe (usando override direto)
cfnBucket.addPropertyOverride('ObjectLockEnabled', true);

// Adicionar metadata ao recurso CloudFormation
cfnBucket.addOverride('Metadata', {
  'aws:cdk:path': 'MeuStack/Bucket/Resource',
  'Comment': 'Auditado em 2026-05-11',
});

// Remover uma propriedade gerada pelo L2
cfnBucket.addPropertyDeletionOverride('Tags');

// Fixar o logical ID (para migração de stacks existentes)
cfnBucket.overrideLogicalId('MeuBucketOriginal');

Escape hatch hierarchy (from least to most invasive):

1. Props do L2                → use sempre que disponível
   bucket.versioned = true

2. Methods do L2              → para integrações e permissões
   bucket.grantRead(fn)

3. node.defaultChild + props  → para props L1 não expostas no L2
   cfnBucket.lifecycleConfiguration = { ... }

4. addPropertyOverride        → para props L1 sem tipo no CDK
   cfnBucket.addPropertyOverride('NewProp', value)

5. addOverride                → para metadados, DependsOn, etc.
   cfnBucket.addOverride('DependsOn', ['OutroRecurso'])

6. CfnResource puro (L1)      → quando L2 não serve de jeito nenhum
   new s3.CfnBucket(this, 'Bucket', { ... })

7. Construct Hub — evaluating third-party constructs

[FACT] The Construct Hub is the public repository of third-party CDK constructs, maintained by AWS. Constructs are published via npm (TypeScript/JavaScript), PyPI (Python), Maven (Java) and NuGet (C#).

How to evaluate a third-party construct:

Critérios técnicos:
  ✅ Compatível com CDK v2 (aws-cdk-lib, não @aws-cdk/*)
  ✅ Versão recente com releases regulares
  ✅ Testes na suite de CI (GitHub Actions, etc.)
  ✅ README com exemplos claros de uso
  ✅ Escape hatches documentados

Critérios de risco:
  ⚠️  Mantenedor único vs. organização
  ⚠️  Número de dependentes (downloads/semana)
  ⚠️  Issues abertas sem resposta
  ⚠️  Compatibilidade com sua versão de CDK
  ⚠️  Licença compatível com o uso pretendido

Alternative to third-party constructs: always check if native CDK has an equivalent L3 before adding an external dependency. Third-party constructs have maintenance cost — when CDK releases an incompatible version, you depend on the third party to update.


Practical example

Side-by-side comparison of all three levels for the same resource:

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

export class ComparacaoStack extends cdk.Stack {
  constructor(scope: cdk.App, id: string) {
    super(scope, id);

    const role = new iam.Role(this, 'AppRole', {
      assumedBy: new iam.ServicePrincipal('lambda.amazonaws.com'),
    });

    // ── L1: controle total, verboso ────────────────────────────────────
    const bucketL1 = new s3.CfnBucket(this, 'BucketL1', {
      versioningConfiguration: { status: 'Enabled' },
      bucketEncryption: {
        serverSideEncryptionConfiguration: [{
          serverSideEncryptionByDefault: { sseAlgorithm: 'AES256' },
        }],
      },
    });
    // Para dar permissão: você escreve a IAM Policy completa manualmente
    new iam.CfnPolicy(this, 'PolicyL1', {
      policyName: 'BucketAccess',
      roles: [role.roleName],
      policyDocument: {
        Version: '2012-10-17',
        Statement: [{
          Effect: 'Allow',
          Action: ['s3:GetObject', 's3:PutObject'],
          Resource: `${bucketL1.attrArn}/*`,
        }],
      },
    });

    // ── L2: conciso, helpers automáticos ──────────────────────────────
    const bucketL2 = new s3.Bucket(this, 'BucketL2', {
      versioned: true,
      encryption: s3.BucketEncryption.S3_MANAGED,
    });
    bucketL2.grantReadWrite(role);  // policy gerada automaticamente

    // ── L2 + escape hatch: melhor dos dois mundos ────────────────────
    const bucketHybrid = new s3.Bucket(this, 'BucketHybrid', {
      versioned: true,
    });
    // Adicionar propriedade não exposta pelo L2
    const cfn = bucketHybrid.node.defaultChild as s3.CfnBucket;
    cfn.addPropertyOverride('ObjectLockEnabled', true);
    bucketHybrid.grantReadWrite(role);  // ainda usa o helper do L2
  }
}

Run and compare the generated templates:

cdk synth

# Ver quantos recursos cada abordagem gerou
cat cdk.out/ComparacaoStack.template.json | jq '[.Resources | to_entries[] | .value.Type] | group_by(.) | map({type: .[0], count: length})'

# Inspecionar a policy gerada pelo grantReadWrite do L2
cat cdk.out/ComparacaoStack.template.json | \
  jq '.Resources | to_entries[] | select(.value.Type == "AWS::IAM::Policy") | .value.Properties.PolicyDocument'

Common pitfalls

1. Assuming L2 has all CloudFormation properties

L2 is a manually maintained abstraction. New CloudFormation resources appear in L1 immediately (automatic generation), but in L2 they depend on a PR being merged. Before concluding that "CDK doesn't support X", check if L1 has what you need — in most cases, it does.

2. Using escape hatch when an L2 method exists

cfnBucket.addPropertyOverride('BucketName', 'meu-bucket') works, but new s3.Bucket(this, 'B', { bucketName: 'meu-bucket' }) is more readable, typed and validates the value. Use escape hatch only when L2 truly doesn't expose what you need.

3. Third-party L3 constructs as a black box

A third-party L3 construct can create dozens of resources. If you haven't read the code or the README carefully, you may encounter surprises: resources with RemovalPolicy.RETAIN that you don't control, IAM policies broader than necessary, or networking configurations that conflict with your VPC. Always run cdk synth and review the generated template before adopting a third-party L3 in production.


Reflection exercise

You are implementing an S3 bucket to store audit logs with the following security constraints: Object Lock enabled in GOVERNANCE mode with 7-year retention, notification to an SQS queue on every s3:ObjectCreated:*, and a bucket policy that denies any operation not using TLS.

When trying to implement with L2, you discover that Object Lock is not available as a direct prop in the CDK v2 s3.Bucket construct.

Describe the complete implementation strategy: which combination of L1, L2 and escape hatches would you use for each requirement, and why. Is there any requirement that is simpler in pure L1 than in L2 + escape hatch? If so, which one and why?


Resources for further study

Construct model:
- AWS CDK Constructs — official documentation with the definition of all three levels and examples in TypeScript and Python.

Escape hatches:
- Customize constructs from the AWS Construct Library — all escape hatch methods with examples: node.defaultChild, addPropertyOverride, addOverride, addPropertyDeletionOverride.

L3 ECS patterns:
- aws-cdk-lib.aws_ecs_patterns module — lists all available native patterns with complete examples.

Construct Hub:
- constructs.dev — search by service or use case. Use the language and CDK v2 compatibility filters.