Skip to content

Modules & Design

Good infrastructure architecture is not a one-time decision. It is a continuous practice of keeping the system easy and safe to change - the same goal software engineers apply to application code, applied to the infrastructure that runs it. The primitive purpose of Modules to exist is - Versioning, Distribution, and Sharing.


Traditional infrastructure architecture operates on a flawed premise: that the initial design will remain stable once deployed - like a bridge or a building. In practice, infrastructure must evolve continuously to support new features, security patches, performance improvements, and architectural scale changes.

The friction teams experience when changing infrastructure rarely comes from the code itself. It comes from complex design and implicit dependencies, combined with a lack of tooling and process that makes changes routine. Evolutionary architecture addresses this directly: design infrastructure so that both small and large changes can be made incrementally, safely, and without fear.

This philosophy maps directly to Agile and Lean methodologies - which work by removing the danger from system modifications, not by avoiding modifications altogether.


When evaluating infrastructure component design, the CUPID framework (Daniel Terhorst-North) provides five assessable properties that make infrastructure “a joy to work with.” They are not rigid rules - they are trade-off lenses.

PropertyWhat it means for IaC
ComposableCan be provisioned, tested, and delivered independently. Minimal surface area, explicit interfaces.
Unix philosophyDoes one thing well. Single focused purpose, consistent usage model. Maps directly to high cohesion.
PredictableDeterministic and observable. An engineer can know what resources will be provisioned, what’s happening during apply, and what the final state looks like.
IdiomaticFeels natural to anyone familiar with the platform and resources being used. No surprises in usage patterns.
Domain-basedOrganized around the capability it provides, not the underlying technical implementation.

CUPID properties are interdependent: designing for Unix philosophy naturally produces high cohesion; designing for Composability forces loose coupling and clean interfaces.


Every infrastructure component design decision comes down to two questions:

  1. How should elements be grouped?
  2. How should dependencies between groups be managed?

Cohesion measures how closely related the elements inside a single component are.

Cohesion levelCharacteristicsRisk
High cohesionAll elements serve one unified purposeLow blast radius, easy to test, change-safe
Low cohesionLoosely related elements bundled togetherForces full-component deploys for small changes; harder to test

Rule of thumb: Group elements based on how frequently they change together. If two elements are rarely modified in the same commit, they probably belong in separate components. Analyzing historical git commits is a practical way to identify natural grouping boundaries.

When you split a large component into smaller ones (to improve cohesion), you shift the complexity into the relationships between them. Coupling measures how tightly those relationships are.

Coupling levelCharacteristics
Tight couplingChanging one component regularly requires changing another
Loose couplingComponents have a dependency, but can typically be updated independently

The goal: High cohesion within components, loose coupling between them.


When infrastructure is broken into components, dependencies create provider–consumer relationships:

  • Provider - a component that defines and provisions shared resources (VPCs, subnets, load balancers, IAM policies).
  • Consumer - a component that relies on resources created by a provider (e.g., a GKE node pool that needs to be placed inside a provider’s subnet).

Hard rule: circular dependencies must never exist. A provider must never consume resources from any of its own direct or indirect consumers.

Dependencies must be explicitly defined - never implicit.

Implicit interfaces develop when one team quietly relies on resources owned by another without formalizing the dependency. Example: a monitoring service builds VMs into subnets managed by the container team. When the container team restructures those subnets, the monitoring service silently breaks.

Explicit interface contracts require:

  • The provider declares exactly what it exports (subnet IDs, bucket names, service account emails)
  • The consumer declares exactly what it requires
  • Both sides agree to maintain the contract

Organizations should standardize how contracts are expressed - declared Terraform outputs written to a config registry, naming conventions, resource tagging - and treat breaking changes with the same care as a public API change.

Component-Level vs. Resource-Level Dependencies

