Skip to content

How TerraDart works

TerraDart is an authoring layer over Terraform. You describe infrastructure as a Dart Stack; TerraDart turns it into standard Terraform JSON, and OpenTofu or Terraform plan and apply it exactly as they would hand-written configuration. Dart gives you types, refactoring and dart analyze; state, planning and apply stay with the engine.

The same synth also writes a Dart file your app imports, so the values that cross from infrastructure to app — a topic name, a service URL — are typed and never copied by hand.

graph LR
  subgraph pkg["Your Dart package"]
    ST["Stack<br/>lib/app_stack.dart"]
    EP["bin/infra.dart<br/>runStack / runEnvironments"]
  end
  ST --> EP
  EP -->|"terradart synth"| TF["tf-out/main.tf.json"]
  EP -->|"terradart synth"| AD["lib/generated/app_stack.app.dart<br/>Constants · Outputs reader"]
  TF -->|"terradart plan / apply"| EN["OpenTofu or Terraform"]
  EN <--> STATE[("State<br/>local or remote backend")]
  EN -->|"creates"| CLOUD["Cloud resources"]
  STATE -->|"terradart apply / outputs"| DF[".terradart/dart_defines.json"]
  AD --> APP["Your app"]
  DF -->|"--dart-define-from-file"| APP
  CLOUD -->|"outputEnvironment()"| APP
  1. Author. A final class AppStack extends Stack adds resources built with the factories of the provider packages — terradart_google, terradart_aws, terradart_cloudflare, terradart_appwrite — and declares the values the app reads back with addConstant and addOutput.
  2. Synth. terradart synth runs bin/infra.dart, whose runStack (or runEnvironments, for several environments) builds the Stack and writes tf-out/main.tf.json plus the generated app file. dart run bin/infra.dart does the same without the command.
  3. Plan and apply. terradart plan and terradart apply synthesize, then run init and plan or apply in tf-out/ with a checksum-verified OpenTofu the command downloads, or the tofu or terraform already on your PATH — you never install Terraform yourself. State lives in the backend the Stack declares.
  4. Hand off. Constants are known at synth and compiled into the app. Outputs exist after apply and reach the app through the generated reader: a deployed service from its environment, a Flutter or web client from the define file terradart apply writes, a script from the same file.

The terradart command runs steps 2 to 4 in one go.

Synth is the in-memory step: the Stack is walked and assembled into the Terraform JSON tree. In code, stack.synth() returns a SynthResult, and stack.writeTo(outDir) writes it. runStack calls writeTo('tf-out') and tells the terradart command what it wrote. The landing page says you generate *.tf.json; this is the same step.

graph LR
  S[Stack instance] --> SY["synth()"]
  SY --> SR[SynthResult]
  SR --> W["writeTo(outDir)"]
  W --> TF[main.tf.json]
  W -.optional.-> AD[".app.dart constants and outputs reader"]

synth() is pure. It walks the resources you registered with add(...), applies lifecycle wiring, dedups, and produces a SynthResult whose tfJson field carries the Terraform JSON tree.

writeTo(outDir) is the file-I/O wrapper. It always writes main.tf.json. When the Stack was constructed with appExports: AppExports('lib/generated/<stack>.app.dart'), it also writes that generated Dart file, rewritten in full on every synth. Synth runs before any write, so a Stack that cannot synthesize throws with nothing written — the failure mode is atomic, never partial.

Before it encodes anything, synth checks the whole Stack and collects every problem it finds into one SynthException, so a broken Stack reports all of its issues in one run instead of one per fix. Each problem is a subtype of the sealed SynthIssue, carrying the address of the block that holds it and a message that says how to fix it:

IssueWhen
NoProviders / MissingProvider / ProviderConflictno provider is registered, a block needs one that is not, or two registrations clash (a repeated alias, different version pins for one name)
UndeclaredVariablea .variable('x') or var.x names a variable neither variable nor externalVariable declared
UnregisteredReferencea block reads (or depends_on) a resource, data source or module that was built but never passed to add / addModule
SensitiveLiterala sensitive field is set to a literal, which would write the secret into main.tf.json
InvalidTimeouta TfTimeouts value is not a Go duration string
InvalidMoveTargetan addMoved target names no resource of the Stack
UnresolvableConstantan addConstant(...) attribute has no literal value at synth

stack.validate() returns the same list without throwing — the shape a test or a pre-commit check wants. The sealed hierarchy lets a switch handle the issues it cares about:

lib/explain_stack.dart
import 'package:terradart_core/terradart_core.dart';
/// Prints why [stack] cannot synthesize, or nothing when it can.
void explain(Stack stack) {
for (final issue in stack.validate()) {
switch (issue) {
case UnregisteredReference(:final address, :final target):
print('$address reads $target, which was never added');
case SensitiveLiteral(:final address, :final field):
print('$address would write the secret $field into main.tf.json');
default:
print(issue);
}
}
}

