Introduction
I've inherited Terraform modules with sixty input variables for a single ECS service. Sixty. The module was designed to handle every possible ECS configuration, capacity providers, task placement strategies, deployment circuit breakers, service registries, propagate tags settings, force new deployment flags, and a dozen more parameters that 95% of services would never use. The intent was good: build once, configure for anything. The result was a module that required reading four hundred lines of HCL to understand the defaults, that produced plans nobody could confidently interpret, and that application teams routinely bypassed in favor of writing their own ECS resources directly.
Terraform module design is one of the most consequential decisions a platform team makes, and over-abstraction is the most common failure mode. Not under-abstraction: most engineers understand that writing the same resource block fifteen times is bad. Over-abstraction: the module that tries to be everything to every consumer, exposes every underlying resource parameter, handles every edge case through conditional logic, and ends up being more complex than the resources it was supposed to simplify.
This post covers what over-abstraction looks like in practice, why it happens, and what well-designed modules actually look like.
What Over-Abstraction Looks Like and Why It Happens
A Terraform module is over-abstracted when using it requires as much knowledge as not using it. When a consumer needs to read the module source to understand which of the forty variables to set, what the interaction between var.enable_service_discovery and var.service_registry_arn is, and why var.deployment_maximum_percent has a different default when var.launch_type is set to "FARGATE": they would have been better served by writing the aws_ecs_service resource directly.
The over-abstraction antipattern starts with a reasonable instinct: abstracting a complex AWS resource into a module so consumers don't have to understand the underlying service. The failure mode is confusing hiding complexity with eliminating it. A module that exposes fifty variables hasn't eliminated the complexity of the underlying AWS resource, it's translated it into a different form that's harder to look up in documentation, harder to validate with native AWS tooling, and harder to debug when something goes wrong.
There's also a structural pressure toward over-abstraction: modules are often built reactively, one consumer request at a time. The first consumer needs a basic ECS service. The module is simple. The second consumer needs a service with service discovery. A variable gets added. The third needs it with a custom task placement strategy. Another variable. Six months later, the module has accumulated the requirements of eight different consumers and the variable count has grown to match. The module was never designed: it was grown.
The concrete failure this produces: a platform team at a B2B SaaS company built an RDS module that handled MySQL, PostgreSQL, Aurora MySQL, Aurora PostgreSQL, and Aurora Serverless v2 through a single var.engine input and a set of conditional locals that derived dozens of other settings from that input. The module had 71 variables. When the team needed to enable Aurora Serverless v2's scaling configuration, a setting that only applies to that engine type, they added var.serverless_min_capacity and var.serverless_max_capacity. Those variables had no effect on any other engine type, but they appeared in the variable interface for all consumers. Engineers using standard Aurora PostgreSQL were routinely confused about whether they needed to set them. The module's complexity had outgrown the problem it was solving.
AWS Deep Dive: What Well-Designed Terraform Modules Actually Look Like
Design for the Common Case, Not Every Case
The correct mental model for Terraform module design is: a module should make the common case trivially easy and the uncommon case possible. Not: a module should make every case equally possible.
For an ECS service module, the common case is a Fargate service with a single container, behind an ALB, in private subnets, with CloudWatch logging, a task execution role with basic ECR and Secrets Manager permissions, and deployment circuit breaker enabled. That's what 80% of ECS services on any platform look like. The module interface for that common case is eight or ten variables:
# The right-sized interface for an ECS service module
variable "service_name" {
description = "Name of the ECS service. Used as the basis for resource naming."
type = string
}
variable "container_image" {
description = "Full ECR image URI including tag. e.g., 123456789012.dkr.ecr.us-east-1.amazonaws.com/api:v1.2.3"
type = string
}
variable "container_port" {
description = "Port the container listens on. Must match the ALB target group port."
type = number
}
variable "cpu" {
description = "Task CPU units. Valid Fargate values: 256, 512, 1024, 2048, 4096. See https://docs.aws.amazon.com/AmazonECS/latest/developerguide/task-cpu-memory-error.html for memory constraints per CPU value."
type = number
default = 512
}
variable "memory" {
description = "Task memory in MiB. Must be compatible with the cpu value. 512 CPU supports 1024–2048 MiB."
type = number
default = 1024
}
variable "desired_count" {
description = "Number of task instances to run. For production, set to at least 2 for AZ redundancy."
type = number
default = 2
}
variable "vpc_id" {
description = "VPC ID for the security group. Obtain from the VPC module output."
type = string
}
variable "subnet_ids" {
description = "Private subnet IDs for task placement. Use private-app subnets, not public subnets."
type = list(string)
}
variable "alb_target_group_arn" {
description = "ARN of the ALB target group to register tasks with."
type = string
}
variable "secret_arns" {
description = "List of Secrets Manager secret ARNs the task needs to access. Added to the execution role policy."
type = list(string)
default = []
}
Ten variables. No var.launch_type because the module always uses Fargate. No var.enable_circuit_breaker because the module always enables it. No var.task_placement_strategy because the module uses the Fargate default. These are not exposed as variables because they're not consumer decisions, they're platform decisions. The module encodes the platform team's choices and exposes only the choices that are genuinely consumer-specific.
What happens when a consumer needs something the module doesn't support? They have two options: request the module be extended for a genuinely common new requirement, or write the aws_ecs_service resource directly for a genuinely uncommon configuration. The second option is not a module failure. It's the correct outcome for a case that doesn't belong in the standard module.
When to Split a Module vs. When to Extend It
The most common design mistake after over-abstraction is trying to extend a module to cover cases it wasn't designed for, rather than creating a second module.
An ECS service module and an ECS scheduled task module are different things. They share some underlying AWS resources (task definitions, IAM roles, CloudWatch log groups) but their primary resources are different (aws_ecs_service vs. aws_cloudwatch_event_rule + aws_cloudwatch_event_target), their inputs are different, and their operational model is different. Extending the ECS service module to also handle scheduled tasks through a var.schedule_expression variable and a conditional that switches between service and scheduled-task resources produces a module whose behavior changes fundamentally based on a single variable, exactly the pattern that makes modules hard to reason about.
The rule I apply: if a module's behavior changes categorically, not quantitatively, based on the value of an input variable, it should be two modules. Changing var.cpu from 512 to 1024 is quantitative: the module does the same thing, differently. Changing var.launch_type from "FARGATE" to "EC2" is categorical: the module needs different resources, different IAM policies, and different networking configuration. Those are two modules.
The non-obvious Terraform constraint that enforces this discipline: count and for_each on a resource cannot be set based on a variable whose value is unknown at plan time. If your module uses count = var.enable_service_discovery ? 1 : 0 on a aws_service_discovery_service resource, and var.enable_service_discovery comes from a computed value in another resource, Terraform will produce an error: The "count" value depends on resource attributes that cannot be determined until apply. This is Terraform's static analysis correctly identifying that your module's structure depends on runtime data: a sign that the conditional behavior is encoded at the wrong level.
The Output Interface Is as Important as the Input Interface
Most discussion of module design focuses on variables. The output interface, what the module exposes for downstream consumption, is equally consequential and equally prone to design mistakes.
An over-abstracted module often has an under-specified output interface: it creates fifteen resources but exports only the IDs of the primary ones, forcing consumers to either accept the limitation or duplicate resource definitions to get the outputs they need.
A well-designed output interface exports the specific attributes that downstream modules and configurations need, with descriptive names that don't require reading the module source:
output "service_name" {
description = "Name of the ECS service. Use for CloudWatch alarm dimensions and deployment scripts."
value = aws_ecs_service.main.name
}
output "task_definition_arn" {
description = "ARN of the active task definition revision. Use for manual task runs and deployment scripts."
value = aws_ecs_task_definition.main.arn
}
output "task_role_arn" {
description = "ARN of the IAM role assumed by running tasks. Add additional policy attachments to this role for service-specific AWS permissions."
value = aws_iam_role.task.arn
}
output "security_group_id" {
description = "ID of the security group attached to tasks. Add ingress rules here for services that need to call this service directly."
value = aws_security_group.tasks.id
}
output "cloudwatch_log_group_name" {
description = "Name of the CloudWatch Log Group for this service. Use for log-based metric filters and Logs Insights queries."
value = aws_cloudwatch_log_group.service.name
}
The task_role_arn output is the one most commonly missing. If the module creates the task IAM role but doesn't expose it, every consumer who needs to add service-specific permissions (access to a specific DynamoDB table, a specific S3 bucket, a specific Secrets Manager secret beyond the baseline), has to either modify the module (adding more variables) or create a separate IAM role attachment resource that references the task role by a name they have to hard-code or look up. Exporting the task role ARN eliminates that friction and keeps service-specific IAM out of the shared module where it doesn't belong.
Enjoying this so far?
Join thousands of engineers receiving practical cloud, DevOps, Kubernetes, Infrastructure and AI engineering insights.
No spam. Unsubscribe anytime.
Tradeoffs & Decision Framework
The argument for highly parameterized modules is real: if every team writes its own ECS service resource, you get fifteen slightly different ECS configurations, each making slightly different security and operational decisions. Centralized modules enforce consistency.
That argument is correct. The conclusion, therefore expose every parameter, is not. The right question is not "should we use modules?" but "what should modules decide and what should they leave to consumers?"
Module decides (never a variable):
- Launch type (always Fargate for this platform)
- Deployment circuit breaker (always enabled)
- Log driver (always awslogs to CloudWatch)
- Container restart policy (always unless the module has a good reason otherwise)
- Encryption at rest for any storage the module creates
Consumer decides (always a variable):
- Service name, container image, port
- CPU and memory sizing
- Desired count
- Network placement (VPC, subnets, ALB target group)
- Service-specific environment variables and secrets
Context-dependent (variable with a good default):
- Deployment maximum and minimum healthy percent (default 200/100 works for most cases)
- Health check grace period (default 30 seconds, but some services need longer)
- Deregistration delay on the target group (default 300 seconds is too long for fast-cycling dev environments)
The scale at which this framework breaks down: an organization with twenty product teams that have genuinely different platform requirements (one team running GPU-accelerated ML workloads, another running latency-sensitive real-time services, another running batch processing jobs), may need different modules for different platform tiers rather than a single universal module that tries to parameterize the differences. The cost of multiple opinionated modules is more modules to maintain. The cost of a single highly-parameterized universal module is a module that satisfies nobody's use case optimally and that everyone works around.
Lessons From the Field
1. Every variable added to a module to satisfy one consumer's request is a variable every future consumer has to understand. Built an RDS module for a fintech client that started with twelve variables. Over eighteen months of consumer requests ("can you add a variable for parameter group family," "can you add support for custom option groups," "can you expose the backup window"), it grew to forty-four. At that point, the most common consumer question on the platform Slack was "which variables do I actually need to set?" The answer was twelve. The other thirty-two were for edge cases that two or three services used. We split the module: a standard module with twelve variables for the common case, and an extended module for services with custom requirements. Adoption of the standard module was immediate.
2. The module that handles both MySQL and PostgreSQL handles neither well.
Inherited a database module at a SaaS company that accepted var.engine and used it to switch between MySQL-specific and PostgreSQL-specific configurations through conditional locals. The PostgreSQL consumers couldn't use certain features because the module's interface was constrained to what worked for both engines. The MySQL consumers couldn't use certain MySQL 8.0-specific parameter groups because the conditional logic didn't support them cleanly. Splitting into separate engine-specific modules, with separate variable interfaces optimized for each engine, eliminated twelve pages of module issue backlog in a single sprint.
3. A module that takes thirty minutes to understand will be bypassed within six months.
Evaluated a VPC module at a logistics company that had been in the codebase for two years. The original author had left. The module had grown to eighty-three variables. Application teams had started creating VPCs directly with aws_vpc, aws_subnet, and related resources because "the module is too complicated and we can't figure out what to set." The platform team had lost control of VPC configuration standards. The fix was a complete rewrite, not an update to the existing module, with a hard limit of fifteen variables and a documented policy that new variables required a review against the "does the common case need this?" criterion.
4. Module outputs omitted to keep the interface "clean" always get requested back. Built an ECS module that deliberately omitted the task role ARN output to "keep the interface focused." Within two weeks, three different teams had opened issues requesting access to the task role for service-specific IAM policy attachments. Added it back in v1.1.0. The lesson: omitting outputs doesn't simplify the module: it pushes the complexity downstream to consumers who have to work around the missing output. If a module creates a resource, it should expose the resource's most commonly consumed attributes.
5. The right time to create a new module is when the second consumer's requirements don't fit the first consumer's module without adding a major conditional.
The signal is clear: if satisfying a new consumer requires adding a variable whose value fundamentally changes the module's resource structure (count conditionals, categorical for_each patterns, major changes to IAM policy content), you're being asked to create a second module, not extend the first. Recognizing that signal early prevents the accumulated-conditional problem that makes modules unreadable.
Final Thoughts
Terraform module design is maturing as a discipline, and the community is converging on patterns that avoid over-abstraction. The growth of opinionated module registries, the adoption of module versioning as a forcing function for stable interfaces, and the increasing availability of tools that evaluate module complexity (variable counts, output coverage, conditional density), are all pushing toward smaller, more focused modules with clear interfaces.
The pattern that consistently produces the best outcomes: modules that encode platform decisions and expose only genuinely consumer-specific configuration, with a stated scope that makes it clear what the module handles and what it intentionally leaves out. A module that knows what it doesn't do is easier to maintain, easier to consume, and easier to evolve than one that tries to cover every case through parameterization.
The instinct to over-abstract usually comes from a good place, wanting to prevent duplicated configuration, wanting to enforce consistency, wanting to build something reusable. All of those goals are right. The implementation that achieves them is fewer variables with better defaults, not more variables with more conditionals.
Terraform module design is part of what we evaluate and rebuild when we take on infrastructure engagements at PulseSoft. If your module library has become harder to use than the AWS resources it wraps, let's talk.
Key Takeaways
- A Terraform module is over-abstracted when using it requires as much knowledge as not using it. If consumers need to read the module source to understand which of forty variables to set and how they interact, the module has accumulated complexity rather than eliminating it.
- Modules accumulate variables reactively, one consumer request at a time. The fix is not refusing consumer requests, it's evaluating each request against "does the common case need this?" and creating a second, specialized module when the answer is no.
- Module design has two distinct categories: platform decisions (never variables) and consumer decisions (always variables). Launch type, logging configuration, and circuit breaker settings are platform decisions. Service name, CPU, memory, and network placement are consumer decisions. Exposing platform decisions as variables undermines the consistency the module was built to enforce.
- If a module's behavior changes categorically based on a variable value, it should be two modules. Changing CPU from 512 to 1024 is quantitative (the same module behavior, different scale. Switching from Fargate to EC2 launch type is categorical), different resources, different IAM, different networking. Those are two modules.
- The
count = var.enable_x ? 1 : 0pattern on conditional resources fails when the variable's value is computed at apply time. Terraform's static analysis correctly identifies this as a plan-time error. It's a signal that the conditional behavior belongs in a separate module, not in a conditional block inside the current one. - Module outputs are as important as module variables. A module that creates a task IAM role but doesn't export the ARN forces consumers to work around the omission through hard-coded names or duplicate resource definitions. If a module creates it, the module should expose the attributes consumers commonly need.
- The right time to split a module is when satisfying a new consumer's requirements requires a major conditional that changes the module's resource structure. Recognizing that signal early, before the conditional is added, is the difference between a focused module and an accumulated-complexity module that nobody can confidently modify.
Get insights like this in your inbox
Join thousands of engineers receiving practical cloud, DevOps, Kubernetes, Infrastructure and AI engineering insights.
No spam. Unsubscribe anytime.