CDKTF Architecture and Synthesis

A CDKTF program never talks to a cloud API. It builds a tree of objects in memory, hands that tree to a compiler, and the compiler writes a JSON file that Terraform then executes. Everything that feels surprising about CDKTF — attributes that print as ${TfToken[TOKEN.7]}, resource addresses with a hash glued on the end, a Python exception whose traceback is half JavaScript — follows from that one architectural fact. This topic sits inside CDKTF workflows and Terraform synthesis and explains the compiler: what runs when you call a constructor, when values become concrete, how the construct tree turns into cdk.tf.json, and which parts of the output you can safely reach around when the typed API does not cover your case.

Problem Framing

Practitioners arrive at CDKTF from two directions and get stuck in two different places.

Coming from Terraform, the instinct is to read a CDKTF program as HCL with different punctuation. That reading holds until the first time a value does not behave like a value. In HCL, aws_vpc.main.id is an expression the language evaluates during the plan, and everyone knows it. In Python, vpc.id is an attribute access that returns immediately, so it looks like data — and then if vpc.id.startswith("vpc-"): runs at synthesis time against a placeholder string, takes the wrong branch, and emits a configuration nobody reviewed. The generated JSON is valid. The plan is valid. The infrastructure is wrong.

Coming from application Python, the instinct is the opposite: treat the program as a script that does things. But a CDKTF constructor performs no work in the cloud sense. S3Bucket(self, "reports", bucket="acme-reports") registers a node in a tree and returns a proxy. Nothing is created, nothing is validated against AWS, and nothing about the current state of your account is consulted. The object you hold is a description that will be serialised, not a handle to a live thing. Code that assumes otherwise — a loop that queries the resource it just declared, a try/except around a constructor expecting an API failure — silently does nothing useful.

Both mistakes share a root cause: not knowing which of the three phases a given line of code executes in. Construction builds the tree. Synthesis flattens the tree into JSON and resolves every deferred value. Apply, run by Terraform against the JSON, is the only phase where the cloud exists. Python control flow lives entirely in the first phase; Terraform functions and interpolations live entirely in the third. The rest of this page is about the boundary between them, and about the artefacts synthesis produces so you can inspect that boundary directly instead of guessing.

Which phase a line of code runs in Which phase a line of code runs in: One CDKTF program, three phases with 4 facets. One CDKTF program, threephases Construction Python builds a tree of construct objects in memory Synthesis tokens resolve and cdk.tf.json is written to disk Apply Terraform reads the JSON and calls the cloud API The trap Python if/else can only see phase one
Python control flow runs before any value exists; Terraform expressions run after synthesis has finished.

Prerequisites

Everything below assumes a project that already synthesises. If cdktf synth does not currently write a cdk.tf.json, fix that before reading further, because every diagnostic technique here is applied to that file.

  • Python 3.9 or newer with cdktf>=0.20 and constructs>=10.3 installed in a virtualenv the CLI can see.
  • Node.js 18 or newer on PATH. This is not optional and it is not only for the CLI: the jsii runtime starts a Node child process to host the actual CDKTF implementation, so a Python-only environment cannot synthesise at all.
  • Terraform 1.5+ available to the CLI, since cdktf deploy shells out to it and the escape hatches below emit blocks that need a recent language version.
  • Provider bindings, either the prebuilt pip packages (cdktf-cdktf-provider-aws) or a populated .gen/ directory from cdktf get. Which one you use is a reproducibility decision covered in pinning Terraform provider versions in CDKTF.
  • jq, because reading cdk.tf.json by eye stops working at about fifty resources.
# CLI: verify every layer of the toolchain the synthesis pipeline depends on
node --version                      # 18+; jsii spawns this, not your Python interpreter
cdktf --version && terraform version
python -c "import cdktf, constructs; print(cdktf.__file__)"
cdktf synth && ls cdktf.out/stacks

The jsii Bridge: What Runs When You Call a Constructor

CDKTF is written in TypeScript. The Python package you import is not a reimplementation — it is a generated proxy layer produced by jsii, the same toolchain that produces the AWS CDK's Python bindings. When your program imports cdktf, the jsii Python runtime spawns a Node.js child process, loads the compiled TypeScript into it, and opens a JSON-RPC channel over its standard input and output. Every constructor call, every property read, every method invocation crosses that channel.

