Terraform modules should expose decisions their callers own
Build and test a local network-calculation module whose inputs, validation and outputs separate caller choices from fixed subnet policy.

A reusable module does not need a variable for every implementation detail. It needs an interface that lets its caller make the decisions the caller owns. A network module might accept an allocated address range and named subnet slots while keeping one subnet-size policy fixed. That narrower contract can be easier to review than a collection of flags that silently changes several responsibilities at once.
This article is for a developer who can read Terraform variables and resource blocks. The worked example was checked with Terraform 1.16.1 on Linux. It calculates addresses and stores one result in a local terraform_data resource. It creates no cloud network, uses no external provider, and makes no claim about routing, firewall rules or available addresses in an existing estate.
State the division of responsibility
The caller chooses a canonical IPv4 /16 network and a map from semantic names to subnet numbers. The module divides that network into /24 blocks. Each number must be a distinct integer between zero and 255. The module returns the corresponding map of CIDR strings. The root module chooses which named result its application needs.
These are separate decisions. The child module does not know whether every caller needs a subnet called web. Our application does. The module therefore validates numeric allocation rules, while the application’s lookup establishes its own required name. Moving that name requirement into a general module would make every other caller inherit an application-specific assumption.
HashiCorp’s module composition guide recommends expressing dependencies through module interfaces and keeping composition relatively flat. Its dependency-inversion examples are useful here: the root connects the parts, rather than making a child discover how every other part was created.
The output is a public interface, not an authorization boundary. A caller’s access to configuration and state is governed separately. Hiding an internal name behind an output does not encrypt it or restrict someone who can already read the underlying files.
Write the smallest useful module
Run the blocks in the same Bash session. The first creates a new temporary directory. The child has no resources; its work is validation and calculation. The root’s built-in resource makes the chosen result observable in local state.
tf_module_lab=$(mktemp -d)
cd "$tf_module_lab"
mkdir -p modules/network_contract
cat > modules/network_contract/main.tf <<'HCL'
variable "network_cidr" {
type = string
nullable = false
validation {
condition = try(
cidrnetmask(var.network_cidr) == "255.255.0.0" &&
var.network_cidr == "${cidrhost(var.network_cidr, 0)}/16",
false
)
error_message = "Use a canonical IPv4 /16 network."
}
}
variable "subnet_numbers" {
type = map(number)
nullable = false
validation {
condition = try(
length(var.subnet_numbers) > 0 &&
alltrue([for n in values(var.subnet_numbers) :
floor(n) == n && n >= 0 && n < 256]) &&
length(distinct(values(var.subnet_numbers))) == length(var.subnet_numbers),
false
)
error_message = "Use nonempty, distinct integer subnet slots from 0 to 255."
}
}
locals {
subnet_cidrs = {
for name, slot in var.subnet_numbers :
name => cidrsubnet(var.network_cidr, 8, slot)
}
}
output "subnet_cidrs" {
value = local.subnet_cidrs
}
HCL
cat > main.tf <<'HCL'
terraform {
required_version = ">= 1.4.0, < 2.0.0"
}
variable "network_cidr" {
type = string
default = "10.42.0.0/16"
}
variable "subnet_numbers" {
type = map(number)
default = { web = 1, data = 2 }
}
module "network" {
source = "./modules/network_contract"
network_cidr = var.network_cidr
subnet_numbers = var.subnet_numbers
}
resource "terraform_data" "application_binding" {
input = module.network.subnet_cidrs["web"]
}
output "subnet_cidrs" {
value = module.network.subnet_cidrs
}
HCL
terraform init -input=false -no-color
terraform plan -input=false -no-color -out=initial.tfplan
terraform apply -input=false -no-color initial.tfplan
terraform show -json > before.json
terraform output -json subnet_cidrs > initial-subnets.json
The input-variable documentation describes how the parent supplies a child’s inputs. Here the child has no defaults: its caller must provide both decisions. The root supplies convenient demonstration defaults. Those two levels should not be confused when documenting which values an external caller must set.
The first condition uses cidrnetmask to require an IPv4 /16 and cidrhost to compare the original string with its network address. Thus 10.42.1.0/16 is rejected rather than silently normalized. Invalid function arguments become a false condition through try, yielding the module’s diagnostic. This policy also rejects alternative noncanonical spellings.
The second condition checks more than the map’s type. map(number) alone permits fractional values and duplicate numbers under different keys. The variable reference also distinguishes a non-null collection from non-null elements. nullable = false excludes a null map; the validation expression additionally rejects null slot values. It does not claim that every rejected input has the same diagnostic: type errors can occur before custom validation.
Derive the result instead of trusting a plausible address
The cidrsubnet reference defines the second argument as the number of extra prefix bits. Adding eight bits to /16 gives /24; eight bits provide 256 possible slot numbers. Slot one is therefore 10.42.1.0/24, not the first host address in the parent network.
Verify the output with Python’s separate address implementation and inspect the stored binding. This checks the actual result, rather than merely confirming that Terraform completed without errors.
python3 -I - <<'PY'
from pathlib import Path
import ipaddress, json
actual = json.loads(Path('initial-subnets.json').read_text())
subnets = list(ipaddress.ip_network('10.42.0.0/16').subnets(new_prefix=24))
expected = {name: str(subnets[slot]) for name, slot in {'web': 1, 'data': 2}.items()}
assert actual == expected
state = json.loads(Path('before.json').read_text())
resource, = state['values']['root_module']['resources']
assert resource['address'] == 'terraform_data.application_binding'
assert resource['values']['input'] == actual['web']
print(actual)
PY
An output value makes a child’s selected value available to its parent. The root refers to module.network.subnet_cidrs, not to the child’s local variable. The root output additionally makes this result available through the CLI. Removing that root output would not remove the child output or the application’s dependency on it.
Add a caller choice without changing an existing binding
Add an app slot while preserving the existing assignments. The plan changes the root output map, but the resource still receives the same web CIDR. Its action should be no-op, and its generated identity should survive.
python3 -I - <<'PY'
from pathlib import Path
import json
Path('choices.auto.tfvars.json').write_text(json.dumps({
'subnet_numbers': {'web': 1, 'data': 2, 'app': 3}
}))
PY
terraform plan -input=false -no-color -out=expanded.tfplan
terraform show -json expanded.tfplan > expanded.json
python3 -I - <<'PY'
from pathlib import Path
import json
plan = json.loads(Path('expanded.json').read_text())
change, = plan['resource_changes']
assert change['address'] == 'terraform_data.application_binding'
assert change['change']['actions'] == ['no-op']
assert plan['output_changes']['subnet_cidrs']['after']['app'] == '10.42.3.0/24'
PY
terraform apply -input=false -no-color expanded.tfplan
terraform show -json > after.json
python3 -I - <<'PY'
from pathlib import Path
import json
def binding(path):
state = json.loads(Path(path).read_text())
resource, = state['values']['root_module']['resources']
return resource['values']
assert binding('before.json') == binding('after.json')
print('app added; existing web binding and ID preserved')
PY
This is a property of the explicit assignments, not of adding arbitrary list entries. An interface that derives numbers from list positions could renumber later entries when a new name is inserted. A named map makes those choices reviewable, but callers can still change a number deliberately. The module cannot promise that every edit preserves every downstream resource.
Test the boundary that owns each failure
The following cases deliberately fail planning. They preserve and restore the successful input file, and compare raw state bytes after every failure. No rejected plan is applied.
python3 -I - <<'PY'
from pathlib import Path
import json, subprocess
path = Path('choices.auto.tfvars.json')
saved, state = path.read_bytes(), Path('terraform.tfstate').read_bytes()
cases = [
('duplicate', {'subnet_numbers': {'web': 1, 'data': 1}}, 'distinct integer'),
('fraction', {'subnet_numbers': {'web': 1.5}}, 'distinct integer'),
('outside', {'subnet_numbers': {'web': 256}}, 'distinct integer'),
('host-bits', {'network_cidr': '10.42.1.0/16'}, 'canonical IPv4 /16'),
('missing-web', {'subnet_numbers': {'data': 2}}, 'Invalid index'),
]
try:
for name, values, diagnostic in cases:
path.write_text(json.dumps(values))
result = subprocess.run(
['terraform', 'plan', '-input=false', '-no-color'],
capture_output=True, text=True, timeout=30)
assert result.returncode == 1, (name, result.stdout, result.stderr)
assert diagnostic in result.stdout + result.stderr, name
assert Path('terraform.tfstate').read_bytes() == state
print(name, 'rejected; state unchanged')
finally:
path.write_bytes(saved)
PY
terraform plan -input=false -no-color -detailed-exitcode
The final plan is empty and exits zero. Duplicate, fractional and oversized slots violate the child’s allocation policy. Host bits violate its canonical-network policy. Missing web is different: the child accepts a valid map containing data, but the root application’s lookup fails. Good diagnostics point to this distinction instead of blaming every invalid composition on the child.
For an exercise, try an empty map, a negative slot and a null slot value. Then try slots zero and 255. The first group must fail; the endpoints are valid under this module’s policy. Verify their CIDRs independently. None of those checks establishes that a cloud platform will allocate every address in the resulting blocks to hosts.
Keep the guarantees narrower than the interface
The module does not check whether another team already owns the parent range. It does not require private address space, discover existing subnets, reserve cloud-specific addresses or configure isolation. Those requirements need their own inputs, trusted inventory or provider-specific checks. Calling a returned value a “private subnet” would promise behavior the calculation never establishes.
Fixing /24 is a tradeoff: a smaller interface and consistent policy at the cost of flexibility. Expose a subnet-size choice when callers actually need to own that decision, then revise validation and examples together. Quietly changing the fixed size in a later release can alter every output even if the variable names remain unchanged.
Likewise, adding both create_network and use_existing_network flags can force a module to coordinate contradictory states. A root that selects or creates a network and passes the required values into a separate consumer often has a clearer responsibility boundary. This is a design choice, not a rule that all modules must be resource-free like this teaching example.
Finish by reviewing and applying the local cleanup plan. Only the root’s built-in resource has a managed lifecycle here.
terraform plan -destroy -input=false -no-color -out=cleanup.tfplan
terraform apply -input=false -no-color cleanup.tfplan
terraform state list
The empty list completes the fixture. Its useful result is an interface whose accepted inputs, output shape, failure ownership and limits can be explained and tested independently of a particular cloud deployment.