A block declared in a hand-written .tf file beside main.tf.json is the one legitimate unregistered reference: declare it with addExternalBlock('google_pubsub_topic.legacy'), the counterpart of externalVariable for variables (terradart migrate does this for the blocks it leaves in the sidecar). Names are checked earlier, where they are registered: add, addModule, variable and addOutput throw ArgumentError for a name that is not a Terraform identifier.

Stack, Resource, and Data are abstract base class. Your subclasses must declare a class modifier:

final class AppInfraStack extends Stack {
AppInfraStack() : super(providers: [GoogleProvider(project: 'my-project')]);
}

base and sealed are also valid; what is rejected is plain class (or implements Stack, which would bypass the base-class state that synth depends on).

TerraDart never reads or writes state itself. The Stack declares where state lives — LocalBackend, GcsBackend, S3Backend, or a partial backend completed per environment — and the engine keeps it there. The terradart command picks the engine: the one named in pubspec.yaml or on the command line, the one that last applied this state, tofu or terraform on PATH, or a pinned OpenTofu release it downloads and checks against its SHA-256 (The engine).

Because the output is standard Terraform JSON, everything around the engine stays as it is: remote state, -target, moved blocks (addMoved), existing modules called with addModule, and HCL files beside main.tf.json that feed the same apply. With environments, each one gets its own Terraform directory and state, selected with --env.

A Stack hands values to the Dart code that uses its resources — a Cloud Run service, a Lambda function, a Flutter client — in two ways, each with its own method:

graph LR
  AO["addOutput(name, value)"] --> TFO["Terraform output block (main.tf.json)"]
  AO --> RD[".app.dart Outputs reader"]
  AC["addConstant(name, constant)"] --> DC[".app.dart Constants class"]
  ST[Application code] --> DC
  ST --> RD
  TF[OpenTofu or Terraform] --> TFO
  TFO -.environment / define file.-> RD
  • addOutput declares a Terraform output for a value known after apply — a resource ID, a Cloud Run URL — and a getter of the generated reader (see below). It takes any TfArg, so the value is a dot shorthand like every resource argument: addOutput('service_url', service.uri, description: 'Cloud Run URL'). It checks at registration that the name is a Terraform identifier, is not taken, and — unless sensitive: true — that the value does not read a sensitive field.

  • addConstant declares a static const on the generated <StackName>Constants class, for a value known at synth. The value is a sealed AppConstant<T>, picked with a dot shorthand:

    • topic.name — the literal an attribute of the Stack is set to (name: .literal('orders-prod')), so the string is written once. Every input has such a getter, named <input>Ref (scope.scopeId for scope_id), and other resources can take it too (scopeId: scope.scopeId). Synth reports an UnresolvableConstant issue that says what the attribute is set by when it is not a literal (a reference, a variable, an expression, or left for the provider to compute) or is a sensitive field.
    • .value(5) — a value that belongs to no resource, such as an API version.
    • .fromEnvironment('API_BASE_URL') — a String.fromEnvironment read at the app’s compile time.

    T can be String, int, double, num, bool, Object, or a List / String-keyed Map of them, each optionally nullable; addConstant rejects any other type, a name that is not a public Dart identifier, and a duplicate.

The constants file is configured once, on the constructor, and addConstant throws when it is missing:

lib/orders_stack.dart
import 'package:terradart_google/provider.dart';
import 'package:terradart_google/pubsub.dart';
final class OrdersStack extends Stack {
OrdersStack({required String projectId})
: super(
providers: [GoogleProvider(project: projectId)],
appExports: AppExports('lib/generated/orders_stack.app.dart'),
) {
final topic = add(GooglePubsubTopic('orders', name: .literal('orders-prod')));
addConstant('ordersTopicName', .ref(topic.name));
addOutput('orders_topic_id', topic.id);
addDartDefineOutput();
}
}

terradart synth runs bin/infra.dart, which writes tf-out/main.tf.json and the generated file:

bin/infra.dart
import 'package:my_app/orders_stack.dart';
import 'package:terradart_core/terradart_core.dart';
Future<void> main(List<String> args) =>
runStack(args, () => OrdersStack(projectId: 'my-project'));

The same file holds <StackName>Outputs, a typed reader of the Stack’s outputs: one getter per non-sensitive output, named in lowerCamelCase and typed like the output’s value (addOutput('orders_topic_id', topic.id) becomes String get ordersTopicId). Its const constructors pick the source — the environment (fromEnvironment), or the defines compiled into a client (fromDartDefine(), see Outputs in client apps):

