Building Reusable CDKTF Constructs in Python

Building reusable CDKTF constructs in Python lets you package a resource graph—say a VPC with subnets, or an encrypted S3 bucket with a logging policy—behind a single typed class that any stack can instantiate with a few validated inputs. This task lives within Python constructs and modules under CDKTF Workflows & Terraform Synthesis, and it is the practical payoff of CDKTF: applying ordinary software design—encapsulation, typed interfaces, composition—to infrastructure.

Without reusable constructs, teams copy resource blocks between stacks and drift apart over time: one VPC enables flow logs, another forgets, and a third mis-sizes its subnets. A construct collapses that duplication into one tested abstraction with an explicit configuration contract, so every consumer gets the same hardened defaults and the same dependency wiring.

Context

CDKTF inherits its construct model from the constructs library that also backs the AWS CDK. Every object you instantiate — a provider, a resource, a TerraformStack, your own class — registers itself as a node in a tree under an App. The node knows its scope (its parent), its id (a string unique among its siblings), and its children. Nothing in this tree exists at the Terraform level: when app.synth() runs, CDKTF walks the tree depth-first, asks each resource node to emit its JSON body, and writes a flat cdktf.out/stacks/<stack>/cdk.tf.json document containing only provider, resource, data, output and terraform blocks. Your construct class disappears entirely; what survives is the naming and the wiring it produced.

From construct tree to Terraform address From construct tree to Terraform address: Network construct node with 4 facets. Network construct node scope the parent that owns this node id unique among siblings only path hash 8 chars appended for uniqueness cdk.tf.json flat blocks, no construct left
The construct tree is a synthesis-time artefact; only the derived logical IDs reach Terraform.

That erasure is why the naming rules matter more than they look. CDKTF derives a resource's Terraform logical ID from the construct path — the ids of every ancestor between the stack and the resource, joined and suffixed with a short deterministic hash of the full path. A Subnet created with id subnet-0 inside a Network construct given id core-net becomes something like core-net_subnet-0_9F3AC21B in the emitted JSON, and the state address aws_subnet.core-net_subnet-0_9F3AC21B follows from it. The hash exists so that two constructs of the same class in different scopes cannot collide; the consequence is that moving a construct to a different parent, or renaming its instance id, changes the address of every resource beneath it. Terraform reads that as "the old resource is gone, a new one is needed" and plans a destroy/create — on a VPC, that is a full network rebuild.

So the boundary you draw around a construct is a durable commitment, not a refactoring detail. Choose it around things that are created and destroyed together and that a consumer would reasonably want as a unit: a network, a service's compute plus its role, a bucket plus its policy and logging target. Splitting or merging that boundary later requires either terraform state mv for every affected address or an override_logical_id call to pin the old name. The mechanics of that pinning are covered in controlling CDKTF stack and construct naming.

Prerequisites

Prerequisites Prerequisites: layered from cdktf down to Python. cdktf constructs mypy pytest Python
Prerequisites: the building blocks this section assembles.
  • Python 3.9+ with cdktf and the constructs package installed (both come with a cdktf init --template=python scaffold).
  • A pinned provider binding such as cdktf-cdktf-provider-aws; see pinning Terraform provider versions in CDKTF so your construct compiles against a known schema.
  • mypy and pytest for type checking and snapshot tests of the construct.
  • Familiarity with construct scope and logical IDs, covered under Python constructs and modules.
  • No cloud credentials are needed to author or unit-test a construct; synthesis runs entirely in memory.

Implementation

1. Define a typed props dataclass

Implementation Implementation: 1. Define a typed then 2. Subclass then 3. Compose the 1. Define a typed 2. Subclass 3. Compose the
Implementation: the stages run left to right — 1. Define a typed, 2. Subclass, 3. Compose the.

Give the construct a single, frozen configuration object instead of a long list of keyword arguments. A frozen dataclass with __post_init__ validation catches bad inputs before any resource is created.

