Python Constructs & Modules

Infrastructure as Code requires deterministic resource graphs and strict state boundaries. Python 3.9+ provides the typing primitives and object-oriented patterns necessary to model cloud infrastructure safely, and within the broader CDKTF Workflows & Terraform Synthesis workflow these constructs are the unit of reuse that the synthesis pipeline compiles. Frameworks like CDKTF and Pulumi translate runtime Python objects into declarative Terraform state.

This guide details construct scoping, type-safe module composition, provider aliasing, and CI/CD validation gates. You will learn how to enforce state integrity, isolate credentials, and validate infrastructure before deployment.

One distinction is worth fixing before anything else: a construct is not a Terraform module. A Terraform module is a directory of HCL that Terraform loads at plan time and that owns its own variable and output plumbing. A construct is an ordinary Python object that exists only while cdktf synth is running. By the time Terraform sees anything, the construct is gone and all that remains is flattened JSON. Everything Python offers — validation, computation, inheritance, dependency injection, unit tests — happens before Terraform starts, and none of it survives into the plan except the resource blocks it emitted.

Problem Framing

The failure this page addresses is duplication that drifts. A CDKTF repository almost always starts honestly: one TerraformStack subclass, a handful of resources, a single environment. The second environment arrives and the fastest path is to copy the stack file and change the CIDRs. The third arrives and the copy gets copied. Six months later prod_stack.py has flow logs and a restrictive default security group, staging_stack.py has flow logs but not the security group, and nobody can say which of the five files is the reference implementation.

Duplicated stacks versus one shared construct Duplicated stacks versus one shared construct: comparison across Copy-pasted stack, Shared construct. Concern Copy-pasted stack Shared construct VPC flow logs on in 2 of 5 stacks one default, every caller Subnet CIDR maths retyped per stack computed once, unit-tested Provider binding drifts per repository pinned by the package Security fix five pull requests one release, one bump Review surface 300 lines of resources six typed props
The same five concerns, duplicated by hand on the left and owned by one typed class on the right.

Nothing in that failure produces an error. Every stack synthesizes, every plan is clean, and every apply succeeds — the divergence is invisible to the toolchain because Terraform has no opinion about whether two stacks should look alike. The only mechanism that makes the similarity enforceable is a shared Python object holding one definition of the defaults, which is precisely what a construct is.

Two guides sit beneath this page and cover the two halves of that work. Building reusable CDKTF constructs in Python is the mechanical path: a frozen props dataclass, a Construct subclass that builds the resource graph, typed attributes exposing only the identifiers consumers need, and a snapshot test that runs without credentials. Publishing CDKTF constructs as a Python package takes that same class and turns it into a wheel other teams can pip install and pin, with the release hygiene a library demands when its bugs become real infrastructure changes.

Prerequisites

  • Python 3.9+ with cdktf and constructs installed — both arrive with a cdktf init --template=python scaffold.
  • Node.js on PATH. The Python bindings are jsii-generated wrappers over a Node runtime, so cdktf synth starts a Node process even though you never write JavaScript.
  • The Terraform binary, because CDKTF shells out to it for cdktf diff, cdktf deploy, and cdktf destroy.
  • A pinned provider binding such as cdktf-cdktf-provider-aws, so constructs compile against a known schema.
  • mypy and pytest. Constructs are the part of an infrastructure codebase that most repays static checking, because a synthesis error at three seconds is far cheaper than a provider error at ninety.
# CLI: confirm the toolchain before writing a construct
python -c "import cdktf, constructs; print(cdktf.__name__, constructs.__name__)"
node --version && terraform version && cdktf --version

No cloud credentials are required to author, type-check, or unit-test a construct. Synthesis resolves the object graph entirely in memory and writes JSON into cdktf.out/, so the whole inner loop is offline. Keeping it that way is a deliberate control: a test suite that needs sts:AssumeRole is a test suite somebody eventually points at production.

Object-Oriented Infrastructure Patterns

Python class inheritance maps directly to IaC resource dependency graphs. Base classes define shared networking or IAM boundaries. Child classes inherit configuration while injecting environment-specific parameters.

Object Oriented Infrastructure Patterns Object Oriented Infrastructure Patterns: layered from cdktf.out down to Pulumi. cdktf.out Python IAM CDKTF Pulumi
Object Oriented Infrastructure Patterns: the building blocks this section assembles.