A constructor call crossing the jsii bridge A constructor call crossing the jsii bridge: Your Python → jsii runtime → Node child process → Construct tree. Your Python jsii runtime Node childprocess Construct tree S3Bucket(...) JSON-RPC create register node object ref @aws.S3Bucket@10023 Python proxy
Every construct you instantiate is created in a Node process and handed back as a reference.

Three practical consequences fall out of this, and each explains a class of confusing behaviour.

Your Python objects are handles, not data. S3Bucket(self, "reports", ...) sends a create request across the channel; what comes back is an object reference like @cdktf/provider-aws.s3Bucket.S3Bucket@10023. The Python instance stores that reference and forwards attribute access. This is why vars(bucket) is uninformative, why copy.deepcopy on a construct does not do what you want, and why pickling a stack fails. It is also why constructor keyword arguments are validated on the TypeScript side: pass a string where a number is expected and you get a type error phrased in jsii's vocabulary, not Python's.

Exceptions cross the boundary. A validation failure inside CDKTF surfaces in Python as jsii.errors.JSIIError, and the message body is the JavaScript error text. A duplicate construct id, for example, comes back as jsii.errors.JSIIError: There is already a Construct with name 'reports' in ProdDataStack [prod-data] — the bracketed name is the stack's construct id, and the quoted name is the id you passed twice within the same scope. Reading these errors is easier once you accept that the stack trace has two halves and only the top half is your code.

Startup dominates small runs. Booting Node and loading the provider bindings costs a second or two before any of your code runs, which is why a trivial cdktf synth never feels instant. It also means the process is stateful: if the Node child dies mid-synthesis you get a truncated cdk.tf.json rather than a clean failure, so treat a partially written output directory as suspect and re-run with cdktf synth rather than reusing it.

# main.py — nothing here touches AWS; every line manipulates the construct tree
# CLI: cdktf synth
from constructs import Construct
from cdktf import App, TerraformStack
from cdktf_cdktf_provider_aws.provider import AwsProvider
from cdktf_cdktf_provider_aws.s3_bucket import S3Bucket


class ReportsStack(TerraformStack):
    def __init__(self, scope: Construct, ns: str, *, region: str) -> None:
        super().__init__(scope, ns)
        # Provider note: the provider block is itself a construct; omitting it makes
        # synthesis succeed and `terraform plan` fail with "Provider configuration not present".
        AwsProvider(self, "aws", region=region)

        self.bucket: S3Bucket = S3Bucket(self, "reports", bucket="acme-prod-reports")
        print(type(self.bucket.arn))   # <class 'str'> — but the value is a token, not an ARN
        # State implication: nothing is written to state until `terraform apply` runs the JSON.


def main() -> None:
    app = App()
    ReportsStack(app, "prod-data", region="eu-west-1")
    app.synth()   # the only line that produces a file


if __name__ == "__main__":
    main()

Tokens: Why an Attribute Prints as a Placeholder

The most consequential design decision in CDKTF is how it represents values that are unknowable at synthesis time. A bucket's ARN does not exist until Terraform creates the bucket. But your Python needs something to pass to the IAM policy that references it, and it needs that something to be typed as str so the generated bindings stay usable.

The answer is a token: a syntactically valid string carrying a unique marker, minted at construction time and swapped for a real Terraform interpolation during synthesis. Read bucket.arn in a debugger and you see something like ${TfToken[TOKEN.14]}. Pass that string into another construct's arguments, let it be concatenated, embedded in a dict, or joined into a longer string, and the marker travels with it. At the end of synthesis CDKTF walks the whole emitted structure, finds every marker, and replaces it with ${aws_s3_bucket.reports.arn}.

The life of a token The life of a token: Attribute read then Marker minted then Carried in args then Resolution pass then Interpolation Attribute read bucket.arn Marker minted ${TfToken[TOKEN.14]} Carried in args concat, dict, json Resolution pass walk emitted JSON Interpolation ${aws_s3_bucket...}
A token is a placeholder string that survives ordinary Python operations and is swapped out at the end of synthesis.

Tokens are what make the graph work without you declaring dependencies. Terraform builds its dependency edges by parsing interpolations in the configuration, so the moment a token from resource A lands in resource B's arguments, the emitted JSON contains a reference and the edge exists. You almost never need add_dependency(); reach for it only when the ordering requirement is real but invisible to the configuration — an IAM policy that must exist before a service assumes the role, for instance, where no attribute is passed between the two.