Section titled “Component-Level vs. Resource-Level Dependencies”
Dependency typeHow it worksWhy it breaks
Resource-level (bad)Consumer hard-codes a provider’s resource names, tags, or IDsProvider renames or restructures → consumer breaks; requires coordinated cross-team update
Component-level (good)Provider exports identifiers to a registry; consumer reads from registryProvider can restructure internals freely as long as it keeps exporting the same identifiers

The formal name for this pattern is the Law of Demeter (Principle of Least Knowledge): a consumer component should never depend on the internal implementation details of its providers. It should only know what it needs to connect - not how that thing was built.


In IaC, testability is not a quality concern bolted on after design - it is a design concern.

Testing an infrastructure component in isolation requires that the component can be provisioned without pulling in a large chain of dependencies. If it cannot, the component is over-coupled and will resist automated delivery.

This creates a forcing function: rigorous isolated testing drives good design. Components that are hard to test in isolation are components with weak cohesion or tight coupling. Fixing the test problem means fixing the design problem.

The Agile concept of Test-Driven Development (TDD) applies here not just as a mechanical practice, but as an architectural discipline - writing tests first forces engineers to confront coupling and cohesion problems before they become expensive to fix.

TDD

Design forces are the requirements and constraints that shape where component boundaries are drawn. They operate across four lifecycle stages:

ForceInfluence
Code ownershipRepository permissions naturally push code to follow team ownership boundaries
Working setsGroup code that engineers change together as part of a single task
Delivery scopeKeep a component’s code together so it can be built and tested as one unit
ForceInfluence
Downtime managementSmaller, independently deliverable components reduce the scope of workloads affected by any single change
Workload alignmentShared runtime resources (e.g., one database for multiple apps) create deployment coupling - a database can’t be upgraded until all its apps are ready
ComplianceIsolate regulated infrastructure into dedicated components so lightweight delivery processes can apply to the rest
ForceInfluence
ScalingIsolate bottleneck resources so they can scale independently
Geographic distributionSeparate centralized shared resources from regional replicas
ResilienceStorage and compute have different recovery requirements - separate them so disaster recovery is simpler
Data regulationCredit card data, tenant-isolated data - each needs distinct infrastructure instances with enforced security boundaries
Component lifecyclesSlow-to-provision resources (load balancer rules) and fast-cycling resources (test compute) should live in separate components to avoid blocking test pipelines
Hosting costsAvoid unnecessary replication; optimize placement for both cost and carbon footprint
ForceInfluence
Cognitive sizeLarge components are harder to understand, slower to test, and have a wider blast radius. Keep them small - but manage the integration complexity that follows.
Change scopeMinimize the number of components that must change to deliver a single routine modification. Use commit history to identify natural cohesion clusters.
Conway’s LawSystems reflect the communication structures of the organizations that build them. Align component boundaries with team ownership to avoid cross-team modification friction.
Cost of ownershipStandardized, reusable components reduce the total amount of custom code and make routine patching easier across the organization.
SecuritySee section below.
Lightweight governanceCentralized control must be balanced with team autonomy. Over-governance creates bottlenecks; under-governance creates sprawl.

Infrastructure Security as a Design Concern

Section titled “Infrastructure Security as a Design Concern”

Cloud infrastructure introduces a security attack vector that does not exist in traditional data centers: the IaaS API itself.

Traditional network segmentation (public segments, DMZs, internal storage networks) defends against network-based attacks. In a cloud environment, an attacker who gains IaaS API credentials can bypass all network layers entirely - directly creating, modifying, or destroying infrastructure through the API.

This fundamentally changes how component boundaries must be designed:

StrategyWhat it achieves
Separate IaaS accounts per security domainAPI credentials for one account cannot affect resources in another
Risk-based groupingEvaluate the specific blast radius if each account’s API credentials are compromised
Monitoring isolationHost alerting and monitoring in a separate account - a compromised workload account cannot silence its own alerts

The structure of infrastructure code looks different depending on which lifecycle stage you’re examining. Design decisions at one stage ripple into others.