CDKTF and Pulumi intercept these runtime objects during execution. They traverse the object tree and emit declarative JSON or HCL. This translation layer bridges imperative Python logic with declarative state engines.

CLI: cdktf synth triggers the synthesis pipeline. It resolves the Python object graph and writes the intermediate Terraform configuration to the cdktf.out directory.

The synthesis process validates resource references before state generation. Design classes to expose explicit configuration interfaces. Implicit attribute resolution causes silent drift during plan execution.

There are exactly three structural types in a CDKTF program, and confusing them is the most common early mistake. App is the root: it owns the output directory and is the only object whose synth() writes files. TerraformStack is the state boundary: one stack becomes one Terraform working directory under cdktf.out/stacks/<name>/, with one backend and one state file. Construct is everything else — a grouping node with no state of its own, existing purely to organise resources and give them a namespace. If a thing needs its own state file or its own backend it is a stack; otherwise it is a construct.

Inheritance is the wrong default for the relationship between constructs. Subclassing a construct to add a resource couples the child to the parent's private layout, and CDKTF gives you no way to remove an inherited resource without changing its logical ID. Composition is safer: a PlatformNetwork construct that instantiates a FlowLogs construct can drop or swap it later, whereas PlatformNetwork(FlowLogsMixin) cannot. Reserve inheritance for the case it genuinely models — a family of stacks that share a backend and a tagging policy but differ in what they contain.

The second thing to internalise is that resource attributes are not values during synthesis. vpc.id does not return vpc-0a1b2c3d; it returns a token string of the form ${aws_vpc.network_vpc_E7D2A1B4.id} that Terraform resolves at apply time. That is why a length check on vpc.id silently does the wrong thing, and why interpolating a token into a name works — the token survives as text — while arithmetic on one does not. Any decision that must be made from a real cloud value has to come either from a data source, which Terraform resolves at plan time, or from the Python that computes the props before the construct ever runs.

Construct Lifecycle & Scope

Constructs operate within strict hierarchical boundaries. Every resource inherits a parent scope and a unique logical ID. The framework uses this hierarchy to compute resource addresses and prevent naming collisions.

Construct Lifecycle & Scope Construct Lifecycle & Scope: Author then Preview then Apply then Verify Author Preview Apply Verify
Construct Lifecycle & Scope: the stages run left to right — Author, Preview, Apply, Verify.

Lifecycle hooks execute in deterministic order: initialization registers resources, validation checks configuration constraints, synthesis serializes the dependency graph. You can override these hooks to inject custom validation logic.

Dependency resolution relies on explicit references. Passing a construct output to another construct's input creates a directed edge in the DAG. The framework computes the apply order automatically.

The scope argument is not decorative. When you write Vpc(self, "vpc", ...) inside a construct, the first argument fixes that resource's position in the tree, and CDKTF derives its Terraform logical ID from the full path: platform/network/vpc becomes something like platform_network_vpc_5C3A9F10, where the suffix is a hash of the path that keeps addresses unique and stable. Two consequences follow directly. Moving a construct under a different parent changes every logical ID beneath it, and Terraform reads a changed logical ID as destroy the old resource and create a new one. Renaming the id string passed to a construct does exactly the same thing. Neither is a refactor; both are a plan that deletes a VPC.

When you must rename without recreating, pin the address explicitly instead of letting the path derive it:

# platform/pinned_network.py
# CLI: cdktf synth && terraform -chdir=cdktf.out/stacks/prod plan
from constructs import Construct
from cdktf_cdktf_provider_aws.vpc import Vpc


class PinnedNetwork(Construct):
    def __init__(self, scope: Construct, ns: str, cidr_block: str) -> None:
        super().__init__(scope, ns)
        self.vpc = Vpc(self, "vpc", cidr_block=cidr_block, enable_dns_hostnames=True)
        # State implication: freezes the Terraform address at aws_vpc.platform_vpc so the
        # construct can move in the Python tree without producing a destroy/create plan.
        self.vpc.override_logical_id("platform_vpc")
        # Provider note: the resource still belongs to whichever AwsProvider is in scope;
        # override_logical_id changes the address, never the provider binding.

Validation is the lifecycle hook most worth using. Construct exposes an add_validation mechanism whose errors CDKTF collects across the whole tree and reports together, so a misconfigured props object fails synthesis with a message naming the construct path rather than failing three minutes later inside a provider API call. Prefer failing in __post_init__ on the props dataclass where you can — that is earlier still — and reserve tree validation for constraints that only make sense once siblings exist, such as "this stack must not declare two NAT gateways in the same availability zone".

