github.com/hugorut/terraform@v1.1.3/website/docs/language/modules/develop/composition.mdx (about) 1 --- 2 page_title: Module Composition 3 description: |- 4 Module composition allows infrastructure to be described from modular 5 building blocks. 6 --- 7 8 # Module Composition 9 10 In a simple Terraform configuration with only one root module, we create a 11 flat set of resources and use Terraform's expression syntax to describe the 12 relationships between these resources: 13 14 ```hcl 15 resource "aws_vpc" "example" { 16 cidr_block = "10.1.0.0/16" 17 } 18 19 resource "aws_subnet" "example" { 20 vpc_id = aws_vpc.example.id 21 22 availability_zone = "us-west-2b" 23 cidr_block = cidrsubnet(aws_vpc.example.cidr_block, 4, 1) 24 } 25 ``` 26 27 When we introduce `module` blocks, our configuration becomes hierarchical 28 rather than flat: each module contains its own set of resources, and possibly 29 its own child modules, which can potentially create a deep, complex tree of 30 resource configurations. 31 32 However, in most cases we strongly recommend keeping the module tree flat, 33 with only one level of child modules, and use a technique similar to the 34 above of using expressions to describe the relationships between the modules: 35 36 ```hcl 37 module "network" { 38 source = "./modules/aws-network" 39 40 base_cidr_block = "10.0.0.0/8" 41 } 42 43 module "consul_cluster" { 44 source = "./modules/aws-consul-cluster" 45 46 vpc_id = module.network.vpc_id 47 subnet_ids = module.network.subnet_ids 48 } 49 ``` 50 51 We call this flat style of module usage _module composition_, because it 52 takes multiple [composable](https://en.wikipedia.org/wiki/Composability) 53 building-block modules and assembles them together to produce a larger system. 54 Instead of a module _embedding_ its dependencies, creating and managing its 55 own copy, the module _receives_ its dependencies from the root module, which 56 can therefore connect the same modules in different ways to produce different 57 results. 58 59 The rest of this page discusses some more specific composition patterns that 60 may be useful when describing larger systems with Terraform. 61 62 ## Dependency Inversion 63 64 In the example above, we saw a `consul_cluster` module that presumably describes 65 a cluster of [HashiCorp Consul](https://www.consul.io/) servers running in 66 an AWS VPC network, and thus it requires as arguments the identifiers of both 67 the VPC itself and of the subnets within that VPC. 68 69 An alternative design would be to have the `consul_cluster` module describe 70 its _own_ network resources, but if we did that then it would be hard for 71 the Consul cluster to coexist with other infrastructure in the same network, 72 and so where possible we prefer to keep modules relatively small and pass in 73 their dependencies. 74 75 This [dependency inversion](https://en.wikipedia.org/wiki/Dependency_inversion_principle) 76 approach also improves flexibility for future 77 refactoring, because the `consul_cluster` module doesn't know or care how 78 those identifiers are obtained by the calling module. A future refactor may 79 separate the network creation into its own configuration, and thus we may 80 pass those values into the module from data sources instead: 81 82 ```hcl 83 data "aws_vpc" "main" { 84 tags = { 85 Environment = "production" 86 } 87 } 88 89 data "aws_subnet_ids" "main" { 90 vpc_id = data.aws_vpc.main.id 91 } 92 93 module "consul_cluster" { 94 source = "./modules/aws-consul-cluster" 95 96 vpc_id = data.aws_vpc.main.id 97 subnet_ids = data.aws_subnet_ids.main.ids 98 } 99 ``` 100 101 ### Conditional Creation of Objects 102 103 In situations where the same module is used across multiple environments, 104 it's common to see that some necessary object already exists in some 105 environments but needs to be created in other environments. 106 107 For example, this can arise in development environment scenarios: for cost 108 reasons, certain infrastructure may be shared across multiple development 109 environments, while in production the infrastructure is unique and managed 110 directly by the production configuration. 111 112 Rather than trying to write a module that itself tries to detect whether something 113 exists and create it if not, we recommend applying the dependency inversion 114 approach: making the module accept the object it needs as an argument, via 115 an input variable. 116 117 For example, consider a situation where a Terraform module deploys compute 118 instances based on a disk image, and in some environments there is a 119 specialized disk image available while other environments share a common 120 base disk image. Rather than having the module itself handle both of these 121 scenarios, we can instead declare an input variable for an object representing 122 the disk image. Using AWS EC2 as an example, we might declare a common subtype 123 of the `aws_ami` resource type and data source schemas: 124 125 ```hcl 126 variable "ami" { 127 type = object({ 128 # Declare an object using only the subset of attributes the module 129 # needs. Terraform will allow any object that has at least these 130 # attributes. 131 id = string 132 architecture = string 133 }) 134 } 135 ``` 136 137 The caller of this module can now itself directly represent whether this is 138 an AMI to be created inline or an AMI to be retrieved from elsewhere: 139 140 ```hcl 141 # In situations where the AMI will be directly managed: 142 143 resource "aws_ami_copy" "example" { 144 name = "local-copy-of-ami" 145 source_ami_id = "ami-abc123" 146 source_ami_region = "eu-west-1" 147 } 148 149 module "example" { 150 source = "./modules/example" 151 152 ami = aws_ami_copy.example 153 } 154 ``` 155 156 ```hcl 157 # Or, in situations where the AMI already exists: 158 159 data "aws_ami" "example" { 160 owner = "9999933333" 161 162 tags = { 163 application = "example-app" 164 environment = "dev" 165 } 166 } 167 168 module "example" { 169 source = "./modules/example" 170 171 ami = data.aws_ami.example 172 } 173 ``` 174 175 This is consistent with Terraform's declarative style: rather than creating 176 modules with complex conditional branches, we directly describe what 177 should already exist and what we want Terraform to manage itself. 178 179 By following this pattern, we can be explicit about in which situations we 180 expect the AMI to already be present and which we don't. A future reader 181 of the configuration can then directly understand what it is intending to do 182 without first needing to inspect the state of the remote system. 183 184 In the above example, the object to be created or read is simple enough to 185 be given inline as a single resource, but we can also compose together multiple 186 modules as described elsewhere on this page in situations where the 187 dependencies themselves are complicated enough to benefit from abstractions. 188 189 ## Multi-cloud Abstractions 190 191 Terraform itself intentionally does not attempt to abstract over similar 192 services offered by different vendors, because we want to expose the full 193 functionality in each offering and yet unifying multiple offerings behind a 194 single interface will tend to require a "lowest common denominator" approach. 195 196 However, through composition of Terraform modules it is possible to create 197 your own lightweight multi-cloud abstractions by making your own tradeoffs 198 about which platform features are important to you. 199 200 Opportunities for such abstractions arise in any situation where multiple 201 vendors implement the same concept, protocol, or open standard. For example, 202 the basic capabilities of the domain name system are common across all vendors, 203 and although some vendors differentiate themselves with unique features such 204 as geolocation and smart load balancing, you may conclude that in your use-case 205 you are willing to eschew those features in return for creating modules that 206 abstract the common DNS concepts across multiple vendors: 207 208 ```hcl 209 module "webserver" { 210 source = "./modules/webserver" 211 } 212 213 locals { 214 fixed_recordsets = [ 215 { 216 name = "www" 217 type = "CNAME" 218 ttl = 3600 219 records = [ 220 "webserver01", 221 "webserver02", 222 "webserver03", 223 ] 224 }, 225 ] 226 server_recordsets = [ 227 for i, addr in module.webserver.public_ip_addrs : { 228 name = format("webserver%02d", i) 229 type = "A" 230 records = [addr] 231 } 232 ] 233 } 234 235 module "dns_records" { 236 source = "./modules/route53-dns-records" 237 238 route53_zone_id = var.route53_zone_id 239 recordsets = concat(local.fixed_recordsets, local.server_recordsets) 240 } 241 ``` 242 243 In the above example, we've created a lightweight abstraction in the form of 244 a "recordset" object. This contains the attributes that describe the general 245 idea of a DNS recordset that should be mappable onto any DNS provider. 246 247 We then instantiate one specific _implementation_ of that abstraction as a 248 module, in this case deploying our recordsets to Amazon Route53. 249 250 If we later wanted to switch to a different DNS provider, we'd need only to 251 replace the `dns_records` module with a new implementation targeting that 252 provider, and all of the configuration that _produces_ the recordset 253 definitions can remain unchanged. 254 255 We can create lightweight abstractions like these by defining Terraform object 256 types representing the concepts involved and then using these object types 257 for module input variables. In this case, all of our "DNS records" 258 implementations would have the following variable declared: 259 260 ```hcl 261 variable "recordsets" { 262 type = list(object({ 263 name = string 264 type = string 265 ttl = number 266 records = list(string) 267 })) 268 } 269 ``` 270 271 While DNS serves as a simple example, there are many more opportunities to 272 exploit common elements across vendors. A more complex example is Kubernetes, 273 where there are now many different vendors offering hosted Kubernetes clusters 274 and even more ways to run Kubernetes yourself. 275 276 If the common functionality across all of these implementations is sufficient 277 for your needs, you may choose to implement a set of different modules that 278 describe a particular Kubernetes cluster implementation and all have the common 279 trait of exporting the hostname of the cluster as an output value: 280 281 ```hcl 282 output "hostname" { 283 value = azurerm_kubernetes_cluster.main.fqdn 284 } 285 ``` 286 287 You can then write _other_ modules that expect only a Kubernetes cluster 288 hostname as input and use them interchangeably with any of your Kubernetes 289 cluster modules: 290 291 ```hcl 292 module "k8s_cluster" { 293 source = "modules/azurerm-k8s-cluster" 294 295 # (Azure-specific configuration arguments) 296 } 297 298 module "monitoring_tools" { 299 source = "modules/monitoring_tools" 300 301 cluster_hostname = module.k8s_cluster.hostname 302 } 303 ``` 304 305 ## Data-only Modules 306 307 Most modules contain `resource` blocks and thus describe infrastructure to be 308 created and managed. It may sometimes be useful to write modules that do not 309 describe any new infrastructure at all, but merely retrieve information about 310 existing infrastructure that was created elsewhere using 311 [data sources](/language/data-sources). 312 313 As with conventional modules, we suggest using this technique only when the 314 module raises the level of abstraction in some way, in this case by 315 encapsulating exactly how the data is retrieved. 316 317 A common use of this technique is when a system has been decomposed into several 318 subsystem configurations but there is certain infrastructure that is shared 319 across all of the subsystems, such as a common IP network. In this situation, 320 we might write a shared module called `join-network-aws` which can be called 321 by any configuration that needs information about the shared network when 322 deployed in AWS: 323 324 ``` 325 module "network" { 326 source = "./modules/join-network-aws" 327 328 environment = "production" 329 } 330 331 module "k8s_cluster" { 332 source = "./modules/aws-k8s-cluster" 333 334 subnet_ids = module.network.aws_subnet_ids 335 } 336 ``` 337 338 The `network` module itself could retrieve this data in a number of different 339 ways: it could query the AWS API directly using 340 [`aws_vpc`](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/vpc) 341 and 342 [`aws_subnet_ids`](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/subnet_ids) 343 data sources, or it could read saved information from a Consul cluster using 344 [`consul_keys`](https://registry.terraform.io/providers/hashicorp/consul/latest/docs/data-sources/keys), 345 or it might read the outputs directly from the state of the configuration that 346 manages the network using 347 [`terraform_remote_state`](/language/state/remote-state-data). 348 349 The key benefit of this approach is that the source of this information can 350 change over time without updating every configuration that depends on it. 351 Furthermore, if you design your data-only module with a similar set of outputs 352 as a corresponding management module, you can swap between the two relatively 353 easily when refactoring.