The Cosmos DB Module: Where the Module Call Meets Raw Resources
The catalog's data-tier entry draws an unusually good boundary - the module owns the account and its security posture, raw resources own the data model - and its commented-out option menu doubles as accidental documentation.
Almost every stack in the estate I worked on obeyed the same rule: main.tf contains exactly one module call and zero resource blocks. The Cosmos DB stacks are the interesting exception. The deployed copies mix a module call — the account, with all its security plumbing — with plain, hand-written azurerm_cosmosdb_* resources for the databases and collections inside it. That mix looks like a rule violation, and I want to argue it's actually the most correctly drawn boundary in the catalog.
This is part 5 of the Terraform Module Catalog, companion to the main Terraform on Azure series; the lookup-map chassis is in main-series Part 2, and this template turns out to be the ancestor the storage wrappers of Part 4 were copied from — the fossils prove it.
A different tier, literally
The first thing the template's locals reveal is topology. Where the app modules read ..._spoke_app_resource_group_name from the platform JSON, this one reads the db family:
resource_group_list = {
dev = try(local.json_data.dev_spoke_db_resource_group_name, null)
}
key_vault_name_list = {
dev = try(local.json_data.dev_spoke_db_key_vault_name, null)
}
key_vault_key_name_list = {
dev = try(local.json_data.dev_spoke_db_key_vault_cmk_key_name, null)
}
Every spoke carries a separate data-tier resource group with its own Key Vault and its own customer-managed key, distinct from the app tier's. Databases get different operators, different access policies, different key-rotation stories than web apps — so the platform JSON encodes the split, and the module inherits it by reading different keys. The CMK inputs aren't optional hardening: like the storage wrappers, the account comes up encrypted with a platform-owned key or it doesn't come up.
The account surface itself is compact: server_name, offer_type, kind, a capabilities list, tags, diagnostics via the standard data-source pattern — and two private-endpoint inputs where other modules have one:
# Mongo flavour # SQL (Core) flavour
kind = "MongoDB" # "GlobalDocumentDB"
private_endpoint_types = ["mongo"] # ["documents"]
private_endpoint_subresource = "MongoDB" # "Sql"
One input drives the private DNS zone wiring, the other the endpoint's subresource — Cosmos exposes a different API plane per kind, and the endpoint must match the flavour. The estate had both flavours deployed as twin folders, and the twins differ in almost nothing but those three lines: same chassis, same CMK, same diagnostics, different API dialect.
The commented-out menu
The template's main.tf carries something none of the other templates have: a block of commented-out inputs that reads like a menu.
#Additional custom Cosmos DB properties
#enable_multiple_write_locations = local.enable_multiple_write_locations
#analytical_storage_enabled = local.analytical_storage_enabled
#consistency_policy_level = local.consistency_policy_level
#consistency_policy_max_interval_in_seconds = local.consistency_policy_max_interval_in_seconds
#consistency_policy_max_staleness_prefix = local.consistency_policy_max_staleness_prefix
...
#retention_days = local.retention_days
#log_categories = local.log_categories
This is accidental documentation, and it's worth naming as a pattern because half the module ecosystems I've seen do it. The underlying InfraModules code supports multi-region writes, analytical storage, tunable consistency, diagnostic category filtering — but the template's job is the golden path, so the advanced knobs ship disarmed. The comments tell you the module's full surface without making you read the module.
As documentation it has real virtues: it's exactly where you're looking when you need it, and uncommenting a line is a smaller step than discovering an input exists. But it rots exactly like documentation, because it is documentation — the compiler never checks a comment. Nothing guarantees those referenced locals or module inputs still exist at the tag you've pinned; the menu can drift from the kitchen. If I were maintaining this library today I'd promote the menu to real inputs with sensible defaults and let terraform-docs generate the reference — the README's empty “Inputs / No input.” doc-hook block shows the generator was this close to being wired up. But I'd keep the spirit: a template should advertise what its module can do, not just what the golden path uses.
The boundary: module for the account, resources for the data model
Now the exception that earns its keep. The deployed Mongo stack adds a second .tf file next to main.tf:
resource "azurerm_cosmosdb_mongo_database" "orders" {
name = "orders_db"
resource_group_name = local.resource_group
account_name = module.cosmosdb.cosmosdb_name
}
resource "azurerm_cosmosdb_mongo_collection" "quotes" {
name = "order_quotes"
database_name = azurerm_cosmosdb_mongo_database.orders.name
account_name = module.cosmosdb.cosmosdb_name
shard_key = "id"
throughput = 400
default_ttl_seconds = "0"
}
Its SQL-API twin does the same with azurerm_cosmosdb_sql_database and a fistful of containers, each declaring its partition key path and provisioned throughput. Note the seam: the raw resources consume module.cosmosdb.cosmosdb_name — one of the module's very few outputs, in a library that mostly has none.
Why is this the right boundary? Because the two halves change for different reasons and belong to different owners. The account is platform-shaped: networking, encryption, API kind, diagnostics — it changes when the platform's posture changes, and it's the same for every team, which is what modules are for. The data model is application-shaped: collection names, shard keys, TTLs, RU budgets — it changes when the application changes, it's different for every service, and forcing it through a generic module would mean inventing a collections = [ ... ] object schema that reimplements the provider's own resources, worse. The estate's engineers didn't write a design doc about this; they just put the platform-shaped part in the module and the app-shaped part in raw HCL, and the result is the most readable stack in the estate. One-module-call purity is a good default, not a commandment — break it exactly where ownership breaks.
The fossils
Honesty section. This template carries the same congenital defect family as its storage descendants — the dev private-link key copy-pasted into the staging and prod rows of the template itself (dev_spoke_vnet_subnet_privatelink_name in all three slots), and I found it survived into deployed production stacks, dutifully copied by teams who trusted the plumbing. The template's locals also still contain a comment announcing “properties specific to this particular storage account” — the copy lineage showing through the paint. And one deployed twin's tag map carries both a Lifecycle and an Env tag that disagree with each other (Lifecycle = "staging", Env = "qc") — two environment vocabularies from two eras of the estate, fossilised into the same map row, a reminder that every tag you add is a value you must now keep consistent forever.
None of these broke anything loudly. All of them are the same species: values that dev-only testing can never falsify. By now — five modules in — the pattern should feel familiar: the estate's bugs don't live in the resources, they live in the rows of the maps nobody's workspace ever selects.
That closes the data tier's front door. Next the catalog turns to messaging: Part 6, Event Hubs — maps of hubs, authorization rules as booleans, and the only template in the library that dared to customise the provider's features block.