Structuring Reusable Modules with Python 3.9+ Typing

Reusable modules require strict input contracts. Python 3.9+ typing primitives prevent schema mismatches during synthesis. Define configuration boundaries before instantiating cloud resources.

Structuring Reusable Modules with Python + Typing Structuring Reusable Modules with Python + Typing: layered from typing down to Enforcing Type Safety. typing dataclasses TypedDict Python Enforcing Type Safety
Structuring Reusable Modules with Python + Typing: the building blocks this section assembles.

Enforcing Type Safety with typing & dataclasses

Use TypedDict for JSON-compatible configuration payloads. Combine dataclasses with runtime validation to catch invalid inputs early. This approach eliminates silent failures during state generation.

# platform/config.py
# CLI: python -m mypy platform/config.py --strict
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Protocol, TypedDict, Generic, TypeVar, Optional
from enum import Enum

class Environment(str, Enum):
    DEV = "dev"
    STAGING = "staging"
    PROD = "prod"

class NetworkConfig(TypedDict, total=True):
    vpc_cidr: str
    enable_nat_gateway: bool
    availability_zones: list[str]

class ModuleConfig(Protocol):
    """Contract for infrastructure module inputs."""
    environment: Environment
    region: str
    network: NetworkConfig
    tags: dict[str, str]

T = TypeVar("T", bound=ModuleConfig)

@dataclass(frozen=True)
class SecureModuleConfig(Generic[T]):
    """Immutable configuration wrapper with validation boundaries."""
    config: T
    secret_manager_arn: str
    kms_key_id: Optional[str] = field(default=None)

    def __post_init__(self) -> None:
        # State implication: raising here aborts synthesis, so no cdktf.out is written
        # and no Terraform address is ever minted for an unsupported region.
        if not self.config.region.startswith(("us-", "eu-", "ap-")):
            raise ValueError(f"Unsupported AWS region: {self.config.region}")

Three choices in that snippet are load-bearing. frozen=True matters because a construct reads its props during __init__ and never again; a props object mutated afterwards produces a configuration that disagrees with the synthesized JSON, and that disagreement stays invisible until somebody reads a diff. field(default_factory=dict) rather than a literal default avoids the shared-mutable-default trap, which in infrastructure code means two stacks silently sharing one tag dictionary. And Protocol rather than a base class lets a caller pass any object with the right shape — including a test double — without importing your class hierarchy.

TypedDict and dataclasses solve different halves of the problem and belong together. TypedDict describes data that arrives as JSON or YAML and must round-trip unchanged, so it is the right type for the parsed contents of an environment file. A dataclass describes an object with invariants and behaviour, so it is the right type for the validated result. The parse step between the two is where the runtime check belongs, and it is the only place in the program where a KeyError is an acceptable outcome.

Module Composition & Dependency Injection

Isolate module boundaries using explicit dependency injection. Never rely on global variables or implicit environment lookups. Inject configuration objects directly into construct initializers. For a complete walkthrough of subclassing Construct, defining a typed props dataclass, and composing components, see building reusable CDKTF constructs in Python.

This pattern enables deterministic testing and parallel execution. You can swap mock configurations during unit tests. Production pipelines inject live parameters from CI/CD secrets.

Dependency mapping during synthesis relies on explicit object references. The framework traverses injected objects to compute resource ordering. See CDKTF Architecture & Synthesis for detailed DAG resolution mechanics.

Injection also decides what a construct is allowed to know. A construct that reads os.environ["AWS_REGION"] inside its own __init__ cannot be instantiated twice in one program with different regions, cannot be tested without mutating the process environment, and cannot be reviewed without reading its body. A construct that receives a props object declares its entire dependency surface in its signature. The rule that follows is short: environment lookups happen once, at the top of the entry point, and everything below receives values.

# main.py — the only place the process environment is read
# CLI: cdktf synth
import os
from cdktf import App, TerraformStack
from constructs import Construct
from cdktf_cdktf_provider_aws.provider import AwsProvider
from platform.network import NetworkConstruct
from platform.props import NetworkProps


class PlatformStack(TerraformStack):
    def __init__(self, scope: Construct, ns: str, props: NetworkProps, region: str) -> None:
        super().__init__(scope, ns)
        # Provider note: one AwsProvider per stack; child constructs inherit it by scope.
        AwsProvider(self, "aws", region=region)
        self.network = NetworkConstruct(self, "network", props)