lib/read_outputs.dart
import 'dart:convert';
import 'dart:io';
import 'generated/orders_stack.app.dart';
/// A script or test that runs after `terradart apply` or `terradart outputs`,
/// reading the define file they write.
String appliedTopicId() {
final defines = File('.terradart/dart_defines.json').readAsStringSync();
final values = jsonDecode(defines) as Map<String, Object?>;
return OrdersStackOutputs.fromEnvironment(
values.cast<String, String>(),
).ordersTopicId;
}
/// A deployed service, whose environment carries ORDERS_TOPIC_ID.
String deployedTopicId() =>
OrdersStackOutputs.fromEnvironment(Platform.environment).ordersTopicId;

fromEnvironment and fromDartDefine read each output from the variable named after it in SCREAMING_SNAKE_CASE (ORDERS_TOPIC_ID); a String output is the variable’s value and any other type is JSON. Each getter reads when called, so a service whose environment sets only some of the variables can read those, and a missing or mistyped value throws a StateError naming the output and the variable. A sensitive output has no getter — read a secret from its secret store. With appExports set, addOutput rejects an output whose getter is not a usable Dart identifier, or whose getter or variable another output already has.

outputEnvironment() returns exactly those variables — each non-sensitive output registered so far, as a (name, value) record of the variable name and a TfArg<String>: a String output as is, any other as jsonencode(...) — so a Cloud Run service (or a function) receives the outputs it reads, with no variable name written twice:

final topic = add(GooglePubsubTopic('orders', name: .literal('orders-prod')));
addOutput('orders_topic_id', topic.id);
final service = add(GoogleCloudRunV2Service(
'api',
name: .literal('orders-api'),
location: .literal('asia-northeast1'),
template: CloudRunV2ServiceTemplate(containers: [
.new(
image: .literal('us-docker.pkg.dev/cloudrun/container/hello'),
env: [
for (final (:name, :value) in outputEnvironment())
.new(name: .literal(name), source: .value(value)),
],
),
]),
));
addOutput('service_uri', service.uri); // after: a service cannot read itself

Where a provider takes the environment as one map — an AWS Lambda function, a Cloud Function — pass variables, the same environment as a TfArg<Map<String, String>>:

final table = add(AwsDynamodbTable(
'items',
name: .literal('items'),
hashKey: .literal('id'),
attribute: [.new(name: .literal('id'), type: .s)],
));
addOutput('table_name', table.name);
add(AwsLambdaFunction(
'api',
functionName: .literal('items-api'),
role: .literal('arn:aws:iam::123456789012:role/items-api'),
runtime: .providedAl2023,
handler: .literal('bootstrap'),
code: .filename(.literal('build/bootstrap.zip')),
environment: .new(variables: outputEnvironment().variables),
));

outputEnvironment(only: [...]) picks outputs by name. Register an output that reads the service itself after the call — a resource whose environment references its own attributes is a Terraform cycle.

A client app — Flutter, Dart web, a CLI — gets the same variables as a file instead. addDartDefineOutput() declares an output whose value is the JSON that --dart-define-from-file reads; terradart apply writes it to .terradart/dart_defines.json (.terradart/dart_defines.<env>.json with --env), and the client reads it with const OrdersStackOutputs.fromDartDefine(). Outputs in client apps walks through it.

AppExports(path, name: 'Orders') renames the generated classes (OrdersConstants, OrdersOutputs). The path is relative to the directory bin/infra.dart runs in, and the generated file imports only dart:convert, so it can live in a small package the app and the infra package both depend on, keeping the app free of provider packages — the Lunch Concierge recipe writes it into its shared/ package. Runnable pattern: pubsub quickstart (lib/subscriber_stub.dart).

A worked end-to-end example lives in the cookbook single-project-app recipe: the outputs db_instance and db_name passed to the Cloud Run service with outputEnvironment(), the Terraform output coffee_service_uri, and the constants serviceName and region read from the Cloud Run service’s own name and location.

Each provider package wraps one official Terraform provider, and its factories mirror that provider’s schema — one per resource and data source, with typed arguments, enums, nested blocks and references:

terradart_time is a small hand-written package beside them: TimeProvider / TimeSleep for HashiCorp time, usable from a Stack on any provider. One Stack can mix packages.

Each provider has a page under Providers — Google Cloud, AWS, Cloudflare, Appwrite — and How TerraDart is built covers how the factories are generated from the provider schemas and kept current.

  • Not a Terraform replacement — state and apply stay in OpenTofu or Terraform.
  • Not a multi-cloud abstraction layer — wrappers faithfully mirror provider schemas rather than imposing cross-cloud abstractions.
  • Not a constructs framework in the pre-1.0 cycle.
  • Not a module system — addModule(ModuleCall(...)) calls an existing Terraform module by its source, and HCL files beside the generated *.tf.json feed the same apply; TerraDart does not turn modules into Dart.

See README — Non-goals for the canonical list.