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.

Prerequisites

  • 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

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}")

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]

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.

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.

# 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"

Run pytest and mypy --strict together; the typed props plus the synthesis assertion catch both contract violations and resource-graph regressions before any terraform plan.

Gotchas & Edge Cases

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.

Frequently Asked Questions

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.