app = App()
PlatformStack(
    app,
    "prod",
    props=NetworkProps(cidr_block="10.20.0.0/16", availability_zones=["eu-west-1a", "eu-west-1b"]),
    region=os.environ.get("AWS_REGION", "eu-west-1"),
)
app.synth()

Step-by-Step: Composing a Three-Layer Construct Library

A construct library that survives contact with a second team has three layers, and keeping them separate is most of the discipline. The props layer is pure data with no CDKTF imports. The construct layer turns props into resources. The stack layer chooses props and wires providers and backends. Each layer is testable without the one above it.

Where a construct sits during cdktf synth Where a construct sits during cdktf synth: App → TerraformStack → NetworkConstruct → AWS binding. App TerraformStack NetworkConstruct AWS binding add stack pass props Vpc(...) token ref typed outputs stack JSON
Synthesis walks down the construct tree and back up: the binding returns unresolved tokens, and only the App writes JSON.

1. Define the props layer with no framework imports

Keep the props module free of cdktf and provider imports. It then loads in milliseconds, is trivially unit-testable, and can be reused by a cost estimator or a policy check that has no business instantiating resources.

# platform/props.py
# CLI: python -m pytest tests/test_props.py -q
from __future__ import annotations
from dataclasses import dataclass, field
from ipaddress import IPv4Network


@dataclass(frozen=True)
class NetworkProps:
    cidr_block: str
    availability_zones: list[str]
    enable_flow_logs: bool = True
    tags: dict[str, str] = field(default_factory=dict)

    def __post_init__(self) -> None:
        if not self.availability_zones:
            raise ValueError("availability_zones must list at least one AZ")
        network = IPv4Network(self.cidr_block)   # raises ValueError on host bits set
        if network.prefixlen > 20:
            raise ValueError(f"cidr_block {self.cidr_block} is too small for per-AZ subnets")

    def subnet_cidrs(self) -> list[str]:
        # State implication: this arithmetic fixes subnet addresses, so changing it later
        # re-CIDRs live subnets — pin it with a test before the first apply.
        parent = IPv4Network(self.cidr_block)
        return [str(s) for s in parent.subnets(new_prefix=24)][: len(self.availability_zones)]

2. Build the construct layer

The construct takes a scope, a logical name, and the props object, then exposes only what consumers need. Everything else stays private, because every public attribute is a compatibility promise you will be asked to keep.

# platform/network.py
# CLI: cdktf synth
from constructs import Construct
from cdktf_cdktf_provider_aws.vpc import Vpc
from cdktf_cdktf_provider_aws.subnet import Subnet
from platform.props import NetworkProps


class NetworkConstruct(Construct):
    def __init__(self, scope: Construct, ns: str, props: NetworkProps) -> None:
        super().__init__(scope, ns)
        self.vpc = Vpc(
            self,
            "vpc",
            cidr_block=props.cidr_block,
            enable_dns_hostnames=True,
            enable_dns_support=True,
            tags=props.tags,
        )
        # Provider note: subnet.vpc_id receives an unresolved token, not a real vpc-id;
        # that reference is what creates the dependency edge in the plan graph.
        self.subnets: list[Subnet] = [
            Subnet(
                self,
                f"subnet-{index}",
                vpc_id=self.vpc.id,
                cidr_block=cidr,
                availability_zone=zone,
                tags=props.tags,
            )
            for index, (zone, cidr) in enumerate(
                zip(props.availability_zones, props.subnet_cidrs())
            )
        ]

    @property
    def subnet_ids(self) -> list[str]:
        return [subnet.id for subnet in self.subnets]

3. Assemble stacks and synthesize

The stack layer is where environments differ. Because props are ordinary Python, per-environment values can come from a file, a dictionary, or a parameter store lookup performed before App() is constructed.

# main.py
# CLI: cdktf synth && cdktf diff prod
from cdktf import App, S3Backend, TerraformStack, TerraformOutput
from constructs import Construct
from cdktf_cdktf_provider_aws.provider import AwsProvider
from platform.network import NetworkConstruct
from platform.props import NetworkProps

ENVIRONMENTS: dict[str, NetworkProps] = {
    "staging": NetworkProps("10.10.0.0/16", ["eu-west-1a", "eu-west-1b"]),
    "prod": NetworkProps("10.20.0.0/16", ["eu-west-1a", "eu-west-1b", "eu-west-1c"]),
}