The failure mode is equally direct: any Python operation that inspects a token's contents is operating on a placeholder. Length checks, startswith, regular expressions, int(), dictionary lookups keyed by the value, if statements — all of them see the marker. There is no runtime error, because the marker really is a string; the branch simply resolves against nonsense.

# policy.py — the difference between reading a token and referencing it
# CLI: cdktf synth && jq '.resource.aws_iam_policy' cdktf.out/stacks/prod-data/cdk.tf.json
import json
from cdktf import Fn, Token
from cdktf_cdktf_provider_aws.iam_policy import IamPolicy

# WRONG: this predicate runs at synthesis time against "${TfToken[TOKEN.14]}"
# if bucket.arn.endswith("-reports"):
#     ...

# RIGHT: build a structure that contains the token and let synthesis resolve it.
document = {
    "Version": "2012-10-17",
    "Statement": [{
        "Effect": "Allow",
        "Action": ["s3:GetObject"],
        "Resource": [f"{bucket.arn}/*"],   # concatenation preserves the marker
    }],
}
IamPolicy(self, "reports-read", policy=json.dumps(document))
# Provider note: json.dumps is safe here because the token survives as a substring;
# the emitted JSON string contains ${aws_s3_bucket.reports.arn}/* after resolution.

# When a decision genuinely must be made at apply time, express it as a Terraform
# function so the condition lands in the configuration rather than in Python.
tier = Fn.element(Fn.split("-", Token.as_string(bucket.id)), 1)

Token.as_string, Token.as_number and Token.as_list exist to move a value between the type slots the bindings expect. They do not resolve anything — they re-encode the same marker so the type checker is satisfied. Token.is_unresolved(value) is the honest way to ask "is this real yet", and it belongs in any helper function that might be handed either a literal or a resource attribute.

From Construct Tree to cdk.tf.json

app.synth() runs a fixed sequence, and knowing the order tells you where your own hooks fit.

What app.synth() does, in order What app.synth() does, in order: layered from 1. Prepare down to 5. Write out. 1. Prepare depth-first walk; aspects visit every node 2. Validate validate hooks and Annotations errors collected 3. Serialise each stack renders itself to one JSON document 4. Resolve tokens markers replaced with Terraform interpolations 5. Write out cdk.tf.json plus manifest.json under cdktf.out
Aspects run before validation, and token resolution happens after serialisation — which is why an unreferenced token never resolves.

First, preparation: CDKTF walks the tree depth-first and gives every node a chance to contribute. Aspects registered with Aspects.of(scope).add(...) are applied here, which is why an aspect that adds tags sees every resource regardless of how deeply nested it is. Second, validation: each construct's validate hook runs and any Annotations.of(node).add_error(...) calls accumulate. If anything was flagged, synthesis aborts and the CLI prints a block beginning Validation failed with the following errors: followed by one line per offending construct path. Third, serialisation: each stack renders itself and its descendants into a single JSON document. Fourth, token resolution: the document is traversed and every marker is replaced with the interpolation it stands for. Fifth, write-out: the JSON is written to cdktf.out/stacks/<stack-id>/cdk.tf.json and a manifest.json at the root records where each stack landed.

What ends up on disk is worth knowing by name, because the CLI is a thin wrapper over it and you will eventually run Terraform directly against these directories.

Artefact Written by What it holds
cdktf.out/manifest.json synthesis stack id → working directory, synthesized path, annotations
cdktf.out/stacks/<id>/cdk.tf.json synthesis the entire Terraform configuration for one stack
cdktf.out/stacks/<id>/.terraform/ terraform init provider binaries and backend configuration
cdktf.out/stacks/<id>/.terraform.lock.hcl terraform init exact provider builds and their hashes
.gen/ or site-packages cdktf get the typed Python bindings your program imports

cdk.tf.json is ordinary Terraform JSON syntax with one CDKTF addition: a top-level "//" key holding metadata, which Terraform ignores. Under it you get the stack name, the CDKTF version, and the backend type. Everything else — terraform, provider, resource, data, module, variable, output, locals — is exactly what the HCL equivalents would produce.

# CLI: read the shape of a synthesized stack before trusting it
cdktf synth
jq 'keys' cdktf.out/stacks/prod-data/cdk.tf.json
jq '.["//"].metadata' cdktf.out/stacks/prod-data/cdk.tf.json
jq -r '.resource | to_entries[] | .key as $t | .value | keys[] | "\($t).\(.)"' \
  cdktf.out/stacks/prod-data/cdk.tf.json | sort