# constructs/network_props.py
# CLI: python -m mypy constructs/network_props.py --strict
from __future__ import annotations
from dataclasses import dataclass, field


@dataclass(frozen=True)
class NetworkProps:
    """Validated inputs for the reusable network construct."""
    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:
        # Provider note: fail fast in Python rather than at terraform plan;
        # synthesis errors are far cheaper to debug than provider API errors.
        if not self.availability_zones:
            raise ValueError("availability_zones must list at least one AZ")
        if "/" not in self.cidr_block:
            raise ValueError(f"cidr_block must be CIDR notation: {self.cidr_block}")

Three properties of that class earn their keep. frozen=True makes the props hashable and stops a consumer mutating them after the construct has already read them — a real hazard when one props object is passed to two constructs. field(default_factory=dict) is mandatory rather than stylistic: a bare tags: dict[str, str] = {} raises ValueError: mutable default <class 'dict'> for field tags is not allowed: use default_factory at import time. And __post_init__ runs before any CDKTF object exists, so an invalid CIDR surfaces as a Python traceback in under a second instead of as Error: creating EC2 VPC: InvalidVpcRange: The CIDR '10.0.0/16' is invalid twenty minutes into an apply.

Keep validation to things you can decide locally. Checking that a CIDR has a prefix length is fine; checking that it does not overlap an existing VPC is not, because that requires an API call and would make synthesis depend on live cloud state.

2. Subclass Construct and build the resource graph

The construct subclasses constructs.Construct, takes its parent scope, a logical id, and the typed props. It creates the underlying resources and exposes only the identifiers consumers need as typed attributes.

# constructs/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 constructs.network_props import NetworkProps


class Network(Construct):
    """Reusable VPC + one subnet per availability zone."""

    def __init__(self, scope: Construct, id: str, *, props: NetworkProps) -> None:
        super().__init__(scope, id)

        self._vpc = Vpc(
            self,
            "vpc",
            cidr_block=props.cidr_block,
            enable_dns_support=True,
            enable_dns_hostnames=True,
            tags=props.tags,
        )

        self._subnets: list[Subnet] = []
        for index, az in enumerate(props.availability_zones):
            subnet = Subnet(
                self,
                f"subnet-{index}",
                vpc_id=self._vpc.id,  # implicit reference creates a DAG edge
                cidr_block=f"10.0.{index}.0/24",
                availability_zone=az,
                tags=props.tags,
            )
            self._subnets.append(subnet)

    @property
    def vpc_id(self) -> str:
        # State implication: expose tokens, not resolved values—the real ID is
        # only known after apply, so consumers must treat this as a reference.
        return self._vpc.id

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

Note the deliberate asymmetry between what the constructor accepts and what the class exposes. Inputs arrive as one props object; outputs leave as narrow read-only properties. The Vpc and Subnet objects themselves stay private (self._vpc, self._subnets) because handing a consumer the raw resource lets them mutate arguments the construct is responsible for — someone downstream setting enable_dns_hostnames=False on your VPC defeats the point of the abstraction. If a consumer genuinely needs an attribute you did not expose, add a property; that keeps the surface area a reviewable decision rather than an accident.

The keyword-only * before props is also load-bearing. Construct.__init__ takes scope and id positionally, and jsii-generated resource classes follow the same convention, so making everything after the id keyword-only prevents a caller silently passing props where an id was expected.

3. Compose the construct inside a stack

Stacks map to Terraform state files; constructs are the composable units inside them. A consuming stack instantiates the construct, passes validated props, and wires its outputs into other resources by reference.

# main.py: compose the reusable construct into a deployable stack
# CLI: cdktf get && cdktf synth
from constructs import Construct
from cdktf import App, TerraformStack, TerraformOutput
from cdktf_cdktf_provider_aws.provider import AwsProvider
from constructs.network import Network
from constructs.network_props import NetworkProps