StageWhat engineers doDesign concernsHow it manifests
Source codeEdit and testOptimize for understanding, sharing, collaborating, changingRepository structure, folder layout, file organization
PackagePrepare for deploymentFast and reliable feedback on production-readinessPackages, branches, tags, deployment artifacts
DeploymentExecute to provisionSpeed and reliability of provisioningDesired state model in memory; determines deployment duration
Live resourcesRun workloadsOperability and troubleshootingActual cloud resources on the IaaS platform

Runtime requirements (scaling, resilience, data isolation) sometimes require only parameterization - the same code deployed multiple times with different config. Other times they require entirely separate source code components.


Modern platform engineering must balance centralized control with team autonomy. Heavy-handed governance creates approval bottlenecks; no governance creates fragmentation and sprawl.

Lightweight governance establishes standard patterns, shared modules, and policy guardrails while allowing teams to operate independently within those boundaries. It is a key element of the EDGE model used by Agile organizations for digital transformation.


The design patterns above are tool-agnostic. In Terraform and OpenTofu, the primary mechanism for implementing them is the module - a reusable package of pure HCL that takes input parameters, executes internal logic, and returns outputs.

DRY Principle
TypeRoleWhere providers are configured
Root moduleEntry point for a Terraform project - running terraform init here creates a workspaceYes - the provider block lives here
Shared moduleReusable module pulled from a registry or Git repositoryNo - inherits providers from the calling root
SubmoduleA child module nested inside a parent module’s modules/ directory, used to decompose complexityNo - inherits providers from the parent
Scope and Data flow Module Block

Modules are called using the module block, which supports three meta-arguments not found on standard resources:

Meta-argumentRequiredPurpose
sourceYesWhere to download the module - local path, Git URL, or registry address
versionRecommendedLocks the module to a specific version range when pulling from a registry
providersOptionalPasses specific provider aliases from the caller into the nested module

The Terraform community follows a conventional layout for modules:

File / DirectoryPurpose
main.tfPrimary resource definitions
variables.tfInput variable declarations
output.tfOutput attribute definitions
README.mdDocumentation - automatically parsed by module registries
modules/Submodules for internal decomposition
templates/Template files referenced by resources
examples/Usage examples for consumers
Registry typeExamplesAuthentication
PublicTerraform Registry (~12,000 modules)None - open access
PrivateTerraform Cloud, Spacelift, self-hostedTokens via terraform login

All Terraform variables are constants - their values cannot change during a run. A module’s data flow uses three scopes:

ScopeBlockDirectionVisibility
InputvariableInward (caller → module)Configurable by the caller
LocallocalsInternal onlyPrivate to the module
OutputoutputOutward (module → caller)Exposed to the caller and downstream resources
Variable Scope
  • Inputs accept a description, type, default, and optional sensitive flag (suppresses log output but does not encrypt state)
  • Outputs can also be sensitive and can declare explicit depends_on for ordering
  • Locals are used for internal processing, avoiding circular references, and caching computed values

Terraform enforces variable types to catch misconfigurations early:

CategoryTypes
Primitivestring (supports interpolation), number (int, float, negative), bool
Collectionlist (ordered, same type), set (unordered, no duplicates)
Structuraltuple (fixed length, mixed types), object (keyed, typed fields, optional keys), map (keyed, same-type values)
Specialnull (value not set), any (accepts any type)

The validation subblock inside a variable block enforces constraints before Terraform reaches the plan stage:

variable "environment" {
type = string
description = "Deployment environment"
validation {
condition = contains(["dev", "staging", "prod"], var.environment)
error_message = "Environment must be dev, staging, or prod."
}
}

Conditions can check string length, integer ranges, regex patterns, or set membership. Failed validations produce the custom error_message immediately - no resources are evaluated.

Refactoring