# Provider note: this last list is the exact set of addresses Terraform will manage.

Logical IDs Are the Contract Between Python and State

The keys in that resource object are logical IDs, and they are the single most load-bearing output of synthesis. Terraform tracks what it owns by address, so a logical ID that changes is, from Terraform's point of view, one resource destroyed and a different one created.

You do not choose logical IDs directly. CDKTF derives each one from the construct's path below its owning stack: a resource declared straight in a stack body keeps its construct id verbatim, while a resource nested inside an intermediate Construct gets its path segments joined with underscores and an eight-character hash of the full path appended. So S3Bucket(self, "reports") in a stack body becomes aws_s3_bucket.reports, but the same call inside Construct(self, "archive") becomes something like aws_s3_bucket.archive_reports_9C4F1B02. The hash exists so the same reusable construct can be instantiated twice in one stack without collision.

This is why an innocuous refactor — extracting a group of resources into a class, renaming a variable that happens to be a construct id, moving a resource one level up — can produce a plan full of replacements. The rules, and the override_logical_id and moved-block techniques for surviving a rename, are worked through in controlling CDKTF stack and construct naming. The stack id matters just as much: it names the directory under cdktf.out/stacks/ and usually feeds the state key, so splitting or renaming stacks is a state operation, not a cosmetic one — see splitting a CDKTF app into multiple stacks for how to draw those boundaries and move resources across them safely.

The defensive habit is cheap: assert the addresses you depend on in a test, so a refactor that moves one fails in CI rather than in a plan.

# tests/test_addresses.py — freeze the synthesis contract
# CLI: pytest tests/test_addresses.py -q
import json
from typing import Set

from cdktf import Testing
from main import ReportsStack


def addresses(stack_json: str) -> Set[str]:
    doc = json.loads(stack_json)
    return {
        f"{res_type}.{logical_id}"
        for res_type, block in doc.get("resource", {}).items()
        for logical_id in block
    }


def test_logical_ids_are_stable() -> None:
    app = Testing.app()
    stack = ReportsStack(app, "prod-data", region="eu-west-1")
    found = addresses(Testing.synth(stack))
    # State implication: if this fails, the next apply proposes destroy + create
    # for every address that moved, unless a moved block is added first.
    assert "aws_s3_bucket.reports" in found

Escape Hatches When the Typed API Falls Short

The generated bindings are a projection of the provider's JSON schema, and projections lose things: an argument added to the provider last week, a lifecycle option the codegen does not model, a nested block whose shape jsii cannot express. CDKTF therefore ships deliberate holes through which you can write raw configuration.

Escape hatches and what they cost Escape hatches and what they cost: comparison across Writes into, Type checked, Failure surfaces at. Hatch Writes into Type checked Failure surfaces at add_override resource JSON no terraform plan override_logical_id the address key no next plan diff TerraformHclModule module block no terraform plan TerraformVariable variable block yes cdktf synth
Every hatch moves the moment of failure later; only TerraformVariable keeps it inside synthesis.

add_override(path, value) writes a value directly into the emitted JSON for that construct, using a dotted path. It runs after the typed arguments are rendered, so it wins. add_override("lifecycle.ignore_changes", ["tags"]) on a resource, or add_override("terraform.backend.s3.role_arn", "...") on a stack, both do what you would hope. The escape is real: nothing type-checks the value you pass, and a typo produces a Terraform-level error at plan time rather than a Python one at synthesis time.

override_logical_id(new_id) replaces the derived logical ID with a literal string. Use it when an address must stay fixed across a refactor, and accept that you have taken over responsibility for uniqueness.

TerraformHclModule wraps an existing Terraform module by source and version, taking a plain dictionary of variables and exposing outputs through get_string("name"). It is the bridge to the existing module ecosystem, and it is untyped by nature — the module's variables are not known to Python, so a misspelled key surfaces as Error: Unsupported argument during plan.

TerraformAsset, TerraformVariable and TerraformLocal cover the remaining cases where a value must exist in the Terraform configuration rather than in Python: file uploads, inputs supplied at apply time by a pipeline, and expressions you want evaluated once and reused.