class PlatformStack(TerraformStack):
    def __init__(self, scope: Construct, ns: str, props: NetworkProps) -> None:
        super().__init__(scope, ns)
        AwsProvider(self, "aws", region="eu-west-1")
        # State implication: one backend key per stack, so staging and prod never touch
        # the same state object and a destroy in staging cannot reach prod.
        S3Backend(
            self,
            bucket="acme-iac-state",
            key=f"cdktf/{ns}/terraform.tfstate",
            region="eu-west-1",
            dynamodb_table="acme-iac-locks",
            encrypt=True,
        )
        network = NetworkConstruct(self, "network", props)
        TerraformOutput(self, "vpc_id", value=network.vpc.id)


app = App()
for name, environment_props in ENVIRONMENTS.items():
    PlatformStack(app, name, environment_props)
app.synth()

Provider Configuration & State Management Boundaries

Provider configuration dictates API authentication and resource routing. Pin versions explicitly and isolate state per environment. Shared state files cause concurrent write conflicts and cross-module drift.

Provider Configuration & State Management Boundaries Provider Configuration & State Management Boundaries: Provider then Provider then API then Provider Version then Alias Mapping Provider Provider API Provider Version Alias Mapping
Provider Configuration & State Management Boundaries: the stages run left to right — Provider, Provider, API, Provider Version, Alias Mapping.

Provider Version Pinning & Alias Mapping

Define provider constraints using semantic version ranges. Use ~> or >= constraints to allow patch updates while preventing breaking changes. Exact pinning is appropriate for production stacks where any provider schema change must be explicitly reviewed.

Multi-region deployments require explicit provider aliasing. Each alias binds to a specific region and credential scope. Pass the alias reference to resource constructors to route API calls correctly.

Provider schema translation handles custom resource mapping. The bridging layer converts Python arguments into Terraform provider blocks. Consult Terraform Provider Bridging for schema translation patterns.

In CDKTF the pin exists in two places and the two must agree. cdktf.json names the provider and the version constraint used to generate bindings; the installed cdktf-cdktf-provider-aws wheel is the generated result of that constraint. If a colleague edits cdktf.json without regenerating, cdktf synth emits JSON referencing arguments the installed binding never knew about, and Terraform reports an unsupported argument rather than a Python error. Treat the binding version in your lockfile as the real pin and the cdktf.json entry as documentation of how it was produced.

Aliases are ordinary Python objects, which makes multi-region composition pleasant: build the provider once, hold it in a variable, and hand it to whichever construct needs it.

# platform/multi_region.py
# CLI: cdktf synth && terraform -chdir=cdktf.out/stacks/prod plan
from cdktf import TerraformStack
from constructs import Construct
from cdktf_cdktf_provider_aws.provider import AwsProvider
from cdktf_cdktf_provider_aws.s3_bucket import S3Bucket


class ReplicatedBuckets(TerraformStack):
    def __init__(self, scope: Construct, ns: str) -> None:
        super().__init__(scope, ns)
        primary = AwsProvider(self, "aws", region="eu-west-1")
        # Provider note: an additional provider must carry alias=; without it Terraform
        # fails at init with "Duplicate provider configuration".
        replica = AwsProvider(self, "aws-us", region="us-east-1", alias="use1")

        S3Bucket(self, "primary", bucket="acme-assets-euw1", provider=primary)
        # State implication: both buckets live in ONE state file — an alias routes API
        # calls to another region, it does not create another state boundary.
        S3Bucket(self, "replica", bucket="acme-assets-use1", provider=replica)

Remote State Isolation & Locking Strategies

Configure remote backends for all production workloads. Use S3 with DynamoDB locking for AWS. GCS supports native object generation locking. Terraform Cloud provides managed state with audit trails.

Workspace isolation prevents cross-environment drift. Map each workspace to a specific environment tier. Never share state files between independent constructs.

CLI: cdktf diff --stack prod-network executes a dry-run plan against the isolated workspace. It validates resource changes without modifying remote state.

State locking prevents concurrent modifications. CI/CD pipelines must acquire locks before plan execution. Implement exponential backoff and timeout strategies for lock contention.

