Up

Upstood

Services

Company

Let's talk

All articles

Terraform Modules vs CDK Constructs | What the Extra Layers Change

29 July 2026 · 8 min read

Terraform gives you two layers of abstraction. CDK gives you four. That difference decides how much your own module has to justify itself, and what a refactor costs you a year later.

The problem

Teams moving between the two tools compare the syntax. HCL against TypeScript, terraform plan against cdk diff, state files against CloudFormation. The syntax is the smallest difference of the three.

The larger one is how many layers sit between a line of your code and a resource in the account, and where that resource's identity comes from. In Terraform the identity is something you write. In CDK it is something the layer structure computes for you.

That sounds academic until a pull request that changes no behaviour deletes a bucket.

The finding

Terraform has two layers. CDK has four.

In Terraform there is the provider resource, and there is the module you write around it. That is the entire ladder. When a guide says "write a module", it means: wrap raw resources, add defaults, validate inputs, expose outputs.

CDK adds two rungs before you write anything.

Layer What it is
L1 (CfnBucket) Auto-generated from the CloudFormation resource spec. One construct, one resource, no defaults, raw property shapes.
L2 (Bucket) Hand-written by AWS. Opinionated defaults, typed enums, helper methods. Can emit several resources.
L3 (patterns) Several L2s wired together for a use case.
Your wrapper Whatever your organisation adds on top.

The consequence is easy to miss: an L2 already is a module in the Terraform sense. Curated, opinionated, typed inputs, sensible defaults, maintained by someone else. When you write your own construct around s3.Bucket, you are writing a module around a module.

The layer count is not just conceptual, it shows up in the template. A plain L1 CfnBucket synthesises to exactly one resource. The L2 Bucket with enforceSSL: true and autoDeleteObjects: true synthesises to five: the bucket, a bucket policy, a custom resource, an IAM role and a Lambda function. One line of props, four extra resources, one of which executes code in the account.

Neither number is wrong. They are different amounts of decision made on your behalf.

Identity is where the layers bite

In Terraform, a resource's identity is the address you wrote: aws_s3_bucket.bucket. It is in the file. You can grep for it.

In CDK, identity is the CloudFormation logical ID, and CDK computes it by hashing the construct's path through those layers. You never write it. It does not appear anywhere in your source code.

Here is the same bucket, bucketName: "demo" throughout, synthesised on aws-cdk-lib 2.189.1:

Change to the code Logical ID
baseline: new s3.Bucket(this, "Bucket", …) Bucket83908E77
added versioned, a lifecycle rule, a new output Bucket83908E77
renamed the TypeScript class Bucket83908E77
renamed the construct id to "Storage" Storage07F31EBC
extracted the bucket into a SecureBucket construct BucketD7FEB781
added a grouping parent construct StorageBucket5CB7C8EA

Properties are free. Class names, file names and variable names are free. What is not free is the id strings on the path from the stack down to the resource, and how many levels sit between them.

The middle three rows are the interesting ones. Adding versioning and a lifecycle rule changes real infrastructure behaviour and the identity holds. Extracting a construct changes no behaviour at all and the identity moves.

What CloudFormation does with that

It matches resources on the logical ID alone. Not on the bucket name, not on the properties. So the extraction reads as one resource removed and a different one added:

[-] AWS::S3::Bucket Bucket        Bucket83908E77   destroy
[+] AWS::S3::Bucket Bucket/Bucket BucketD7FEB781

cdk diff reports it accurately. The word destroy is right there. The difficulty is upstream of the diff: the pull request that produced it contains no bucket name, no property change and no resource. It contains a class extraction and two changed lines, which is normally the safest kind of change a reviewer sees. And the two logical IDs both begin with Bucket, differing only in eight hex characters.

With the default removal policy the same diff reads orphan instead of destroy, which leaves the old bucket behind in the account, unmanaged and still billing. Which of the two you get depends on a removal policy set somewhere else in the file.

The same trap has a name in Terraform

Terraform has this problem too. Rename a resource inside a module and consumers get a destroy and create.

The difference is that Terraform ships a repair tool:

moved {
  from = aws_s3_bucket.bucket
  to   = module.secure.aws_s3_bucket.bucket
}

That block lives inside the module. It travels with the version bump. A consumer who upgrades runs plan and reads "has moved to", followed by no changes. Once every consumer is upgraded, the author deletes the block in a later major.

CDK's equivalent arrived later and works differently. The cdk refactor command, in preview and gated behind --unstable=refactor, compares your code against the deployed state, detects constructs that have been renamed or moved, and uses CloudFormation's refactoring API to preserve the resources while their logical IDs change. AWS names this exact case in the command's documentation: "Reorganize your construct hierarchy (like grouping AWS resources under a new L3 construct) while preserving the underlying cloud resources."

