1.2.6.1. Local Sub-modules
We mentioned in Local Sub-modules vs Splitting .tf Code Files that there are two scenarios where we can create sub-modules within a module:
- A resource (or group of resources) intended to be declared in multiple places within the module
- Sub-modules intended to be published as independent modules with reference addresses
We will discuss both scenarios in this chapter.
1.2.6.1.1. Resources Intended for Multiple Declarations Within a Module
Sometimes we choose local sub-modules for DRY (Don't Repeat Yourself) principles and maintainability. For example, in the avm-res-compute-virtualmachine module, not only is an Azure virtual machine defined, but the module also allows callers to define various extensions installed on the virtual machine (azurerm_virtual_machine_extension). However, different extensions only differ in fields like publisher, type, and settings. If we wrote dozens of nearly identical resource blocks directly in the root module, the code would become bloated and difficult to maintain. By extracting them into a local sub-module, we only need to put the common logic in modules/extension, then call the unified template multiple times in main.extensions.tf with for_each to instantiate each extension. This keeps the code readable and makes it easy to make centralized changes later.
The second point: explicit dependencies (depends_on). azurerm_virtual_machine_extension must wait until the virtual machine is fully available before execution, otherwise extension hanging or initialization failures may occur. Although the extension block references virtual_machine_id and Terraform can implicitly infer the "create VM first, install extensions later" order, when there are other resources alongside the VM that also modify VM properties (e.g., first add a managed identity to the VM, then deploy extensions that depend on the identity), implicit dependencies are often insufficient. Therefore, in each module "extension" call block, the author explicitly wrote something like:
depends_on = [
azurerm_linux_virtual_machine.this,
azurerm_windows_virtual_machine.this,
module.run_command
]
This ensures that:
- Extensions will only trigger after VM resources are completed;
- When a VM is replaced or Run Command changes, extensions will also be redeployed accordingly;
- Avoids deployment order confusion caused by "hidden dependencies" that the Azure RM Provider cannot identify.
Additionally, if the dependency relationship requires that a group of related resources must all be created before the dependent resource can begin creation, rather than writing complex dependent resource addresses in depends_on, it's better to encapsulate this group of related resources as a module. When an address in depends_on is a module, Terraform will only trigger the creation of resources that depend on that module after all resources in the module are completely created. This helps make our code concise and understandable, while being easy to maintain (adding resources to the sub-module doesn't require modifying depends_on in the root module).
The official Terraform documentation clearly states that depends_on is used for "hidden dependencies that Terraform cannot automatically infer," and since version 0.13, it can directly apply to module blocks. So this approach is both standard and necessary. Without such explicit dependencies, certain post-installation steps that require system extensions to be installed first (such as custom scripts or GC policies) might encounter race conditions, leading to the awkward situation of terraform apply failures.
1.2.6.1.2. Sub-modules Intended to Be Published as Independent Modules
Sometimes our reusable modules only expose various configurable options for a certain type of cloud resource, but to use that resource correctly, different scenarios often require corresponding best practice configurations. Leaving module callers to figure out these best practice configurations on their own is inconvenient and cannot ensure that users with different skill levels can write correct and complete configurations. In this case, we can choose to create a set of local sub-modules that encapsulate calls to the root module along with pre-configured parameters representing best practices for different scenarios.
A concrete example is network security groups (azurerm_network_security_group). Network security groups are similar to firewalls, defining access rules in virtual networks, generally composed of protocol, source IP, source port, destination IP, destination port, allow/deny action, and rule priority. Different services use different rules, mostly differing in destination ports and protocols. We can pre-configure corresponding common rule parameters for various services and save them as sub-modules under modules:
.
├── ActiveDirectory
├── Cassandra
├── Cassandra-JMX
├── Cassandra-Thrift
├── CouchDB
├── CouchDB-HTTPS
├── DNS-TCP
├── DNS-UDP
├── DynamicPorts
├── ElasticSearch
├── FTP
├── HTTP
├── HTTPS
├── IMAP
├── IMAPS
├── Kestrel
├── LDAP
├── MSSQL
├── Memcached
├── MongoDB
├── MySQL
├── Neo4J
├── POP3
├── POP3S
├── PostgreSQL
├── RDP
├── RabbitMQ
├── Redis
├── Riak
├── Riak-JMX
├── SMTP
├── SMTPS
├── SSH
├── WinRM
└── _template
Although these types of sub-modules are located in the module repository, they can be referenced as independent modules using double-slash syntax. For example, you can write source = "xxxx//modules/rules" in Terraform configuration to reference the modules/rules subdirectory alone. Each sub-module generally only encapsulates one typical usage pattern or best practice configuration, keeping the module focused on a single function. This way, callers can import only the needed functional subset without understanding the implementation details of the complete module.