The boundary that matters is the stack, not the construct. Constructs are free: you can nest twenty of them and the synthesized output is still one Terraform configuration with one state file. A stack costs something — a separate backend key, a separate lock, a separate plan, and any value crossing from one stack to another has to travel through an output and a remote state data source instead of a plain Python reference. Split into stacks when you want an independent blast radius or an independent deployment cadence, and keep everything else in constructs. The mechanics of that split live in State Backend Configuration for CDKTF, with the broader treatment under managing IaC state.

Packaging & Distributing Construct Libraries

Once two repositories want the same construct, the interesting question stops being how to write it and becomes how to ship it. Vendoring by copy forks the code on day one: a fix applied in one repository never reaches the others, and the divergence stays quiet because nothing errors.

How should a construct be shared? How should a construct be shared?: choose among 3 options. Who consumes this construct? one repo src/ package, noindex one org private index, e.g.CodeArtifact everyone PyPI with SemVer andchangelog
The distribution choice sets the credential model and how quickly a bad release can be withdrawn.

Version numbers mean something sharper for infrastructure than for application libraries. A patch release must synthesize byte-identical JSON for existing callers — anything else becomes a plan against production. A minor release may add optional props whose defaults preserve the previous output. A major release is any change that alters a logical ID, removes a prop, or changes a default, because every one of those shows up in somebody's plan as a resource being replaced. A changelog entry naming the affected resource addresses is worth more to a consumer than the version number itself.

# tests/test_release_compat.py — guard the patch-release promise
# CLI: python -m pytest tests/test_release_compat.py -q
import json
from pathlib import Path
from cdktf import Testing, TerraformStack
from platform.network import NetworkConstruct
from platform.props import NetworkProps


def test_synth_output_matches_golden_file() -> None:
    app = Testing.app()
    stack = TerraformStack(app, "compat")
    NetworkConstruct(stack, "network", NetworkProps("10.0.0.0/16", ["eu-west-1a"]))

    current = json.loads(Testing.synth(stack))
    golden = json.loads(Path("tests/golden/network.json").read_text())
    # State implication: a difference in these keys means existing consumers would see
    # resources replaced — acceptable in a major release, forbidden in a patch.
    assert current["resource"].keys() == golden["resource"].keys()

The step-by-step for the release pipeline itself — index choice, src/ layout, package metadata, and the credential model for uploads — is covered in publishing CDKTF constructs as a Python package, including why the publish job should hold no cloud credentials at all.

Testing Strategies & CI/CD Pipeline Hooks

Testing boundaries must validate both Python logic and generated infrastructure. Mock cloud APIs for unit tests. Parse synthesized JSON for integration validation. Enforce policy gates before apply.

Testing Strategies & CI/CD Pipeline Hooks Testing Strategies & CI/CD Pipeline Hooks: pytest then Testing Strategies then CD Pipeline Hooks then Python then Mock pytest Testing Strategies CD Pipeline Hooks Python Mock
Testing Strategies & CI/CD Pipeline Hooks: the stages run left to right — pytest, Testing Strategies, CD Pipeline Hooks, Python, Mock.

Unit & Integration Testing with pytest

Unit tests verify construct initialization and configuration validation. Integration tests execute cdktf synth and parse the output. Snapshot testing detects unexpected resource attribute changes.

# tests/test_network_construct.py
# CLI: python -m pytest tests/test_network_construct.py -q
import json
import pytest
from cdktf import Testing, TerraformStack
from platform.network import NetworkConstruct
from platform.props import NetworkProps


def test_synthesis_emits_one_vpc_and_two_subnets() -> None:
    """Verify synthesis produces the resource shape consumers depend on."""
    app = Testing.app()
    stack = TerraformStack(app, "test-stack")
    NetworkConstruct(
        stack,
        "network",
        NetworkProps(cidr_block="10.0.0.0/16", availability_zones=["eu-west-1a", "eu-west-1b"]),
    )

    manifest = json.loads(Testing.synth(stack))
    # Provider note: no AWS call happens here — synthesis is entirely in-memory.
    assert len(manifest["resource"]["aws_vpc"]) == 1
    assert len(manifest["resource"]["aws_subnet"]) == 2


def test_rejects_a_cidr_too_small_to_subnet() -> None:
    with pytest.raises(ValueError, match="too small for per-AZ subnets"):
        NetworkProps(cidr_block="10.0.0.0/24", availability_zones=["eu-west-1a"])