The typical workflow for converting a hard-coded root module into a reusable, publishable module:

  1. Extract variables - replace every hard-coded value with an input variable
  2. Add outputs - expose the attributes that consumers will need (IDs, endpoints, ARNs)
  3. Test in a separate workspace - instantiate the refactored module from a clean root to verify it works in isolation
  4. Publish - push to a Git host (GitHub, GitLab) or a module registry for team-wide consumption
  5. Version - tag releases with semantic versioning so consumers can pin to stable versions

Modules introduce real overhead - versioning, dependency management, a separate build step, and an abstraction layer that every teammate must understand. That overhead is worth paying only when the benefit is clear.

When to Create a Module

Before extracting code into a module, ask:

QuestionIf yes →If no →
Will this code be used in 3+ distinct places?Module is likely justifiedFavour duplication or file/folder organisation
Does wrapping it genuinely simplify the interface?Good candidate for a Facade or BundleRisk of creating an Obfuscation Module
Do the resources inside always provision together?Bundle Module makes senseSplit into separate, focused modules
  • The same group of resources is provisioned across three or more stacks with only configuration differences
  • The underlying resource API is genuinely complex and a simplified interface would save consumers from needing to understand it
  • You want to enforce a secure or approved configuration as the only option available to consumers
  • A platform team needs to offer self-service infrastructure components to stream-aligned teams
  • You need to version and distribute the code independently of the stacks that consume it
  • The code exists in only one place - use files and folders to organise; reach for a module only when a second or third consumer appears
  • The wrapper would pass all parameters straight through without hiding complexity (that’s an Obfuscation Module)
  • The module would need heavy conditionals to handle divergent use cases - split it into separate focused modules instead
  • You’re only trying to break up a large stack - splitting into separately deployable stacks solves the deployment problem; modules alone do not
  • You’d be creating a module to satisfy a premature DRY instinct - YAGNI applies here

Code libraries (Terraform modules) are the primary mechanism for packaging and reusing infrastructure logic. How they are designed determines whether they simplify your codebase or silently complicate it. The patterns below describe the design spectrum from well-focused modules to antipatterns that create more problems than they solve.

Infrastructure Code Library Patterns
PatternTypeWhat it doesLanguage
Facade ModulePatternWraps a single resource with a simplified interfaceDeclarative
Bundle ModulePatternGroups multiple related resources under one interfaceDeclarative
Infrastructure Domain EntityPatternDynamically provisions resources based on high-level business inputsImperative
Stack ModulePatternImplements an entire deployable stack as a libraryEither
Obfuscation ModuleAntipatternWraps a resource without simplifying it-
Unshared ModuleAntipatternA module used in exactly one place-
Spaghetti ModuleAntipatternAn over-configured module that generates wildly different results-
Modular MonolithAntipatternA monolith split into modules but still deployed as one unit-

A facade module (also called a wrapper module) wraps a single infrastructure resource and exposes only a few essential parameters while hardcoding the rest internally.

Facade Module

Example: a facade for a virtual server might expose only name and memory, while internally hardcoding the source image, provisioning tool, server role, and network configuration.

BenefitTrade-off
Simpler, more readable stack codeLimits flexibility - not suitable for every use case
Standardizes secure, approved configurationsAdds an abstraction layer that obscures what’s actually provisioned
Centralized updates propagate to all consumersMaintenance and debugging overhead from the extra layer

Best for: simple, repetitive use cases where the underlying resource API is overly complex and most options are irrelevant to the consumer.

A bundle module groups a cohesive collection of related resources under a single interface - essentially a facade applied to multiple resources instead of one.

Bundle Module

Example: an application_server bundle might accept app_name, cluster_size, and ram. Internally it provisions the server cluster, configures a load balancer, and sets up DNS - wiring everything together.

BenefitTrade-off
Eliminates boilerplate across stacksMay provision more resources than needed in some cases
Captures institutional knowledge about how resources connectUsers must understand everything it provisions to avoid over-provisioning

Best for: declarative languages where the resources involved do not vary across use cases. If resources change based on the situation, create separate modules or use an Infrastructure Domain Entity instead.