That closes the gap, but not in the same place moved closes it. Terraform's block is written by the module author and travels inside the module, so a consumer repairs the break by upgrading and reading plan. cdk refactor is run by whoever owns the deployment, against deployed state, after the change has landed. For a construct published to other teams, the author can cause the break and cannot ship the fix.

It also refuses to run on a mixed change. The command verifies that the application contains exactly the same set of resources as the deployed state, differing only in their location in the construct tree, and rejects the operation if it detects any resource additions, deletions or modifications. A pull request that extracts a construct and adjusts a property in the same commit is not refactorable by it.

The older manual route is still there: reaching through the L2 to the L1 underneath and pinning the old value by hand.

const cfn = bucket.node.defaultChild as s3.CfnBucket;
cfn.overrideLogicalId("Bucket83908E77");

Three things separate that from moved. You have to know the hash, which means synthesising the old version or reading the deployed template. It cannot be removed later, because removing it changes the identity again. And it documents nothing: the code says SecureBucket/Bucket while claiming an identity from a shape that has not existed since the previous release.

Why this compounds with the layer count

The two findings are the same finding. Logical IDs are derived from the path through the layers, so every layer you add or remove is an identity change. Terraform's flatter ladder means fewer opportunities to move something by accident, and its repair is declarative, stable, and shipped by the author to the consumer. CDK has more rungs to move between, and its repair is a preview command run by the operator after the fact.

CDK's extra rungs buy real things: the L2s carry AWS's own defaults, and the assertion tests that check them run in milliseconds with no cloud credentials, which is a tier most Terraform teams never reach. The rungs are also the mechanism by which a tidy-up deletes a database.

The options

Keep the construct tree flat. Resources sit directly in the stack. Fewer levels means fewer identity changes available. Composition happens by writing a new stack rather than restructuring an existing one. Cheap while nothing is deployed, and the decision is effectively frozen at the first deploy.

Pin logical IDs by hand. overrideLogicalId or stack.renameLogicalId. Lets you restructure freely at the cost of a permanent hardcoded hash for every resource you move.

Diff the synthesised template in CI. Synthesise the previous release and the current commit, extract the logical IDs from both, fail the build when an existing one disappears. Catches the class of change rather than repairing it, and it makes the invisible part visible in the pull request where the decision is actually made.

Retain on delete for stateful resources. RemovalPolicy.RETAIN turns a deletion into an orphan. The resource survives, unmanaged, and the deploy may still fail if the physical name is taken.

cdk refactor. Preview, behind --unstable=refactor. Detects moved or renamed constructs and calls CloudFormation's refactoring API to keep the resources while their logical IDs change. --dry-run prints the mapping without applying it, and an override file resolves cases where more than one mapping is valid. Costs you a preview dependency, and it rejects any change that is not purely a relocation.

Where each one fits

A flat tree fits work where nothing is deployed yet and the abstraction is not yet earned. The cost is that a shared construct extracted later is a migration, not a refactor. Before the first deploy that decision is free, and it stops being free permanently on the day something exists in the account.

Hand-pinned logical IDs fit a small number of deliberate moves in an application stack you own end to end. They stop fitting in a shared construct library: the hashes accumulate across releases, and after the third one the tree's real identity lives in a pile of hex strings rather than in its shape.

Template diffing in CI fits anyone publishing constructs that other teams consume by version. There, a refactor by the author is a destroy in someone else's account, and the author is the only person positioned to catch it. It fits less well on a single application stack with one reviewer who already reads every diff.

cdk refactor fits a team that owns both the code and the deployment, can accept a preview command in the path to production, and is willing to split a restructuring commit from a behaviour commit so the command will accept it. It fits worst in the case that motivated this comparison: a construct library whose consumers are other teams. There the break travels with the version bump and the repair does not, so every consumer runs it separately in their own account, or does not run it at all.

Retain on delete fits databases, state buckets and anything holding data, in every setup. What it does not do is prevent the identity change, so it pairs with one of the options above rather than replacing them.

The layer question sits underneath all of this. If your organisation's wrapper adds real policy, naming guarantees and validated inputs that an L2 cannot express, the extra rung is doing work. If it forwards properties to s3.Bucket with a longer name, it is a layer of identity risk that buys nothing, and Terraform's guidance applies unchanged: a module wrapping one resource with pass-through variables is not abstraction.


Want us to look for issues like this in your account? We offer a free AWS audit: upstood.com

Want this looked at in your AWS account?

Every audit covers cost, security and architecture design at a fixed price, with a guarantee: results or you do not pay

See audit tiers

Upstood

Home
Let's talk
Upstood | 2026

VAT: IT14214770969 · Via Cufra 17 · 20159 Milano - Italia · Tel: +39 3447504971 · info@upstood.com