# Terraform Modules That Don't Break When Your Team Grows

Six months into a project, our `main.tf` was 800 lines long.

Every resource — Cloud Run services, Pub/Sub topics, Cloud SQL, Redis, IAM bindings, secrets — lived in one file. Nobody wanted to touch it. Adding a new Cloud Run service meant scrolling through hundreds of lines to find the right place, hoping you didn't accidentally modify something adjacent. Reviewing a PR meant reading a diff that changed lines 47, 312, and 651 with no obvious relationship between them.

The file worked. It was just impossible to reason about.

Modules are the fix. Not because they're the Terraform-approved way to do things, but because they enforce the same principle that makes application code maintainable: one thing, one place, clear boundaries.

* * *

## What a Module Is

A module is a directory with Terraform files. That's it. You call it with `module` block, pass in variables, and get outputs back. It's a function for infrastructure.

```hcl
# Without modules — everything inline in main.tf
resource "google_cloud_run_v2_service" "producer" { ... }
resource "google_cloud_run_v2_service" "consumer" { ... }
resource "google_pubsub_topic" "commerce_events" { ... }
resource "google_pubsub_subscription" "realtime" { ... }
resource "google_sql_database_instance" "main" { ... }
# ... 750 more lines

# With modules — main.tf becomes a composition
module "pubsub" {
  source         = "./modules/pubsub"
  project_id     = var.gcp_project_id
  environment    = var.environment
}

module "cloud_run" {
  source               = "./modules/cloud_run"
  project_id           = var.gcp_project_id
  producer_image       = var.producer_image
  consumer_image       = var.consumer_image
  consumer_url         = module.pubsub.consumer_push_url
}

module "database" {
  source      = "./modules/database"
  project_id  = var.gcp_project_id
  environment = var.environment
  tier        = var.db_tier
}
```

The top-level `main.tf` is now a readable description of what the system is. The details live in the modules.

* * *

## Module Structure That Scales

```plaintext
infra/
├── main.tf           # module composition only
├── variables.tf      # top-level inputs
├── outputs.tf        # top-level outputs
├── versions.tf       # provider + backend config
├── environments/
│   ├── dev.tfvars
│   └── prod.tfvars
└── modules/
    ├── pubsub/
    │   ├── main.tf
    │   ├── variables.tf
    │   └── outputs.tf
    ├── cloud_run/
    │   ├── main.tf
    │   ├── variables.tf
    │   └── outputs.tf
    ├── database/
    │   ├── main.tf
    │   ├── variables.tf
    │   └── outputs.tf
    └── redis/
        ├── main.tf
        ├── variables.tf
        └── outputs.tf
```

Every module has three files. `main.tf` declares resources. `variables.tf` declares inputs. `outputs.tf` declares what the module exposes to the outside. Nothing else.

The rule: if a resource belongs to a specific service or concern, it lives in that module. If you're not sure which module a resource belongs to, that's a signal the module boundaries need rethinking.

* * *

## Writing a Module That's Actually Reusable

A module that works only for one exact configuration isn't a module — it's just a file split. The variables interface is what makes a module reusable.

```hcl
# modules/cloud_run/variables.tf
variable "project_id" {
  type        = string
  description = "GCP project ID"
}

variable "region" {
  type        = string
  default     = "us-central1"
}

variable "producer_image" {
  type        = string
  description = "Docker image URI for the producer service"
}

variable "min_instances" {
  type        = number
  default     = 0
  description = "Minimum Cloud Run instances. Set to 1+ in prod to avoid cold starts."
}

variable "max_instances" {
  type        = number
  default     = 10
}

variable "service_account_email" {
  type        = string
}
```

```hcl
# modules/cloud_run/main.tf
resource "google_cloud_run_v2_service" "producer" {
  name     = "pulsecart-producer"
  location = var.region

  template {
    scaling {
      min_instance_count = var.min_instances
      max_instance_count = var.max_instances
    }

    containers {
      image = var.producer_image
    }

    service_account = var.service_account_email
  }
}
```

```hcl
# modules/cloud_run/outputs.tf
output "producer_url" {
  value       = google_cloud_run_v2_service.producer.uri
  description = "URL of the deployed producer service"
}
```

```hcl
# environments/prod.tfvars
min_instances = 1
max_instances = 20
db_tier       = "db-custom-4-15360"

# environments/dev.tfvars
min_instances = 0
max_instances = 5
db_tier       = "db-g1-small"
```

The same module, two environments, zero duplication. Changing the prod scaling config is one line in `prod.tfvars` — not a search through 800 lines of `main.tf`.

* * *

## Module Outputs and Cross-Module Dependencies

Modules need to talk to each other. The Cloud Run module needs the Pub/Sub push endpoint. The Pub/Sub module needs the Cloud Run service URL for the push subscription. Outputs wire them together cleanly:

```hcl
# main.tf — passing outputs between modules
module "cloud_run" {
  source    = "./modules/cloud_run"
  # Pass the consumer URL to Pub/Sub so it knows where to push
  consumer_url = module.cloud_run.consumer_url  # ← circular?
}

module "pubsub" {
  source         = "./modules/pubsub"
  consumer_push_url = module.cloud_run.consumer_url  # ← resolved at plan time
}
```

Terraform resolves inter-module dependencies automatically at plan time — it builds a dependency graph and applies resources in the right order. You don't need to manage this manually.

Watch for genuine circular dependencies (module A needs an output from module B, which needs an output from module A). These require restructuring — usually by extracting the shared resource into a third module that both depend on.

* * *

## The One Rule

**Each module owns exactly one concern.** Pub/Sub owns topics and subscriptions. Cloud Run owns services and revisions. Database owns the Cloud SQL instance and its config. When a module starts owning two unrelated things, split it.

This rule is what keeps modules reviewable. A PR that touches only `modules/pubsub/` tells a reviewer exactly what changed and why. A PR that touches `main.tf` line 47, 312, and 651 tells them nothing.
