Why a module needs a contract
A module is a black box to the root. The root knows one thing: "I pass these inputs, I get these outputs." What happens inside is irrelevant. This buys you a few things:
- You can rewrite the resources inside the module without editing the root.
- You can use one module from ten different roots.
- You can swap a provider resource (
aws_s3_bucket_v2instead ofaws_s3_bucket) during an upgrade without cascading edits on the outside.
The contract is the set of variable blocks and output blocks. Everything
else in the module is implementation detail.
Input variables
Each variable block is a parameter of the module. It has a type, a
description, an optional default, and optional validation:
# modules/s3-bucket/variables.tf
variable "name" {type = string
description = "S3 bucket name. Must be globally unique."
validation {condition = length(var.name) >= 3 && length(var.name) <= 63
error_message = "Bucket name must be 3-63 characters (AWS rule)."
}
validation { condition = can(regex("^[a-z0-9.-]+$", var.name))error_message = "Bucket name: lowercase, digits, dot, and hyphen only."
}
}
variable "versioning_enabled" {type = bool
description = "Enable versioning. Usually true for prod buckets."
default = false
}
variable "lifecycle_rules" { type = list(object({id = string
enabled = bool
transition = optional(object({days = number
target = string
}))
}))
description = "Lifecycle rules. See examples/."
default = []
}
No default means a required argument
If a variable has no default, the caller must pass a value. Otherwise
Terraform complains at plan. That is good: arguments that are required
should be required.
Type matters
With type = string, the user can only pass a string. Not 42 (a number),
not ["a", "b"] (a list). A type mistake is caught at plan, not at
apply. See hcl-types.
Validation guards the module boundary
Validation catches what the type cannot:
- "Length 3-63" means nothing to the
stringtype. - "Lowercase only" means nothing to the
stringtype. - "days > 0" means nothing to the
numbertype.
Validation runs at plan, before any API calls. It is a cheap way to reject
invalid configurations. Without it, the error surfaces over in AWS as
"InvalidBucketName", and the person using the module does not immediately see
what went wrong.
Output values
Outputs are what the module returns to its parent:
# modules/s3-bucket/outputs.tf
output "arn" {value = aws_s3_bucket.this.arn
description = "Bucket ARN. Used for IAM policies."
}
output "bucket" {value = aws_s3_bucket.this.bucket
description = "Bucket name (as passed in the input)."
}
output "regional_domain_name" {value = aws_s3_bucket.this.bucket_regional_domain_name
description = "DNS name for direct S3 requests."
}
In the root module you read them as module.<name>.<output_name>:
output "logs_arn" {value = module.logs.arn
}
resource "aws_iam_policy" "logs_writer" { policy = jsonencode({ Statement = [{Effect = "Allow"
Action = "s3:PutObject"
Resource = "${module.logs.arn}/*"}]
})
}
sensitive output
If a secret ends up in an output, mark it:
output "access_key_secret" {value = aws_iam_access_key.user.secret
sensitive = true
}
This does not encrypt the value. The state still holds the secret in the
clear (which is bad, see tf-state). All sensitive = true does is hide
the value from the CLI output of terraform output and plan. Real secret
protection comes from a secret manager (Vault, SSM Parameter Store), not from
sensitive = true.
An output does not get depends_on implicitly
If an output depends on a resource that exists only under certain conditions,
add depends_on explicitly:
output "lifecycle_rule_id" {value = length(aws_s3_bucket_lifecycle_configuration.this) > 0 ? aws_s3_bucket_lifecycle_configuration.this[0].id : null
depends_on = [aws_s3_bucket_lifecycle_configuration.this]
}
How to design the contract
Parameterize what changes
Do not add a variable "just in case." A variable should reflect a real difference between the use cases of the module.
# BAD: just in case
variable "force_destroy" {type = bool
default = false
}
variable "object_ownership" {type = string
default = "BucketOwnerEnforced"
}
# plus 15 more variables that nobody ever changes
# GOOD: only what actually differs between use cases
variable "name" { type = string }variable "tags" { type = map(string), default = {} }variable "lifecycle_rules" { type = list(object({...})), default = [] }Keep non-parameterized values in locals inside the module.
Names are the contract
Rename variable "name" to variable "bucket_name" and you have a
breaking change for every root that uses the module. The same rules apply
as with an API: you cannot break names, or it becomes a new major version.
See tf-module-versioning.
Group complex parameters into an object
Ten flat variable "rule_X_..." declarations are worse than one
variable "rules" of type list(object(...)). With an object type the user
sees the whole schema at once and does not guess which variables go together.
Pitfalls
-
Validation runs on every plan. If a validation contains a heavy regex or a large
contains([...100 elements...], var.x), that costs time. Usually it does not matter, but in a module that is called 50 times throughfor_eachit can become noticeable. -
You need
description. Withoutdescription,terraform-docswill not generate a decent README, and the person using the module ends up reading the HCL itself to figure out whatbucket_aclmeans. It is also free documentation that the IDE shows on hover. -
default = nullis not the same as no default.default = nullmeans "the default value is null, and that is valid." If you want the argument to be required, drop the default entirely rather than setting it to null. -
sensitive = trueon an input does not encrypt, it only masks. The same caveats apply as for an output: the value still lives in the state. See tf-state. -
An output that references a resource through
countbreaks when count = 0.aws_s3_bucket.this[0].arnfails with "index 0 out of range" if the resource is absent. Usetry(aws_s3_bucket.this[0].arn, null)or a splat:aws_s3_bucket.this[*].arn(which returns a list, possibly empty). -
You cannot write a validation that references another variable. TF 1.9+ added cross-variable validation through
preconditionin lifecycle, but inside the variable block itself you still get only self-validation.