class PlatformStack(TerraformStack):
    def __init__(self, scope: Construct, ns: str, *, region: str) -> None:
        super().__init__(scope, ns)
        AwsProvider(self, "aws", region=region)

        network = Network(
            self,
            "core-net",
            props=NetworkProps(
                cidr_block="10.0.0.0/16",
                availability_zones=["us-east-1a", "us-east-1b"],
                tags={"team": "platform"},
            ),
        )

        # Consume the construct's typed outputs as cross-resource references.
        TerraformOutput(self, "vpc_id", value=network.vpc_id)


app = App()
PlatformStack(app, "platform", region="us-east-1")
app.synth()

Because the construct exposes vpc_id and subnet_ids as tokens, any other resource in the stack—or another construct—can take a dependency on the network simply by referencing those attributes. CDKTF adds the DAG edge automatically.

4. Leave an escape hatch for fields the construct does not model

No props object anticipates every argument a provider will grow. Rather than adding a pass-through parameter for each one, expose the underlying resource through a narrowly scoped method that uses CDKTF's override API. add_override writes directly into the synthesized JSON for that resource, bypassing the typed binding entirely, which makes it the right tool for a field the pinned provider version predates.

# constructs/network.py (continued): controlled escape hatch
# CLI: cdktf synth && cat cdktf.out/stacks/platform/cdk.tf.json | jq '.resource.aws_vpc'
from typing import Any


class Network(Construct):
    ...

    def override_vpc_field(self, path: str, value: Any) -> None:
        """Set a raw Terraform argument on the VPC this construct owns."""
        # Provider note: add_override edits the emitted JSON, so it accepts
        # arguments the pinned cdktf-cdktf-provider-aws binding has no class
        # attribute for. It is unchecked — a typo lands in cdk.tf.json.
        self._vpc.add_override(path, value)

    def pin_vpc_logical_id(self, name: str) -> None:
        # State implication: freezes the Terraform address to `aws_vpc.<name>`,
        # which is how you adopt a construct without a destroy/create plan.
        self._vpc.override_logical_id(name)

Both methods are unchecked by design, so treat them as an audit surface: every call is a place where the construct's guarantees stop applying. In review, ask whether the override should instead become a typed prop with a default, which is the version future consumers can discover.

Verification

Constructs are testable without cloud credentials. Use cdktf.Testing to synthesize in memory and assert on the resulting JSON, which doubles as a snapshot test against accidental regressions.

Verification Verification: Test → Program → Mock/Cloud. Test Program Mock/Cloud invoke declare resolve assert
Verification: the test drives the program and asserts on resolved values.
# tests/test_network.py
# CLI: pytest tests/test_network.py
import json
from cdktf import Testing, TerraformStack
from cdktf_cdktf_provider_aws.provider import AwsProvider
from constructs.network import Network
from constructs.network_props import NetworkProps


def test_network_creates_subnet_per_az() -> None:
    app = Testing.app()
    stack = TerraformStack(app, "test")
    AwsProvider(stack, "aws", region="us-east-1")

    Network(
        stack,
        "net",
        props=NetworkProps(
            cidr_block="10.0.0.0/16",
            availability_zones=["us-east-1a", "us-east-1b"],
        ),
    )

    manifest = json.loads(Testing.synth(stack))
    subnets = manifest["resource"]["aws_subnet"]
    assert len(subnets) == 2, "one subnet per availability zone expected"

Testing.synth(stack) returns the stack's JSON as a string, so any assertion you can express against a parsed dictionary is available. For the common cases CDKTF ships matchers that read better than hand-rolled dictionary walks:

# tests/test_network_properties.py
# CLI: pytest tests/test_network_properties.py -q
from cdktf import Testing, TerraformStack
from cdktf_cdktf_provider_aws.provider import AwsProvider
from cdktf_cdktf_provider_aws.vpc import Vpc
from constructs.network import Network
from constructs.network_props import NetworkProps
import pytest