A domain entity implements a high-level stack component by dynamically provisioning resources based on business-level input parameters - not technical ones.

Domain Entity

Example: a Java application infrastructure entity accepts traffic_level (high, medium, low) and dynamically adjusts cluster sizing, auto-scaling thresholds, and resource allocation.

AspectDetail
ApproachTop-down - starts from what the use case requires, not from the resources to create
LanguageMust be imperative (CDK, Pulumi, CDKTF) - declarative languages lack the dynamic logic required
Design methodDerived from Domain-Driven Design (DDD) - treat infrastructure delivery as a domain in its own right
Best forPlatform teams building robust, configurable components for other teams to consume

Key insight: the bundle module and the domain entity both group resources, but from opposite directions. Bundles are bottom-up (starting with resources); domain entities are top-down (starting with requirements).

The stack module pattern (also called a no-code module) uses a code library to implement an entire deployable stack. A separate wrapper stack project imports the module and provides instance-specific configuration.

Stack Module

This pattern is already covered in detail in Stacks & Components - The Stack Module Pattern. Its inclusion here is for completeness in the library patterns taxonomy.

Why it exists: Terraform and OpenTofu have strong built-in support for distributing, versioning, and sharing modules - but lack equivalent support for stacks. The stack module pattern is a practical workaround for this tool limitation. Some platforms (e.g., HCP Terraform’s no-code provisioning) automate wrapper generation entirely.


An obfuscation module wraps infrastructure code without simplifying it or adding value - it just passes parameters through to the underlying resource.

Obfuscation Module

How it happens:

  • Misguided DRY - a developer wraps a commonly used resource (security group, load balancer) in a module just because it appears in multiple places, without actually simplifying the interface
  • Custom language syndrome - attempting to build a proprietary language on top of the stack tool’s native constructs

The result: more code to maintain, cognitive overhead for teammates who must learn the custom module, and extra moving parts in the build pipeline - all without any reduction in complexity.

Resolution: if a module doesn’t add enough value to justify its overhead, refactor by replacing module usages with direct stack language code. If the goal is simplifying infrastructure for non-specialists, use higher-level abstractions (compositions, stacks) instead of wrapping low-level resources.

A module that is used in exactly one place - created to organize a growing stack project rather than to enable reuse.

Why it’s a problem: modules introduce versioning, dependency management, and build overhead. Creating a reusable component when you don’t need to reuse it is a textbook case of YAGNI (You Aren’t Gonna Need It).

Better alternatives:

  • Split the stack into multiple smaller, independently deployable stacks
  • Organize with files and folders - structure the code for readability without the overhead of module management

A module so configurable that it generates wildly different infrastructure depending on the parameters it receives - filled with complex conditionals and excessive moving parts.

Spaghetti Module

Example: a single module that conditionally assigns servers to different network segments, optionally creates a database cluster, and dynamically switches between VMs and container instances based on an app_type parameter.

How it happens:

  • A facade or bundle module slowly accumulates edge-case conditionals
  • A developer attempts to build a domain entity using a declarative language (which lacks the required dynamic logic)

Symptoms:

  • Struggling to build pipelines and write automated tests for the module in isolation
  • Changes frequently break unrelated functionality
  • The module is harder to understand than the raw resources it wraps

Resolution: split into multiple focused modules, each with a tight remit. One module for Java application servers, another for MySQL clusters - not one module that tries to handle both.

A monolithic stack that has been split into modules but still deployed as a single unit. The code is more organized, but the operational problems remain:

  • Deployments are still slow
  • A change to one module still risks impacting everything in the stack
  • Feedback loops for testing and delivery are unchanged

Why it fails: the root problem with a monolith is not code organization - it is deployment scope. Splitting code into modules addresses the code problem but leaves the deployment problem untouched.

Resolution: don’t just modularize the code - split the monolith into smaller, separately deployable stacks. See Stack Sizing Patterns for guidance on how to size them.