Enhance the readability of Azure Bicep templates with these tips

In this blog post, I share my insights on writing readable and maintainable Azure Bicep templates. After several years of working with Azure Bicep templates, I have learned that as with any other programming language, clarity and readability are key for maintainability.

This blog post includes the following insights:

  • Choose a consistent code and naming convention
  • Define a clear template structure and use formatting
  • Avoid untyped objects or arrays
  • Avoid complex operations within definitions

Choose a consistent code and naming convention

If you have been programming, you might have adapted your code to a naming convention. For example, in C#, methods are written in PascalCase and variables are written in camelCase. The same principles can be applied to Bicep templates. I believe that having a naming convention for your Bicep template is really important, as this will greatly improve the readability of your Infrastructure-as-Code.
Currently, as far as I know, there is no naming convention ā€œmarketā€ standard for Bicep (yet) like you see for PowerShell or C#.

Notation

If we look back at the Azure Resource Manager (ARM) templates in the JSON format, it was required to use [parameter(...)], [variable(...)] and so on. This looks similar to the Hungarian Notation. This notation adds a prefix to a parameter or variable. In Bicep, this would look something like parKeyVaultName (for a parameter) or varFormattedSubnet (for a variable). Essentially, it creates a clear distinction between where the value comes from.

I have worked with large Bicep templates (over 1,000 lines), and having a clear distinction between modules, resources, parameters, variables, outputs, functions, and types has saved me a lot of time. Let’s look at two examples. In the templates below, you see the definition of Azure Key Vault with and without the Hungarian notation. Keep in mind that these examples are very small and serve only as illustrations; imagine applying this to a larger, more complex template.

Without Hungarian notation

In the snippet below, you see the version without the Hungarian notation. In this snippet it’s difficult to distinguish where each value originates from and if you look at the output value keyVault it is unclear if it’s either a resource, variable or a parameter holding key vault properties.

param name string
param tenantId string = tenant().tenantId
var location = 'westeurope'
var sku = 'standard'
resource keyVault 'Microsoft.KeyVault/vaults@2023-07-01' = {
name: name
location: location
properties: {
sku: {
name: sku
family: 'A'
}
tenantId: tenantId
}
}
output keyVault object = keyVault

With Hungarian notation

In the snippet below, the Hungarian notation is applied. At a glance, it is clear where values are coming from, and the output clearly indicates that it uses the output of the deployed resource.

param parName string
param parTenantId string = tenant().tenantId
var varLocation = 'westeurope'
var varSku = 'standard'
resource resKeyVault 'Microsoft.KeyVault/vaults@2023-07-01' = {
name: parName
location: varLocation
properties: {
sku: {
name: varSku
family: 'A'
}
tenantId: parTenantId
}
}
output outKeyVault object = resKeyVault

If you are interested in using the Hungarian notation, then I recommend the following prefixes for the different components:

ComponentPrefix
Modulemod
Resourceres
Variablevar
Parameterpar
Outputout
Functionfunc
Typetype

Note! This is just one of many ways Bicep templates can be written. If you or your team already have a naming convention that works for you, that’s perfectly fine too!

Define a clear template structure and use formatting

Bicep is a declarative language, which means that any component (resource, module, type etc.) can be defined at any place in the template. Also, it does not matter in which order the components are placed.

To avoid chaos, I keep a strict structure for my templates. This makes the templates predictable, easy to navigate and in the end easier to read. Additionally, having a formatted Bicep template enhances navigation and readability. For this, I use the built-in formatter in the Bicep extension which is available for Visual Studio Code.

Below is the structure I like to use:

////////////
// Imports
////////////
////////////
// metadata
////////////
////////////
// targetScope (optional)
////////////
////////////
// Parameters
////////////
////////////
// Variables
////////////
////////////
// Resources / Modules
////////////
////////////
// Outputs
////////////
////////////
// Custom Types
////////////
////////////
// Functions
////////////

Avoid untyped objects or arrays

It is not bad to have untyped objects or arrays; before the introduction of User-Defined Types we didn’t even know better. However, since this feature has been released, I have been a big fan of typing every object or array within my templates. By giving these a type, you give a meaning or a value to objects or arrays.

Knowing what an object or array contains enhances the readability of your Bicep templates greatly, and additionally, typing helps you to enforce what an object or array can or must contain. As an added advantage, you can also make use of IntelliSense for auto-completion in your Bicep template.