# overrides.py — three escapes, each with a different blast radius
# CLI: cdktf synth && jq '.resource.aws_s3_bucket' cdktf.out/stacks/prod-data/cdk.tf.json
from cdktf import TerraformHclModule
from cdktf_cdktf_provider_aws.s3_bucket import S3Bucket

bucket: S3Bucket = S3Bucket(self, "reports", bucket="acme-prod-reports")

# 1. A lifecycle rule the bindings do not model as a constructor argument.
bucket.add_override("lifecycle.ignore_changes", ["tags[\"LastScanned\"]"])
# State implication: Terraform stops proposing changes for that tag; the code no
# longer describes it, so record who owns the value.

# 2. A provider argument newer than the bindings you compiled against.
bucket.add_override("object_lock_enabled", True)
# Provider note: unchecked at synthesis; an unknown argument fails at plan with
# "Error: Unsupported argument".

# 3. An existing HCL module, wired in by source and version.
network = TerraformHclModule(
    self,
    "network",
    source="terraform-aws-modules/vpc/aws",
    version="5.8.1",
    variables={"name": "acme-prod", "cidr": "10.20.0.0/16"},
)
subnet_ids = network.get_list("private_subnets")   # untyped by construction

Treat every escape hatch as debt with a comment attached. Each one is a place where the Python no longer fully describes the infrastructure, and the next person to read the construct will not find the behaviour in the constructor arguments.

Step-by-Step: Tracing a Value from Python to Plan

The fastest way to internalise all of the above is to follow one attribute end to end.

1. Synthesize and locate the resource block

# CLI: find the emitted block for the construct you care about
cdktf synth
jq '.resource.aws_s3_bucket.reports' cdktf.out/stacks/prod-data/cdk.tf.json

The block should contain the literal arguments you passed and, where you used another resource's attribute, an interpolation string. If you see a raw ${TfToken[TOKEN.n]} still in the file, resolution failed — almost always because the value was stored somewhere synthesis does not traverse, such as a Python attribute you set on self but never passed to a construct.

2. Confirm the reference, not the value

# CLI: prove the dependency edge exists in the configuration
jq -r '.resource.aws_iam_policy["reports-read"].policy' \
  cdktf.out/stacks/prod-data/cdk.tf.json

You want to see ${aws_s3_bucket.reports.arn} embedded in the policy document string. That interpolation is the dependency edge; Terraform will not create the policy before the bucket.

3. Initialise and plan against the synthesized directory

# CLI: run Terraform directly so the errors are unmediated by the CDKTF CLI
terraform -chdir=cdktf.out/stacks/prod-data init
terraform -chdir=cdktf.out/stacks/prod-data plan -out=tfplan -detailed-exitcode
# exit 0 = no changes, 2 = changes pending, 1 = error

4. Read the plan as data

# CLI: turn the plan into something a script can assert on
terraform -chdir=cdktf.out/stacks/prod-data show -json tfplan \
  | jq -r '.resource_changes[]
           | select(.change.actions != ["no-op"])
           | "\(.address)\t\(.change.actions | join(","))"'

Any delete,create pair against a resource you did not intend to replace is a signal to stop and check the logical ID, not to approve the plan.

Verification

Synthesis is a pure function of your source, which makes it unusually easy to test. Three checks, in increasing cost, cover most of what goes wrong.

Synthesis succeeds and is deterministic. Run cdktf synth twice into different output directories and diff them. A difference means something in your program depends on wall-clock time, a random value, or an environment variable — all of which make code review meaningless because the reviewed source no longer determines the applied configuration.

# CLI: prove synthesis is reproducible before trusting any snapshot test
cdktf synth --output out-a >/dev/null
cdktf synth --output out-b >/dev/null
diff -r out-a/stacks out-b/stacks && echo "synthesis is deterministic"

The emitted JSON contains what you asserted. Testing.synth(stack) returns the document as a string without writing anything to disk, locking state, or invoking Terraform, so these tests run in milliseconds inside an ordinary pytest suite.

# tests/test_synth.py — assert on the emitted configuration, not on the Python objects
# CLI: pytest tests/test_synth.py -q
import json

from cdktf import Testing
from main import ReportsStack


def test_provider_and_bucket_are_emitted() -> None:
    app = Testing.app()
    doc = json.loads(Testing.synth(ReportsStack(app, "prod-data", region="eu-west-1")))

    assert "aws" in doc["provider"], "AWS provider block missing from cdk.tf.json"
    bucket = doc["resource"]["aws_s3_bucket"]["reports"]
    assert bucket["bucket"] == "acme-prod-reports"
    # Provider note: no Terraform binary runs here; this checks synthesis only.

