Terraform Core uses typed addresses, a DAG walker, and a plugin protocol. Here's the part worth studying as a Go developer.

How Terraform models your infrastructure as a Go graph


Terraform is one of the best codebases to read if you want to see Go used for a large, long-lived systems tool. The part I keep coming back to is Terraform Core’s combination of typed addresses, a dependency graph, and a plugin boundary for providers.

It is easy to describe Terraform as “a CLI that reads HCL and calls cloud APIs”, but the interesting bit is the machinery in the middle. Terraform first turns your configuration into typed addresses, then into graph nodes, and then walks that graph while coordinating with provider plugins.

The address system is more serious than it looks

Before Terraform can plan anything, it needs a precise way to talk about “this module”, “that resource instance”, or “that output inside that module instance”. Most of that machinery lives under internal/addrs/.

The split between Module and ModuleInstance is a good example of how careful the package is:

  • internal/addrs/module.go represents the static module call path.
  • internal/addrs/module_instance.go represents a concrete module instance path, including instance keys where relevant.
  • internal/addrs/parse_target.go parses target expressions into typed addresses rather than leaving them as strings.
  • internal/addrs/checkable.go and related files describe objects that can own condition checks.

That distinction matters. Terraform is not just storing labels. It is modeling where an object sits in the configuration tree and, separately, which concrete instance is being acted on at runtime.

I like this package because it uses types to stop ambiguity from leaking outward. Once an address has been parsed, later parts of Terraform do not need to keep reinterpreting "module.foo.aws_instance.web[0]" by hand. They get a value with structure.

The graph layer is explicit

Terraform’s execution model is graph-based all the way through planning and apply. The reusable DAG primitives live in internal/dag, and Terraform’s higher-level graph logic is built on top of that.

One small detail I appreciate: Terraform does not hide the graph concept behind vague orchestration language. The internal/dag package has an AcyclicGraph type, validation logic, cycle detection, and a walk API that says the quiet part out loud: it walks nodes in parallel when it can.

That makes the mental model much clearer:

  1. Parse configuration into typed objects.
  2. Build graph nodes and dependency edges.
  3. Validate the graph.
  4. Walk it, allowing independent work to proceed concurrently.

If you have ever written a build system, job scheduler, or deployment engine, that structure should feel familiar.

Here is a tiny DAG walker that captures the same basic idea:

package main

import (
	"fmt"
	"sync"
)

type Node struct {
	Name string
	Deps []string
}

func walkDAG(nodes map[string]Node) {
	done := make(map[string]chan struct{})
	for name := range nodes {
		done[name] = make(chan struct{})
	}

	var wg sync.WaitGroup
	for _, node := range nodes {
		wg.Add(1)
		go func(n Node) {
			defer wg.Done()
			for _, dep := range n.Deps {
				<-done[dep]
			}
			fmt.Printf("Processing: %s\n", n.Name)
			close(done[n.Name])
		}(node)
	}
	wg.Wait()
}

func main() {
	nodes := map[string]Node{
		"vpc":      {Name: "vpc", Deps: nil},
		"subnet":   {Name: "subnet", Deps: []string{"vpc"}},
		"instance": {Name: "instance", Deps: []string{"subnet"}},
		"sg":       {Name: "sg", Deps: []string{"vpc"}},
	}
	walkDAG(nodes)
}

Terraform’s real walker is obviously more involved than this. It has to deal with diagnostics, provider interactions, state changes, and cancellation. But the broad shape is still a dependency graph with parallelizable branches, which is exactly the kind of problem Go handles well.

Providers are a plugin boundary, not a package import

Terraform Core does not directly embed AWS, Azure, or GCP API logic. Providers are separate plugins, launched as separate processes, and Terraform talks to them through the Terraform plugin protocol.

That protocol is defined in protocol buffers and implemented over gRPC. HashiCorp’s own docs are explicit about this, and it is one of the more important architectural lines in the system. Core is responsible for graph construction, planning, state handling, and orchestration. Providers are responsible for the domain-specific work of talking to infrastructure systems.

In practice, many providers do end up making HTTP requests under the hood because cloud APIs are often HTTP APIs. But that is a provider concern, not something Terraform Core models directly. Some providers may wrap an official SDK, some may make raw HTTP calls, and some may talk to something that is not plain JSON over HTTP at all.

That separation is why Terraform can stay relatively small at the core while the provider ecosystem grows independently.

Parsing and targeting: how terraform plan -target works

When you run terraform plan -target=aws_instance.web, Terraform needs to parse that string into a structured address. internal/addrs/parse_target.go and internal/addrs/parse_target_action.go handle this.

The useful part here is not the parsing itself. It is what Terraform gets after parsing. A string like module.foo.aws_instance.bar[0] becomes a typed target made of a module path, a resource identity, and possibly an instance key. Once that happens, the rest of the engine can operate on values with shape instead of strings with conventions.

Reference parsing in internal/addrs/parse_ref.go converts HCL traversal expressions into typed addresses. This is how Terraform knows that var.name refers to an InputVariable and module.vpc.subnet_id refers to a module output.

Partial expansion and move tracking

Two more corners of internal/addrs are worth reading.

internal/addrs/partial_expanded.go deals with addresses whose full instance path is not known yet. That shows up when Terraform knows part of a module path but cannot fully expand the rest during planning. It is a good example of modeling uncertainty directly instead of smuggling it around in nil values or special-case strings.

The move endpoint files implement the moved block machinery. Terraform needs to understand whether a move refers to a module endpoint or a resource endpoint, and it gives that distinction a concrete type. Again, the pattern is consistent: if the domain has structure, Terraform tries to encode that structure in Go types.

What Go developers can take from this

Terraform is worth studying for a few reasons.

It models names as types. If your program has a hierarchy of modules, resources, instances, and references, stringly-typed code will hurt you. Terraform is a good reminder to spend the type budget.

It treats the dependency graph as a first-class runtime structure. That is cleaner than burying ordering logic in ad hoc conditionals.

It keeps the provider boundary hard. Core orchestrates. Providers implement domain behavior. The split is operationally annoying sometimes, but architecturally it is strong.

It encodes awkward edge cases directly. Partial expansion, move endpoints, and target parsing are all evidence of a codebase that has had to survive real-world complexity for a long time.

If you are building anything that plans work across dependent objects, Terraform’s internal/addrs and internal/dag packages are worth an afternoon.