Three tiers earn their place. Props tests are pure Python and need neither CDKTF nor Node, so they run in milliseconds and are where CIDR arithmetic, tag merging, and naming rules belong. Synthesis tests use Testing.synth and assert on the parsed JSON — resource counts, specific arguments, the presence of an encryption block — catching the class of bug where a prop silently fails to reach the resource. Golden-file tests compare the whole synthesized document against a committed snapshot and are the only tier that catches an unintended change of logical ID, which is the change that hurts most.

Policy Enforcement & Drift Detection Gates

Map CI/CD stages to explicit validation gates. Run linters and type checkers first. Execute unit tests against isolated stacks. Run cdktf diff (which invokes terraform plan) for plan validation.

CLI: pytest -v --tb=short tests/ runs the test suite with strict failure boundaries. Combine with mypy and ruff for static analysis.

Parallel execution requires workspace isolation. Never run concurrent applies against the same state file. Implement approval gates for production modifications.

Rollback strategies depend on state integrity. If a plan fails validation, the pipeline must halt. Manual intervention resolves drift before re-execution.

Order the pipeline by cost, cheapest first, and let every stage that can run without credentials do so. ruff and mypy --strict need nothing at all. pytest on the props and synthesis tiers needs Node but no cloud access. Only cdktf diff needs a role, and it needs a read-only one: a plan requires Describe* and Get*, not Create*. Granting the plan stage write permissions is a common and unnecessary escalation, and it is why a mis-triggered pipeline is occasionally able to do more than report.

Verification

A construct is verified when three statements hold: it type-checks, it synthesizes the resources you expect, and the plan it produces against a real backend is the plan you intended. Check them in that order, because each is roughly an order of magnitude slower than the last.

Feedback loop cost per construct check Feedback loop cost per construct check: mypy --strict, pytest unit tests, cdktf synth, terraform plan on AWS. mypy --strict ~3 s pytest unit tests ~9 s cdktf synth ~25 s terraform plan on AWS ~95 s
Push assertions to the cheapest loop that can hold them; a plan against a live account is the slowest place to learn a CIDR is malformed.
# CLI: the full local gate, cheapest stage first
python -m ruff check platform/ tests/
python -m mypy platform/ --strict
python -m pytest tests/ -q
cdktf synth
python -c "import json,pathlib; d=json.loads(pathlib.Path('cdktf.out/stacks/prod/cdk.tf.json').read_text()); print(sorted(d['resource']))"

Two checks on the synthesized output are worth automating. First, assert that the backend block exists and is not local — a stack that synthesizes with no terraform.backend writes state into a file inside cdktf.out/, which CI then discards, so the next run believes nothing exists and plans to create everything. Second, assert that resource addresses are stable across a refactor by diffing the sorted key list of resource against the previous commit; an empty difference means no consumer will see a replacement.

# tools/assert_remote_backend.py
# CLI: python tools/assert_remote_backend.py prod
import json
import pathlib
import sys

stack_name: str = sys.argv[1]
doc = json.loads(pathlib.Path(f"cdktf.out/stacks/{stack_name}/cdk.tf.json").read_text())
backend: dict[str, object] = doc.get("terraform", {}).get("backend", {})
# State implication: an empty backend block means state was written locally and lost.
if "s3" not in backend:
    raise SystemExit(f"{stack_name} synthesized without an S3 backend — refusing to apply")
print("backend key:", backend["s3"]["key"])

Common Mistakes & Anti-Patterns

Common Mistakes & Anti Patterns Common Mistakes & Anti Patterns: Where it breaks with 3 facets. Where it breaks Omitting Pytho watch this boundary API watch this boundary JSON watch this boundary
Common Mistakes & Anti Patterns: the boundaries where things break and what to check.
  • Omitting Python 3.9+ type hints: Leads to runtime schema mismatches during synthesis. Always annotate configuration classes.
  • Using mutable global state: Causes cross-module drift and unpredictable test results. Inject dependencies explicitly.
  • Failing to implement explicit state locking: Results in concurrent write conflicts in CI/CD pipelines. Enforce backend locking configurations.
  • Hardcoding provider versions: Breaks reproducibility and blocks security patches. Use constraint ranges with upper bounds.
  • Skipping integration tests: Leaves cloud API responses unvalidated. Always parse synthesized JSON plans before deployment.
  • Treating a rename as cosmetic: Changing the id string passed to a construct rewrites the Terraform address and produces a destroy/create plan. Use override_logical_id when the name must change and the resource must not.
  • Doing work in the construct that belongs in the props: Reading environment variables, calling boto3, or branching on os.getenv inside __init__ makes a construct untestable and non-deterministic between runs.
  • One enormous stack: A single stack holding networking, data stores, and applications means every change plans everything and one lock serialises the whole organisation. Split by blast radius, not by tidiness.