Terraform agrees the configuration is well formed. terraform validate catches everything synthesis cannot: unknown arguments introduced by an override, provider constraints that cannot be satisfied, references to resources that do not exist. Run it in CI immediately after synth, before any plan that needs credentials.

# CLI: the cheapest gate that catches override typos and provider mismatches
cdktf synth
terraform -chdir=cdktf.out/stacks/prod-data init -backend=false
terraform -chdir=cdktf.out/stacks/prod-data validate -json | jq '.error_count, .diagnostics'

Troubleshooting

jsii.errors.JSIIError: There is already a Construct with name 'reports' in ReportsStack [prod-data]. Two constructs share a scope and an id. Usually a loop that passes a constant id instead of interpolating the iteration variable. Ids only need to be unique within their immediate scope, so f"reports-{name}" fixes it.

Validation failed with the following errors: followed by construct paths. Something's validate hook or an aspect flagged an error. The path in brackets is the construct path, not the Terraform address — walk it segment by segment through your Python to find the offending node.

A plan proposes to replace resources you did not touch. Compare the logical IDs in the current cdk.tf.json against the addresses in state (terraform state list). If they differ, a refactor moved a construct. Fix it with a moved block or override_logical_id, never by letting the replacement proceed.

Error: Provider configuration not present at plan time. The stack emits resources but no provider block, typically because the provider was instantiated on a different stack or inside a construct whose scope resolved elsewhere. Check jq '.provider' cdk.tf.json.

Error: Unsupported argument naming an argument you never wrote in Python. It came from an add_override, or from a TerraformHclModule variable key the module does not declare. Overrides are unchecked by design, so grep for them first.

A token appears literally in the plan output. If ${TfToken[TOKEN.9]} reaches Terraform, the value escaped the structure synthesis traverses — commonly by being written into a file, an environment variable, or a TerraformAsset path during construction. Move the reference into a construct argument so resolution can find it.

Synthesis is slow or hangs. Provider bindings are large and jsii loads them eagerly. Import only the modules you use (from cdktf_cdktf_provider_aws.s3_bucket import S3Bucket, not a package-level star import), and check that no construct body performs network I/O — a boto3 call inside a constructor runs on every synth, including in CI.

A stack ambiguity error from the CLI. With multiple stacks, cdktf deploy refuses to guess and lists the available stack ids. Pass the id explicitly, or use cdktf list to see exactly what the app produced.

FAQ

Why does printing a resource attribute show a placeholder instead of a value?

Because the value does not exist yet. CDKTF returns a token — a marker string standing in for a Terraform interpolation — and swaps it for ${aws_s3_bucket.reports.arn} during synthesis. Use Token.is_unresolved(value) if a helper needs to distinguish a literal from a deferred value, and never branch on a token's contents.

Do I need Node.js installed if my whole project is Python?

Yes. The CDKTF library itself is TypeScript, and the Python package is a jsii proxy that runs it in a Node child process. Without Node on PATH, importing cdktf fails before any of your code executes. This also means CI images need a Node runtime alongside Python.

Can I run terraform directly instead of cdktf deploy?

Yes, and it is often the better choice in a pipeline. After cdktf synth, cdktf.out/stacks/<id>/ is an ordinary Terraform working directory: terraform -chdir=... init|plan|apply behaves exactly as it would for hand-written HCL, and the error messages are unfiltered.

What is the difference between add_override and override_logical_id?

add_override injects arbitrary configuration into a resource's emitted JSON at a dotted path, bypassing the typed constructor. override_logical_id changes only the key that resource is filed under, which is its Terraform address. The first affects what the resource is; the second affects what state calls it.

Is add_dependency() ever necessary if tokens create edges automatically?

Occasionally. Passing an attribute from one resource to another already produces an interpolation and therefore an edge. add_dependency() is for ordering that the configuration cannot express — an IAM policy that must be attached before a service can assume a role, or a null-resource bootstrap step that must follow a database being ready.

How do I stop a refactor from renaming resource addresses?

Assert the addresses in a pytest test that fails when they change, then decide deliberately: either keep the name with override_logical_id, or accept the new name and emit a moved block so Terraform relocates the state entry instead of destroying the resource.