def _synth() -> str:
    app = Testing.app()
    stack = TerraformStack(app, "test")
    AwsProvider(stack, "aws", region="eu-west-1")
    Network(stack, "net", props=NetworkProps(
        cidr_block="10.0.0.0/16", availability_zones=["eu-west-1a"]))
    return Testing.synth(stack)


def test_vpc_has_hardened_defaults() -> None:
    assert Testing.to_have_resource_with_properties(
        _synth(), Vpc.TF_RESOURCE_TYPE,
        {"enable_dns_hostnames": True, "enable_dns_support": True},
    )


def test_invalid_cidr_is_rejected_before_synthesis() -> None:
    # State implication: the guard runs at construction time, so no node is
    # ever added to the tree and nothing reaches cdk.tf.json.
    with pytest.raises(ValueError, match="cidr_block must be CIDR notation"):
        NetworkProps(cidr_block="10.0.0", availability_zones=["eu-west-1a"])

Two assertions of different kinds belong in every construct's test file: one that the emitted graph has the shape you promise, and one that a bad input fails loudly rather than synthesizing something plausible. Run pytest and mypy --strict together; the typed props plus the synthesis assertions catch both contract violations and resource-graph regressions before any terraform plan. If you want the JSON itself frozen against unintended change, the approach in snapshot testing CDKTF stacks with pytest layers cleanly on top of these.

Gotchas & Edge Cases

Gotchas & Edge Cases Gotchas & Edge Cases: Where it breaks with 4 facets. Where it breaks vpc_id watch this boundary str watch this boundary TerraformOutpu watch this boundary Edge Cases watch this boundary
Gotchas & Edge Cases: the boundaries where things break and what to check.

Hardcoded child logical IDs collide on reuse. If a construct names a child resource with a fixed string and you instantiate the construct twice in one stack, the logical IDs clash. Always derive child IDs from loop indices or props (as f"subnet-{index}" above) and give each construct instance a distinct id in its parent scope.

Leaking resolved values instead of tokens. A construct attribute like vpc_id is a token resolved only at apply time, not a plain string. Do not call str(), slice it, or build other strings from it during synthesis—pass it through unchanged. Manipulating a token produces a literal ${...} fragment in the JSON and breaks the dependency graph.

Putting cross-stack references in a shared construct. A construct belongs to one stack's state. If two stacks must share a value, do not reach across with a Python reference; export it with TerraformOutput and consume it via remote state. Mixing the two breaks during synthesis because the value is not known across state boundaries.

Duplicate child ids inside one construct. Building children in a loop that can yield the same key twice — for example keying subnets by availability zone when the input list contains a repeat — fails at construction with RuntimeError: There is already a Construct with name 'subnet-eu-west-1a' in Network [core-net]. The message names the offending id and the parent's path, which is usually enough to find the input that repeated. Deduplicate in __post_init__ rather than defending inside the loop.

Branching on a token value. if props.enable_flow_logs: is fine because that is a real Python bool. if network.vpc_id.startswith("vpc-"): is not: during synthesis the value is the string ${aws_vpc.core-net_vpc_9F3AC21B.id}, so the branch evaluates against the placeholder and silently picks the wrong side. Anything that must vary by a value only known after apply belongs in a Terraform conditional expression, not in Python control flow.

Provider objects instantiated inside the construct. Creating an AwsProvider in the construct rather than the stack looks convenient until two constructs do it and synthesis emits two default provider "aws" blocks; Terraform then fails the plan with Error: Duplicate provider configuration. Constructs should consume the ambient provider from their scope, and accept an explicit provider= argument only when they genuinely target a second region or account.

Operational Notes

A construct that more than one team consumes is a piece of software with a release process, not a file in a shared folder. The most useful discipline is deciding, for every change, which of three categories it falls into, because the categories map directly onto what Terraform will do to existing consumers.