Troubleshooting

jsii.errors.JSIIError: There is already a Construct with name 'network' in PlatformStack [prod]. Two constructs share a parent scope and a logical name. Inside a loop the name must vary — f"subnet-{index}", not "subnet". The name only has to be unique among siblings, so two different parents may each contain a network.

ModuleNotFoundError: No module named 'cdktf_cdktf_provider_aws'. The provider binding was never installed, or it was installed into a different virtualenv than the one cdktf runs from. Reinstall it into the active environment and confirm cdktf.json lists the provider so cdktf get can regenerate the bindings if you use generated rather than prebuilt packages.

Validation failed with the following errors: followed by a construct path. Your own validation callbacks rejected the tree. CDKTF aggregates every validation error before failing, so fix them together rather than one synth at a time; the path in each message is the construct's position in the tree and maps directly onto a line of your Python.

Error: Duplicate provider configuration at terraform init. Two AwsProvider instances exist in one stack and the second has no alias. Give every provider after the first an explicit alias, then pass the provider object to each resource that should use it.

Error: Backend configuration changed after editing S3Backend. Terraform found a state file initialised against a different backend. Run terraform -chdir=cdktf.out/stacks/<name> init -reconfigure when the old state is disposable, or -migrate-state when it is not — and copy the existing state object first, following migrating IaC state between backends.

A plan shows every resource being replaced after a refactor. You moved constructs in the tree and every logical ID moved with them. Do not apply. Either revert the move and reapply it with override_logical_id pinning the old addresses, or accept the replacement deliberately and only for resources that hold no data.

Key Takeaways

Python construct composition is CDKTF's strongest feature. The dataclass and Protocol patterns shown above let you build infrastructure libraries with the same rigor as application libraries—typed interfaces, validated inputs, and fast unit tests that run without cloud credentials. Invest in the module boundary design early; restructuring a monolithic CDKTF stack into constructs after the fact is expensive.

The three-layer split — props, construct, stack — is the part to adopt first. It costs almost nothing on day one, and it is what makes the later work possible: testing without credentials, packaging without refactoring, and reasoning about a plan without reading the whole repository.

FAQ

How do I enforce strict typing for IaC module inputs in Python?

Use TypedDict for JSON-compatible configuration payloads and a frozen dataclass for the validated object your constructs actually consume. Put runtime checks in __post_init__ so a bad region or CIDR raises before any Terraform address is minted. Running mypy --strict over the props module then catches the mismatches that annotations alone only document.

Will renaming a construct destroy my infrastructure?

Yes, unless you pin the address. CDKTF derives each Terraform logical ID from the construct's path, so renaming a construct or moving it under a different parent changes the address, and Terraform reads a changed address as a destroy followed by a create. Call override_logical_id with the original address to rename in Python while leaving the plan empty.

When should I split a construct out into its own stack?

Split when you want a separate blast radius, a separate deployment cadence, or a separate lock — not merely for organisation. Constructs cost nothing and nest freely inside one stack, whereas a stack costs a backend key, a plan, and cross-stack references through outputs and remote state data sources. Group things that should fail together.

Can I test Python IaC constructs without deploying to the cloud?

Yes, and most of your tests should be. Testing.synth(stack) resolves the construct graph in memory and returns the Terraform JSON as a string, so you can assert on resource counts, arguments, and backend configuration with no credentials present. Only the final cdktf diff stage needs an AWS role, and that role only needs read permissions.

Why does a resource attribute print a ${...} string instead of a real ID?

Because synthesis runs before Terraform does. Resource attributes are tokens — placeholders Terraform substitutes at apply time — so they are safe to pass into other resources and into f-strings, but meaningless to compare, slice, or do arithmetic on. If a decision needs a real cloud value, take it from a data source or compute it in the props before the construct runs.

Should a shared construct pin its provider binding or leave it open?

Declare a range with an upper bound, such as cdktf-cdktf-provider-aws>=19,<20, and let the consuming application's lockfile choose the exact version. An exact pin inside a library makes it impossible for two construct packages to coexist in one environment, while an unbounded requirement lets a major binding release change your synthesized JSON with no change on your side.