Skip to content
Kumar Chandrachooda
Azure & DevOps

The Cognitive Search Module and the Accidental Required Field

A search template that ships with "TBD" where partition and replica counts belong - so it cannot apply until someone makes a sizing decision - plus a three-way diagnostics choice documented entirely in commented-out code. Part 9 of the Terraform Module Catalog.

By Kumar Chandrachooda 18 Mar 2026 5 min read
A template that refuses to apply until you decide

Every template library has a module that teaches by accident. In this catalog it's Cognitive Search (Azure AI Search, as it's now badged): a compact, conventional wrapper that happens to contain two of the most interesting artifacts in the whole library — a placeholder that functions as an enforcement mechanism, and a diagnostics menu written entirely in comments.

This is Part 9 of the Terraform Module Catalog, companion to the main Terraform on Azure series; the shared chassis is covered there, so we can go straight to the search service.

The surface: three numbers and a key ring

A search service's shape is governed by three values, and the wrapper exposes exactly those:

sku_list             = { dev = "standard", staging = "standard", prod = "standard" }
partition_count_list = { dev = 1, staging = 1, prod = 3 }
replica_count_list   = { dev = 1, staging = 2, prod = 3 }

The comments in the real file link straight to the service-limits page, which is the correct instinct, because these are not interchangeable knobs: partitions buy storage and indexing throughput, replicas buy query throughput and availability. They also multiply: billing is in search units, partitions × replicas, so the innocent-looking jump from 1 × 1 in dev to 3 × 3 in prod is a nine-fold cost change hiding in two one-line diffs. Of every service in this catalog, Search is the one whose two integers deserve the most deliberate review. The arithmetic that matters is written nowhere in Azure's portal UI but should be in every README: roughly, three replicas is the floor for a highly-available read workload, and the SLA only exists at two or more. A team that copies dev's 1 into prod hasn't under-sized their search — they've opted out of its availability guarantee. (Also note the lowercase "standard" — Search is the one Azure service in this catalog whose SKU names are case-sensitively lowercase, a paper cut the template quietly spares its users.)

The other service-specific input is a map of query keys:

query_keys_list = {
  dev = {
    web-frontend = { key_name = "qk-web-frontend" }
    reporting    = { key_name = "qk-reporting" }
  }
  staging = {}
  prod    = {}
}

Query keys are read-only credentials, and minting a named one per consumer is quiet good practice: when the reporting tool is decommissioned, you revoke qk-reporting and nobody else notices. One shared query key is how search services end up with credentials nobody dares rotate. (The template as found had dev's map paired with [] in staging and prod — the same empty-tuple-where-a-map-belongs trap dissected in Part 6; by this point in the catalog it qualifies as the library's signature defect.)

Around that core, the standard secure posture: private-link subnet from platform JSON, Key Vault integration, diagnostics to the platform storage account, and — shared with the Event Hubs template — the customized provider features block that refuses to purge soft-deleted Key Vault material on destroy.

Gem one: the placeholder that cannot apply

Here is the template's partition map as it actually shipped:

partition_count_list = {
  dev     = "TBD"
  staging = "TBD"
  prod    = "TBD"
}
replica_count_list = {
  dev     = "TBD"
  staging = "TBD"
  prod    = "TBD"
}

A string, in all three environments, where the resource demands a number. This template cannot be applied as copied — not in dev, not anywhere. The first plan fails until a human replaces "TBD" with a decision.

As a bug: it's sloppy. The failure arrives as a type-conversion error deep in the module, naming neither the file nor the intent; a newcomer loses an hour learning that TBD was a message addressed to them. Worse, the mechanism is accidental — it only “works” because a string can't convert to a number. The sibling maps that put "TBD" in string-typed slots sail through plan and apply, and become resources named TBD.

As a feature: it's the most effective governance in the library. Every other value in every other template has a copyable dev default, and the estate's history shows exactly what that produces — dev values photocopied into prod at scale. The two values here are the two a team genuinely must not inherit: search capacity is workload-shaped, and replicas are an availability contract. The placeholder converts “please read the sizing docs” from a README plea into a plan-time gate. It is accidental required-field enforcement — and the library's Application Gateway template does the same thing on purpose, shipping an intentionally invalid IP address so an unfilled copy can't silently apply (that story in Part 11).

The synthesis I'd ship today: keep the gate, make it honest. Declare the module input as number with a validation block (partition_count >= 1, and an error message that says why it has no default). Same enforcement, delivered at the boundary, with the explanation attached to the failure instead of buried in a lost README.

Gem two: the diagnostics menu written in comments

The template's data.tf is three data sources — one live, two commented out:

data "azurerm_storage_account" "diagnostics" {
  name                = local.diag_settings_destination_name
  resource_group_name = local.diag_settings_destination_rg
}

# data "azurerm_log_analytics_workspace" "diagnostics" {
#   name                = local.diag_settings_destination_name
#   resource_group_name = local.diag_settings_destination_rg
# }

# data "azurerm_eventhub" "diagnostics" {
#   name                = local.diag_settings_destination_name
#   resource_group_name = local.diag_settings_destination_rg
#   namespace_name      = local.eventhub_namespace_name
# }

with a matching commented-out eventhub_namespace_name_list waiting in the locals, and main.tf passing whichever ID is live into logs_destinations_ids. The three-way choice is real and worth making deliberately: storage is the cheap archive (compliance retention, rarely queried), Log Analytics is the queryable one (you want KQL over search latencies and throttling the day an index misbehaves), Event Hub is the export ramp to an external SIEM. The template defaults to the cheapest and documents the other two as uncommentable alternatives.

Is commented-out code as documentation acceptable? In a shared module, no — it rots invisibly, and I'd reach for a destination-type variable and dynamic blocks. But these templates are copied, not shared: each team owns its copy, and a comment you uncomment is discoverable in a way a variable three repos away is not. The estate's Cosmos DB template (Part 5) used the same idiom for its advanced-knobs menu. I've made peace with it in template-shaped code on one condition the library didn't meet: the comment must say when you'd choose each option, not just repeat what the code would do. The three sentences earlier in this section are the missing comment.

The distinctive lesson, then: defaults are policy. Where this template gave a value, teams inherited it forever; where it refused, teams decided. A template author's real power is choosing which questions the copier is forced to answer. Next in the catalog: the wrapper that answered the most questions and broke the most conventions doing it — Part 10, AKS.