Construct change classes Construct change classes: comparison across Plan on upgrade, Release. Change Plan on upgrade Release Add prop with old default no diff minor Change an existing default creates or edits resources major Rename a child id destroy and create major Add a new child resource create only minor Widen provider range schema-dependent test both ends
Classify every construct change by the plan it produces for an existing consumer.

Additive changes — a new prop with a default that reproduces the previous behaviour — are safe to ship as a minor version, because a consumer who upgrades and re-synthesizes gets a byte-identical plan. Changes to a default are the trap: flipping enable_flow_logs from False to True is one line, but every consumer's next plan creates a log group and an IAM role they did not ask for. That deserves a major version and a note in the changelog naming the resources that appear. Anything that alters a child id or the construct's own nesting is also a major version, since it renames Terraform addresses.

Deprecate rather than delete. A prop you want to remove can keep working for a release while warning:

# constructs/network_props.py: deprecation with a real warning
# CLI: python -W error::DeprecationWarning -m pytest tests/
import warnings


@dataclass(frozen=True)
class NetworkProps:
    cidr_block: str
    availability_zones: list[str]
    enable_flow_logs: bool = True
    single_nat_gateway: bool | None = None  # deprecated, use nat_strategy

    def __post_init__(self) -> None:
        if self.single_nat_gateway is not None:
            warnings.warn(
                "single_nat_gateway is deprecated; use nat_strategy='single'",
                DeprecationWarning,
                stacklevel=2,
            )

Running the consuming test suite with -W error::DeprecationWarning turns that warning into a build failure on the consumer's side, which is how a deprecation actually gets actioned instead of ignored for a year.

Pin the provider binding the construct compiles against, and record the range it supports. A construct built on cdktf-cdktf-provider-aws 19.x that a consumer resolves against 21.x may fail at import with ImportError: cannot import name 'Vpc' from 'cdktf_cdktf_provider_aws.vpc' after a module reshuffle, or — worse — synthesize an argument the newer schema renamed. Declare the binding as a real dependency with an upper bound and test against both ends of the range in CI. When the construct is ready to leave the repository it was written in, publishing CDKTF constructs as a Python package covers the packaging and distribution side.

Finally, keep a synthesized fixture in the construct's own repository and diff it in CI. Reviewing a pull request that changes a construct is much easier when the diff shows the resulting cdk.tf.json change alongside the Python change; a one-line default flip that produces forty lines of new JSON is immediately visible for what it is.

FAQ

What is the difference between a CDKTF construct and a stack?

A stack (TerraformStack) maps to a single Terraform state file and is the deployable unit. A construct (Construct) is a reusable, composable group of resources that lives inside a stack. You write constructs to package and share resource graphs, and you place one or more of them into stacks to deploy them.

How do I pass values between two constructs in the same stack?

Expose the producing construct's identifiers as typed properties that return tokens (like vpc_id above), then pass those tokens into the consuming construct's props. CDKTF creates the dependency edge automatically from the reference — you never resolve the value yourself during synthesis.

Can I unit-test a construct without AWS credentials?

Yes. Use cdktf.Testing.app() and Testing.synth(stack) to synthesize entirely in memory, then assert on the parsed JSON. No provider API calls are made, so no credentials are required — this is the basis for snapshot testing your constructs in CI.

Should construct inputs be keyword arguments or a props object?

Prefer a single typed, frozen props dataclass. It centralizes validation in __post_init__, gives consumers one object to construct and test, keeps the constructor signature stable as inputs grow, and works cleanly with mypy --strict.

Why did renaming my construct trigger a destroy and recreate?

Because the Terraform logical ID is derived from the construct path, renaming the instance id or moving it to a different parent changes every child address. Terraform sees unknown addresses and plans replacements. Either accept the rebuild, run terraform state mv for each address, or call override_logical_id to pin the old names.

How large should a single construct be?

Size it by lifecycle, not by line count. Resources that are created, updated and destroyed together — and that a consumer would want as one unit — belong in one construct; anything with an independent lifecycle should be its own construct or its own stack, because splitting it later means renaming state addresses.