Below, you see a Bicep snippet where a virtual network is defined. This virtual network contains a parameterised subnets property called parSubnets and this parameter holds the type array. The downside here is that parSubnets can contain anything you want, but it requires specific values in order to successfully deploy the subnets.

param parSubnets array
resource resVirtualNetwork 'Microsoft.Network/virtualNetworks@2024-01-01' = {
name: 'virtualNetworkName'
location: 'westeurope'
properties: {
addressSpace: {
addressPrefixes: [
'192.168.1.0/24'
]
}
subnets: parSubnets
}
}

Below is the same snippet, but now including the User-Defined Type. It shows in the Bicep template what is expected in the ā€œarray of objectsā€ namely an object including the properties name and addressPrefix and there is an optional property named delegation due to the addition of ? behind the type.

param parSubnets subnetType
resource resVirtualNetwork 'Microsoft.Network/virtualNetworks@2024-01-01' = {
name: 'virtualNetworkName'
location: 'westeurope'
properties: {
addressSpace: {
addressPrefixes: [
'192.168.1.0/24'
]
}
subnets: parSubnets
}
}
type subnetType = {
name: string
properties: {
addressPrefix: string
delegation: string?
}
}[]

An added advantage is that it lets you hover over the parameter to see what it must, and can contain including IntelliSense support:

Feedback is shown when hovering over the parameter
Autocompletion window

Besides User-Defined Types, you can also use built-in types like string and int:

param parArrayOfStrings string[] // Enforces the array to only contain strings
param parArrayOfInts int[] // Enforced the array to only contain integers

Avoid complex operations within definitions

Lastly, avoid performing complex operations within your resource or module definitions. A complex operation can involve the manipulation of an objectstring, or array, or calling a user-defined function with many input parameters.

To increase readability, it is better practice to place these complex operations in a Bicep variable. This way, the resource or module definitions only contain references to either parameters or variables.

In bicepparam parameter files, the case is different. These files contain only parameter definitions, not resource or module definitions. Since variables are supported in bicepparam files, you can do the same thing here. Define your variables to handle the complex operations. Then refer to the variables in the parameter.

In the Bicep snippet below, you see the creation of a route table and a virtual network. This snippet also includes a user-defined function and type. All complex operations, like generating deployment names or formatting the subnet array to a specific format requested by the external Azure Verified Module, are done within the variables.

param parSubnet typeSubnetConfig = [{
name: 'subnet-name'
addressPrefix: '192.168.1.0/24'
routeTableName: 'route-table-name'
}]
var varDeploymentNames = {
routeTableDeploymentName: funcGenerateDeploymentName('rt', 'route-table-var-demo')
virtualNetworkDeploymentName: funcGenerateDeploymentName('vnet', 'virtual-network-var-demo')
}
var varFormattedSubnets = map(parSubnet, subnet => {
name: subnet.name
addressPrefix: subnet.addressPrefix
routeTableResourceId: resourceId('Microsoft.Network/routeTables', subnet.routeTableName)
})
module modRouteTable 'br/public:avm/res/network/route-table:0.3.0' = {
name: varDeploymentNames.routeTableDeploymentName
params: {
name: 'route-table-name'
}
}
module modVirtualNetwork 'br/public:avm/res/network/virtual-network:0.2.0' = {
name: varDeploymentNames.virtualNetworkDeploymentName
dependsOn: [
modRouteTable
]
params: {
name: 'virtual-network-name'
addressPrefixes: [
'192.168.1.0/24'
]
subnets: varFormattedSubnets
}
}
func funcGenerateDeploymentName(resourceAbbreviation string, name string) string => 'deploy-module-${resourceAbbreviation}-${uniqueString(resourceAbbreviation, name)}'
type typeSubnetConfig = {
name: string
addressPrefix: string
routeTableName: string
}[]

Conclusion

In conclusion, writing readable Azure Bicep templates are essential for maintainable infrastructure. By adopting consistent coding conventions, implementing a clear structure, leveraging User-Defined Types, and moving complex operations to variables, you can enhance the readability of your Bicep templates.

These practices not only make your templates more accessible to others, but also reduce errors. Invest time to improve the readability of your Bicep templates and it will pay off in the long run with more robust and maintainable Azure Bicep templates!

Leave a comment