Open-Source Wikis

/

Terraform

/

Background

/

Design decisions

hashicorp/terraform

Design decisions

The load-bearing choices that shape the codebase. Each of these is settled — changing them would be a multi-quarter project — and being aware of them saves time when reading the code.

A graph is the only execution model

Every operation Terraform performs runs through the same machinery: a graph builder constructs a DAG, a walker visits each vertex in topological order, and per-vertex Execute methods do the work. Plan, apply, refresh, destroy, validate, init, and terraform console all share this skeleton. Stacks add their own scheduler (promising) on top, but at the component level they fall back to the same DAG engine.

Why: Terraform's value proposition is dependency-aware infrastructure changes. Once that's baked in as the central concept, every new feature (for-each, count, ephemeral resources, actions) has to fit the same shape. Trying to add an "imperative" or "stream-based" execution mode would introduce a parallel set of bugs and break the user mental model.

The cost: dynamic behavior (count/for-each, modules expanded by for-each, deferred actions) requires the dynamic-expand mechanism (GraphNodeDynamicExpandable) and sub-graph machinery, which makes the engine harder to reason about than a static DAG. The recurring pain point is when an operation can't be planned because something is unknown — see pitfalls.

Providers are out-of-process gRPC plugins

Every resource, data source, action, ephemeral resource, and provider-defined function lives in a separate binary that talks to Terraform Core over gRPC. There is no in-process provider API anymore.

Why: The original Terraform shipped with AWS, Azure, GCP, and others compiled in. That model didn't scale to thousands of providers and hundreds of release cadences. Out-of-process plugins isolate provider crashes, simplify per-provider versioning, and let HashiCorp release Terraform Core independently of any provider.

The cost: gRPC overhead (small but non-zero on hot paths), the protocol becomes a public API surface that has to remain backward-compatible (this is why two protocol versions coexist; see migration context), and the lifecycle of a provider process — handshake, configuration, RPCs, shutdown — has to be managed carefully.

HCL2 + cty.Value for everything user-supplied

User configuration is parsed by HCL2 into structural Go types in internal/configs/, and all values flowing through evaluation are cty.Values with full type information, unknown-value support, and marks. The engine almost never deals with raw strings.

Why: Pre-0.12 Terraform interpolated strings. Adding type checking, for_each, complex expression types, and sensitivity tracking on top of that would have required encoding everything in stringly-typed conventions. The 0.12 rewrite swallowed the cost of a fundamental rewrite to get a real type system.

The cost: an extra abstraction layer. New contributors have to learn cty (cty.Value, cty.Type, IsNull vs IsKnown vs IsWhollyKnown, marks). A surprising number of subtle bugs come from mishandling unknown values or marks; see pitfalls.

State is plain JSON, plans are zip archives

The state file is a versioned JSON document with a stable schema (currently version 4). Plans are zip files containing a protobuf-encoded plan plus the prior state and configuration.

Why: human-inspectability for state was an explicit goal. Operators routinely look at terraform.tfstate to understand what's tracked. Plans don't need that property, but they do need to be self-contained so that terraform apply <planfile> works without re-reading config or state.

The cost: backward compatibility on the state format becomes a forever-promise. Every new field has to be optional or carry a migration. The protobuf-based plan is more flexible internally but adds the constraint that any plan-format change has to handle older plan files for the duration users keep them.

Backends are pluggable, but limited by maintenance

Backends decouple state storage from the rest of the engine — switching from local file to S3 requires no changes to the engine. Some backends are also "operations backends" that run the operation remotely (local, remote, cloud).

Why: state storage has to scale to thousands of teams with different infrastructure. A pluggable interface allowed early Terraform to support S3, GCS, Azure, Consul, etc., without bloating the binary with cloud-specific code.

The cost: maintenance debt. Most backends were contributed by community members who have since stopped maintaining them. The team has frozen the set of accepted state-storage backends and is moving toward provider-supplied state storage (see internal/backend/pluggable/). Existing third-party-maintained backends remain in the tree but are explicitly not actively maintained.

Diagnostics, not errors

Terraform Core almost never returns a bare error. The standard return type is tfdiags.Diagnostics, which can carry warnings and errors with full source context.

Why: most user-facing problems should produce contextual messages — pointing at the user's source code, with severity, summary, and detail. Plain errors lose this. tfdiags lets every layer add context as the message bubbles up.

The cost: a small ergonomic tax — wrapping errors, appending diagnostics, and remembering to check HasErrors() instead of err != nil. New contributors trip over this often.

Marks are how sensitivity propagates

Sensitive values aren't a separate type or a wrapper struct; they're cty.Values with the marks.Sensitive mark applied. Marks propagate through every cty operation — concatenating two strings produces a sensitive string if either input was sensitive.

Why: forcing every function to check sensitivity manually would have been brittle. Mark propagation is automatic and conservative; the engine only has to check at boundaries (state writes, output rendering, JSON serialization).

The cost: any code that needs to operate on a value (decoding, hashing, serializing) has to either tolerate marks or cty.UnmarkDeep then re-mark the result. Forgetting this is the most common mark-related bug. Ephemeral and deprecation marks share the same mechanism.

Operations and CLI are decoupled

The CLI layer (internal/command/) is an UI; the engine doesn't depend on it. The split is enforced by the backendrun.OperationsBackend interface — the CLI hands operations to backends, and only local, remote, and cloud actually execute them.

Why: HCP Terraform needs to run Terraform Core operations without going through the CLI; so does the rpcapi server (see rpcapi). Forcing every consumer to fake a CLI session would be silly.

The cost: parallel rendering paths — the engine emits hooks; both the CLI views and the rpcapi server's event streams subscribe. Adding a new piece of progress information means updating both consumers.

Two languages, two configurations

Stacks are configured in .tfstack.hcl files parsed by internal/stacks/stackconfig/, separately from .tf files parsed by internal/configs/. They share HCL but have different block schemas, different validation, and different evaluation semantics.

Why: stacks needed a different set of abstractions (components, providers-as-stack-objects, dynamic outputs) than modules. Layering all of that into the existing module language would have made both more confusing.

The cost: code duplication between the two parsers, two parallel sets of validation rules, two parallel sets of evaluation contexts. Some of this will get unified over time; some of it won't.

Backward compatibility is a hard constraint

Once a feature ships, the bar for breaking it is extraordinarily high. Configuration syntax that worked in 0.12 still works in 1.16. State files written in 0.12 can be read in 1.16. JSON output from -json flags is treated as a public API and won't be changed without a deprecation cycle.

Why: Terraform users typically have decades of accumulated config and state in production. A change that requires re-running terraform init is tolerable; a change that breaks terraform plan for existing configurations is not.

The cost: dead code stays in the tree (see internal/legacy/, terraform push), and certain APIs can never be cleaned up. The team accepts this — see the contribution guide's "areas of special concern" section.

Built by Factory AutoWiki from public repository content. It is a generated preview for codebase exploration, not source-maintained documentation.

Design decisions – Terraform wiki | Factory