IaC Module Anti-Patterns: Don’t Create Resource Wrapper Modules
While converting an OpenAI Azure example from Bicep to Terraform, I came across a recurring Infrastructure-as-Code (IaC) anti-pattern that’s worth calling out: creating wrapper modules for single resources that don’t actually abstract or simplify anything. This pattern unnecessarily increases code complexity and makes it harder to reason about the deployment without delivering meaningful value in return.
This issue became particularly clear when looking at a module from the Azure Open AI Demo project, which uses Bicep.
https://github.com/Azure-Samples/azure-search-openai-demo
The Problem: A Wrapper Module That Adds Little Value
In the OpenAI sample application, there’s a Bicep module defined at core/security/role.bicep, which is instantiated 21 times throughout the codebase. Each instantiation looks something like this:
module openAiRoleUser 'core/security/role.bicep' = if (isAzureOpenAiHost && deployAzureOpenAi) {
scope: openAiResourceGroup
name: 'openai-role-user'
params: {
principalId: principalId
roleDefinitionId: '5e0bd9bd-7b93-4f28-af87-19fc36ad61bd'
principalType: principalType
}
}
This is the classic case of a wrapper module that does little more than restate the same 7–8 lines of code needed to declare the resource directly. Looking inside the module reveals that it simply defines a role assignment:
metadata description = 'Creates a role assignment for a service principal.'
param principalId string
@allowed([
'Device'
'ForeignGroup'
'Group'
'ServicePrincipal'
'User'
])
param principalType string = 'ServicePrincipal'
param roleDefinitionId string
resource role 'Microsoft.Authorization/roleAssignments@2022-04-01' = {
name: guid(subscription().id, resourceGroup().id, principalId, roleDefinitionId)
properties: {
principalId: principalId
principalType: principalType
roleDefinitionId: resourceId('Microsoft.Authorization/roleDefinitions', roleDefinitionId)
}
}
What is this module doing? What value does it add?
This module repeats the 7–8 lines of code it takes to instantiate the module.
The module is adding the following functionality:
- Adds Validation around the principalType parameter.
- Generates a Random Name for the Role Definition using the current Azure context (i.e. Subscription, Resource Group) and the Principal / Role Definition combination.
- Builds the the roleDefinitionId by hard coding the Microsoft.Authorization/roleDefinitions prefix into a call to the resourceId function.
Because Bicep does not have the ability to create abstractions inside the provider like Terraform does with the azurerm Terraform provider, the unnecessary complexities can only be abstracted inside modules. It takes the same code as the actual instantiation of the module but it does abstract some complexities that would otherwise be copy pasta.
What would we have to copy pasta?
Building the name:
name: guid(subscription().id, resourceGroup().id, principalId, roleDefinitionId)
Building the Role Definition ID:
resourceId('Microsoft.Authorization/roleDefinitions', roleDefinitionId)
These are both extremely low value lines of code and do not justify encapsulation into their own module.
False Abstractions and Their Hidden Cost
The rationale for creating modules is usually to simplify, abstract complexity, reduce duplication, or enforce consistency. But in this case, none of that is really happening. Instead, this wrapper introduces unnecessary indirection. Developers reading the code now have to follow an extra file just to understand something that could have been declared inline. That indirection adds cognitive overhead without any real benefit.
Is it worth it to create a new module to avoid this? I don’t think so. It seems like these two things could just as easily be embedded in a resource definition.
Consider the direct, inline equivalent of this resource:
resource cognitiveServicesRoleUser 'Microsoft.Authorization/roleAssignments@2022-04-01' = {
name: guid(subscription().id, resourceGroup().id, principalId, roleDefinitionId)
properties: {
principalId: principalId
principalType: principalType
roleDefinitionId: resourceId('Microsoft.Authorization/roleDefinitions', 'a97b65f3-24c7-4388-baec-2e87135dc908')
}
}
This inline version does the exact same thing as the module but without making the reader hop between files. It also avoids the illusion that we’ve created a reusable abstraction when we really haven’t. The inline code is more transparent, easier to read, and arguably no more verbose.
Does it reduce the code? Not really, we still have 21 instances of this resource but it does eliminate the unnecessary cyclomatic complexity of the false encapsulation happening inside the module. In order for the developer to underrstand what is happening they have to navigate to a new source code file and analyze it. Whereas if we skip the creation of a module altogether we spare them that extra mental hop.
Terraform vs. Bicep: The Context Matters
One could argue that the lack of built-in provider-level abstractions in Bicep (compared to Terraform) encourages this pattern. Terraform providers often encapsulate many small conveniences, so you rarely feel the need to wrap individual resources just to simplify their syntax. Bicep doesn’t offer that same flexibility, so developers may fall back on modules as the only means of avoiding repeated expressions.
Even so, that doesn’t justify wrapping trivial logic in a separate module. In Bicep, you’re not saving lines of code — you’re just moving them around. In doing so, you’re adding surface area to your codebase and creating more places where bugs or misunderstandings can hide.
When Is a Module Worth It?
Modules make sense when they:
- Encapsulate multiple resources into a coherent unit
- Represent reusable infrastructure patterns
- Accept flexible parameters and output complex values
- Are referenced across different projects or environments
None of that applies to the role.bicep module in this example. The only thing it does is obfuscate a simple role assignment behind a thin wrapper. That’s not abstraction; it’s indirection.
Conclusion
Creating a module should feel like extracting a useful, composable unit of infrastructure logic — not like hiding a single declarative resource behind a curtain. If a module’s implementation is essentially the same length and complexity as its invocation, it’s not buying you anything. In fact, it’s costing you in maintainability and readability.
As a rule of thumb, if you need to navigate into the module to understand what it’s doing, and it’s only doing what you expected, you probably didn’t need the module in the first place.
Skip the unnecessary wrapper. Keep your code flat, transparent, and as simple as the tools allow.