Composing components#
Goal. Break a large scenario down into reusable components. Think of components like functions: small, well-named, tested in isolation, reused across scenarios.
When to extract a component#
Extract a component when you notice one of these signals:
- Repetition. The same 5–10 lines of HCL appear in three scenarios. Make them a component.
- Named concepts. When you find yourself wanting to say “a merchant” instead of “three SQL inserts and two HTTP calls”, you’ve found the component boundary.
- Scope variation. A small concept today might need to expand. A component gives you a single place to change behavior for all callers.
Don’t extract:
- One-off work. If the block only appears once, leave it inline.
- Before you know the shape. Premature components are harder to fix than duplicated HCL — write it out twice, then extract on the third.
Anatomy of a good component#
A component has a focused purpose, a short input surface, and a minimal output surface.
# components/merchant.hcl
component "merchant" {
variable "name" {
type = string
}
variable "region" {
type = string
default = "us"
}
variable "verified" {
type = bool
default = false
}
action "postgres_query" "insert" {
query = "INSERT INTO merchants (name, region, verified) VALUES ('${var.name}', '${var.region}', ${var.verified}) RETURNING id, name, region, verified"
}
output "id" { value = action.postgres_query.insert.id }
output "name" { value = action.postgres_query.insert.name }
output "region" { value = action.postgres_query.insert.region }
output "verified" { value = action.postgres_query.insert.verified }
}Notes:
- Typed variables with defaults where sensible. Required variables — here,
name— have no default. - Outputs expose useful IDs and derived fields. The caller shouldn’t have to
know how
idis generated. - One job. This component creates a merchant. It doesn’t create products, users, or payments — those are separate components that compose with this one.
Composing components that depend on each other#
scenario "merchant_with_products" {
required_providers {
postgres = { source = "builtin/postgres" }
}
variable "dsn" {}
provider "postgres" {
dsn = var.dsn
}
component "merchant" "acme" {
source = "../components/merchant.hcl"
inputs = { name = "Acme Corp" }
}
component "product" "widget" {
source = "../components/product.hcl"
inputs = {
merchant_id = component.merchant.acme.id
name = "Widget"
price = 9.99
}
}
component "product" "gadget" {
source = "../components/product.hcl"
inputs = {
merchant_id = component.merchant.acme.id
name = "Gadget"
price = 19.99
}
}
}The product component is instantiated twice with different names. Orchard
wires each instance’s merchant_id input from the same merchant’s output,
creating a diamond in the dependency graph — both products depend on the
merchant but not on each other.
Pattern: components that call other components#
Components can’t instantiate other components directly today — only scenarios can. If you want a “merchant with products” macro, the composition lives at the scenario level, or in a meta-scenario that’s called from other scenarios.
Naming conventions#
- Component file name = component name.
components/merchant.hclcontainscomponent "merchant" { }. - Instance name describes the role.
component "merchant" "acme"is clearer thancomponent "merchant" "one"when the scenario has multiple instances. - Lowercase, snake_case.
monthly_revenue,created_at,api_token— match the column names of the systems you’re driving.
Keeping components healthy#
- Keep the input surface narrow. If a component has 15 inputs, it’s probably doing too much. Split it.
- Prefer outputs over implicit state. If callers care about a field, expose it. Don’t make them query the database to find what you just inserted.
- Test components in isolation. A small scenario that instantiates just one component makes debugging much easier than chasing through a five-level composition.