User Defined Types in Action

After spending a good deal of time working in depth with User Defined Types at the end of 2023, I want to expand my previous post on the basics of User Defined Types. User Defined Types provide a great new capability as we write Bicep code; it is also a feature that is hard to define it's value until you see it in action. In this post I will walk through a Bicep template I designed to help deploy Azure SQL Databases. This template takes advantage of multiple advanced features and techniques that will bring better understanding and speed to your deployments.

Template Scope overview

Before diving into the Bicep template, I want to explain the design of our Bicep deployment. There are 2 components to our strategy, modules and templates.

  • Modules - Generalized resource that contain the logic to successfully deploy the resource
  • Templates - Contain the business and security requirements for a deployment

Modules are generalized Bicep files that target an Azure resource (e.g Azure SQL, Azure Virtual Machine, Virtual Network). These modules are designed to allow for deployment in multiple configurations without altering the module. These are based on the Azure Resource Modules project. Utilizing a generalized module allows us to abstract away logic and minimize the complexity of the template design by teams.

Here is an example of logic that would be part of module. The Bicep below checks if the template calling this module has passed any Identity properties.

1var formattedUserAssignedIdentities = reduce(map((managedIdentities.?userAssignedResourcesIds ?? []), (id) => { '${id}': {} }), {}, (cur, next) => union(cur, next)) // Converts the flat array to an object like { '${id1}': {}, '${id2}': {} }
2
3var identity = !empty(managedIdentities) ? {
4  type: (managedIdentities.?systemAssigned ?? false) ? (!empty(managedIdentities.?userAssignedResourcesIds ?? {}) ? 'SystemAssigned,UserAssigned' : 'SystemAssigned') : (!empty(managedIdentities.?userAssignedResourcesIds ?? {}) ? 'UserAssigned' : null)
5  userAssignedIdentities: !empty(formattedUserAssignedIdentities) ? formattedUserAssignedIdentities : null
6} : null

This abstraction provides several benefits:

  • Template designers are not required to develop complex logic to manage parameter and variable input.
  • Template parameters are focused on deployment specific information.
  • Template development speed can be increased.

The goal of this strategy is to create templates that require minimal complexity to meet the business requirements for specific deployment scenarios. For example, we have projects that are allowed to host resources with public access, while other projects that have access to on-premise resources are not allowed to have publicly facing resources. The template for public access deployment would have options for public access while the second template would instead require options to ensure traffic is not publicly available.

Here is snippet of a template that is used to ensure deployments are not publicly available:

 1module sqlServer '../../modules/sql/server/main.bicep' = {
 2  name: '${sqlServerName}-dp'
 3  params: {
 4    name: sqlServerName
 5    administratorLogin: sqlServerConfiguration.administratorLogin
 6    administratorLoginPassword: sqlServerConfiguration.administratorLoginPassword
 7    administrators: sqlServerConfiguration.administrators
 8    managedIdentities: sqlServerConfiguration.managedIdentitiesType
 9    location: location
10    lock: sqlServerConfiguration.?lock ?? null
11    publicNetworkAccess: 'Disabled'
12    restrictOutboundNetworkAccess: 'Disabled'
13    enableDefaultTelemetry: false
14    minimalTlsVersion: '1.2'

Templates can meet security and business requirements by limiting the property options available for the user. With this template a user would not be able to enable public network access through misconfiguration of a parameter file. While it is possible for someone to modify the template directly during a PR, the expectation is that this change will be caught during the PR review. If it was missed during the review, then Azure Policy would block the deployment in our environment. Having layers of validation is crucial to ensuring properly configured resources in your deployments.

Template components

Let's review the main components of our template.

Our template is organized into sections:

  • User Defined Types
  • Parameters
  • Variables
  • Resources
  • Modules
  • Outputs

Defining a good structure for your templates can ensure consistent development across multiple teams. Defining structure, formatting, and naming standards allow for faster development and review. I highly recommend developing a style guide for the creation of templates and modules. For this post we will be focusing on the User Defined Types in our template.

User Defined Types

Our template utilizes several User Defined Types to improve usability and discovery of valid parameter values. These types are imported from several files using the compileTimeImports feature:

1import {
2  azureEnvironmentType
3  sqlDatabaseSettingType
4  sqlServerSettingType
5  privateEndpointSubnetType
6  convertGigabytesToBytes
7} from './types/types.bicep'

User Defined Types can grow to be quite large. We can move our User Defined Types to separate Bicep files that represent different parts of our template logic. To ensure our main template file does not become difficult to navigate, I have broken out the logic into several files. 1 for the core SQL server, database, and deployment specific types, and 3 for the SQL sku options for the SQL Database. We will learn more about those types later in this post. First lets look at the core User Defined Types.

Azure Environment Type

The azureEnvironmentType contains information specific to our environments and deployments. It provides static information that can be used to dynamically create or complete resources. The export() decorator makes the type available for import from another Bicep file. The sealed() decorator prevents modification of the property values to avoid alterations in the parameter file.

 1@description('Azure environment specific configuration settings')
 2@export()
 3@sealed()
 4type azureEnvironmentType = {
 5  @description('Azure US Government specific settings')
 6  AzureUSGovernment: {
 7    @description('Azure Gov Event Hub ResourceIds')
 8    eventHubName: {
 9      prod: '<resourceId>'
10      qa: '<resourceId>'
11      dev: '<resourceId>'
12    }
13    @description('Azure Gov supported regions')
14    region: {
15      usgovvirginia: 'ugv'
16      usgovtexas: 'ugt'
17    }
18    @description('Azure Gov Log Analytics ResourceIds')
19    workspace: {
20      prod:'<resourceId>'
21      qa: '<resourceId>'
22      dev: '<resourceId>'
23    }
24    @description('Azure Global Private DNS Zones')
25    privateEndpointDns: {
26      sql: '<resourceId>'
27    }
28  }?
29  @description('Azure Global specific settings')
30  AzureCloud: {
31    @description('Azure Global Event Hub ResourceIds')
32    eventHubName: {
33      prod: '<resourceId>'
34      qa: '<resourceId>'
35      dev: '<resourceId>'
36    }
37    @description('Azure Global supported regions')
38    region: {
39      eastus: 'eus'
40      westus: 'wus'
41    }
42    @description('Azure Global Log Analytics ResourceIds')
43    workspace: {
44      prod: '<resourceId>'
45      qa: '<resourceId>'
46      dev: '<resourceId>'
47    }
48    @description('Azure ECM Global Private DNS Zones')
49    privateEndpointDns: {
50      #disable-next-line no-hardcoded-env-urls
51      sql: '<resourceId>'
52    }
53  }?
54}

While this information could also be stored in a variable, providing it as a type allows us to add helpful descriptions for consumers of the template. Providing a re-usable type that can be used to retrieve environment static values ensures consistent resource deployments.

Here is an example of dynamically creating the SQL Server name based on environment and region information found in the azureEnvironmentType:

1var sqlServerName = 'sql-${projectName}-${deploymentEnvironment}-${azureEnvironment![environment().name].region[location]}'

Another example of accessing the private endpoint DNS zone Resource Id found in the hub network subscription:

1privateEndpoints: [
2      {
3        subnetResourceId: privateEndpointSubnet.id
4        privateDnsZoneResourceIds: [
5          azureEnvironment![environment().name].privateEndpointDns.sql
6        ]
7      }
8    ]

sqlServerSettingType

This Type contains all the parameters needed by our template for the deployment of our SQL Server resource. As I mentioned before, the template focuses on the features of SQL Server for a specific business case. Ensuring each property has a detailed description helps consumers understand the parameter and the potential values available to them. Our goal is to allow consumers to create and complete a new parameter file without reading additional documentation.

 1@export()
 2type sqlServerSettingType = {
 3  @description('Optional. Admin user name for SQl server')
 4  administratorLogin: string?
 5  @secure()
 6  @description('Admin Password for SQL Server')
 7  administratorLoginPassword: string?
 8  @description(
 9    'The Azure Active Directory administrator of the server. This can only be used at server create time. If used for server update, it will be ignored or it will result in an error. For updates individual APIs will need to be used.'
10  )
11  administrators: {
12    @description('\'ActiveDirectory\' is currently the only Type available')
13    administratorType: 'ActiveDirectory'
14    @description('Azure Active Directory only Authentication enabled.')
15    azureADOnlyAuthentication: true | false
16    @description('Login name of the server administrator.')
17    login: string
18    @description('Principal Type of the sever administrator')
19    principalType: 'Application' | 'Group' | 'User'
20    @description('SID (object ID) of the server administrator.')
21    sid: string
22    @description('Tenant ID of the administrator. AzGov | AzGlobal)
23    tenantId: 'AzGovTenantId' | 'AzGlobalTenantId'
24  }?
25  lock: lockType
26  managedIdentitiesType: {
27    @description('Optional. Enables system assigned managed identity on the resource.')
28    systemAssigned: bool?
29    @description('Optional. The resource ID(s) to assign to the resource.')
30    userAssignedResourcesIds: string[]?
31  }?
32}
33
34@export()
35@description('Optional. The type of lock to be applied to the resource')
36type lockType = {
37  @description('Optional. Specify the name of lock.')
38  name: string?
39
40  @description('Optional. Specify the type of lock.')
41  kind: ('CanNotDelete' | 'ReadOnly' | 'None')?
42}?

sqlDatabaseSettingType

This Type defines all the required properties to successfully deploy databases to the SQL server.

 1@export()
 2@description('Configuration settings for SQL Database Deployment')
 3type sqlDatabaseSettingType = {
 4  @description('The name of the database')
 5  name: string
 6  @description('The name of the SKU, this value is filtered based on the SKU Tier')
 7  skuOptions: sqlDatabaseSkuOptions
 8  @description('The default database collation is SQL_Latin1_General_CP1_CI_AS')
 9  collation: 'SQL_Latin1_General_CP1_CI_AS'?
10  @description('Diagnostic Setting options')
11  diagnosticSettings: diagnosticSettingType
12  @description('Optional. Whether or not this database is a ledger database, which means all tables in the database are ledger tables. Note: the value of this property cannot be changed after the database has been created.')
13  isLedgerOn: bool?
14  @description('The license type to apply for this database.')
15  licenseType: string?
16  @description('Defines the period when the maintenance updates will occur.')
17  maintenanceConfigurationId: 'window_5pm_8am_daily' | 'window_10pm_6am_EST_Monday_Thursday' | 'windows_10pm_6am_EST_Friday_Sunday'
18  @description('Optional. The name of the sample schema to apply when creating this database.')
19  sampleName: string?
20  tags: object?
21  @description('The Elastic Pools to create with the server.')
22  elasticPoolId: string?
23  enableDefaultTelemetry: false
24  @description(
25    'Optional. The storage account type to be used to store backups for this database.'
26  )
27  requestedBackupStorageRedundancy: 'Local' | 'Zone' | ''
28  @description('Optional. The short term backup retention policy to create for the database.')
29  backupShortTermRetentionPolicy: object?
30  @description('Optional. The long term backup retention policy to create for the database.')
31  backupLongTermRetentionPolicy: object?
32  @description('''
33  Specifies the mode of database creation.
34  Default: regular database creation.
35  Copy: creates a database as a copy of an existing database. sourceDatabaseId must be specified as the resource ID of the source database.
36  Secondary: creates a database as a secondary replica of an existing database. sourceDatabaseId must be specified as the resource ID of the existing primary database.
37  PointInTimeRestore: Creates a database by restoring a point in time backup of an existing database. sourceDatabaseId must be specified as the resource ID of the existing database, and restorePointInTime must be specified.
38  Recovery: Creates a database by restoring a geo-replicated backup. sourceDatabaseId must be specified as the recoverable database resource ID to restore.
39  Restore: Creates a database by restoring a backup of a deleted database. sourceDatabaseId must be specified. If sourceDatabaseId is the database's original resource ID, then sourceDatabaseDeletionDate must be specified. Otherwise sourceDatabaseId must be the restorable dropped database resource ID and sourceDatabaseDeletionDate is ignored. restorePointInTime may also be specified to restore from an earlier point in time.
40  RestoreLongTermRetentionBackup: Creates a database by restoring from a long term retention vault. recoveryServicesRecoveryPointResourceId must be specified as the recovery point resource ID.
41  Copy, Secondary, and RestoreLongTermRetentionBackup are not supported for DataWarehouse edition.'
42  ''')
43  createMode: 'Default'
44
45  @description('The resource identifier of the source database associated with create operation of this database.')
46  sourceDatabaseResourceId: string?
47  @description('Specifies the time that the database was deleted.')
48  sourceDatabaseDeletionDate: string?
49  @description('The resource identifier of the recovery point associated with create operation of this database.')
50  recoveryServicesRecoveryPointResourceId: string?
51  @description('Specifies the point in time (ISO8601 format) of the source database that will be restored to create the new database.')
52  restorePointInTime: string?
53}[]

Again good descriptions and simplified options are a great benefit to the consumers of the template. A good example of this is the maintenanceConfigurationId property. The value required by the SQL resource is unique resource Id per region ('Microsoft.Maintenance/publicMaintenanceConfigurations/SQL_eastus_DB_1'). The resourceId does not provide meaningful information for the user to determine what value they should choose. To assist the user, we provide them more descriptive options by having them select the name of the maintenance windows that each resourceId represents.

1@description('Defines the period when the maintenance updates will occur.')
2maintenanceConfigurationId: 'window_5pm_8am_daily' | 'window_10pm_6am_EST_Monday_Thursday' | 'windows_10pm_6am_EST_Friday_Sunday'

In the template, the maintenance window names and resource Ids are part of variable object.

 1var maintenanceConfigurationId = {
 2  window_5pm_8am_daily: subscriptionResourceId(
 3    'Microsoft.Maintenance/publicMaintenanceConfigurations',
 4    'SQL_Default'
 5  )
 6  window_10pm_6am_EST_Monday_Thursday: subscriptionResourceId(
 7    'Microsoft.Maintenance/publicMaintenanceConfigurations',
 8    'SQL_${location}_DB_1'
 9  )
10  window_10pm_6am_EST_Friday_Sunday: subscriptionResourceId(
11    'Microsoft.Maintenance/publicMaintenanceConfigurations',
12    'SQL_${location}_DB_1'
13  )
14}

We can then pass the proper value for the maintenance window using the variable and parameter value provided by the user.

1maintenanceConfigurationId[database.maintenanceConfigurationId]

Providing descriptive and meaningful options will minimize questions and confusion when using your template!

sqlDatabaseSkuOptions

Now we get into the complex portion of our template. This type was designed to help dynamically provide users the required properties based on the chosen sku for SQL Database. Generally you provide sku information for SQL Databases through properties such as:

  • skuName
  • skuSize
  • skuCapacity
  • skuFamily

These values align with various tiers of Azure SQL DB:

  • DTU
    • Basic
    • Standard
    • Premium
  • vCore
    • General Purpose Provisioned
    • General Purpose Serverless

Each of these tiers has multiple sku's and potentially different features based on your sku choice. Previously, there was no way to organize the potential options due to the complexity and quantity of sku's available. However, with User Defined Types and discriminated unions, we can bring structure to the chaotic assortment of sku's and properties.

Below is the top level User Defined Type, sqlDatabaseSkuOptions used in the sqlDatabaseSettingType to determine what properties are required for a SQL Database based on the sku chosen.

1import { sqlDatabaseSkuBasic, sqlDatabaseSkuStandard, sqlDatabaseSkuPremium } from 'DtuType.bicep'
2import { sqlDatabaseSkuGeneralPurposeProvisioned } from 'generalPurposeProvisionedType.bicep'
3import { sqlDatabaseSkuGeneralPurposeServerless} from 'generalPurposeServerlessType.bicep'
4
5// SQL SKUs Type
6@sealed()
7@discriminator('type')
8type sqlDatabaseSkuOptions = sqlDatabaseSkuBasic | sqlDatabaseSkuStandard
9 | sqlDatabaseSkuPremium | sqlDatabaseSkuGeneralPurposeProvisioned | sqlDatabaseSkuGeneralPurposeServerless

While this type appears simple, there is a great deal of logic and configuration hidden under the covers. The sqlDatabaseSkuOptions type is a composition of 5 User Defined Types:

  • sqlDatabaseSkuBasic
  • sqlDatabaseSkuStandard
  • sqlDatabaseSkuPremium
  • sqlDatabaseSkuGeneralPurposeProvisioned
  • sqlDatabaseSkuGeneralPurposeServerless

This is accomplished using the @discriminator() decorator. User Defined Types can be combined using a common property found in each type. From the Bicep documentation:

The discriminator decorator takes a single parameter, which represents a shared property name among all union members. This property name must be a required string literal on all members and is case-sensitive. The values of the discriminated property on the union members must be unique in a case-insensitive manner.

Each of our 5 sku types above have a type property that is unique to that sku. This is the first filter used to to choose our sku. Let's take a look at at the sqlDatabaseSkuStandard type:

1@export()
2type sqlDatabaseSkuStandard = {
3  type: 'Standard'
4  skuTier: 'Standard'
5  @description('The Standard SKU of the SQL Server you want to deploy.')
6  sku: standardDtuType
7}

For most SQL Database sku's, there are 2 required properties, skuTier and skuName. For all standard sku's, the skuTier value is Standard. The SkuName has 9 options available, one for each (Standard Service Tier). Along with skuName, there is also a databaseMaxSize property that is needed for each standard sku with. To further complicate our decision, skus can have different maximum database sizes. So how do we handle all these sku options? Discriminated Unions to the rescue! We use dtu property as the key for the standardDtuType discriminated type:

1// Unioned Type for Standard Skus
2@discriminator('dtu')
3type standardDtuType = standardDTU10 | standardDTU20 | standardDTU50 | standardDTU100 | standardDTU200
4 | standardDTU400 | standardDTU800 | standardDTU1600 | standardDTU3000

Each sku has different capacities such as DTU (Database Transaction Unit) and potential database sizes. Below is one of the sku types used in the standardDtuType:

 1type standardDTU10 = {
 2  @description('database transaction unit (DTU)')
 3  dtu: '10'
 4  @description(
 5    'NOTE: Standard S0, S1 and S2 tiers provide less than one vCore (CPU). For resource-intensive workloads, a service tier of S3 or greater is recommended. '
 6  )
 7  skuName: 'S0'
 8  @maxValue(250)
 9  @minValue(1)
10  @description(
11    'Max size for 10 DTUs is 250 GB. Data Storage for S0 uses HDD based storage media best suited for development and testing.'
12  )
13  databaseMaxSize: 1 | 2 | 5 | 10 | 20 | 30 | 40 | 50 | 100 | 150 | 200 | 250
14}

The dtu property is used as the discriminated property in our standardDtuType to combine all of our standard sku options. dtu was chosen as it best represented the deciding factor most consumers would use, the performance level of the sku. The dtu value is used as friendly type to help provide the user with a better understanding of the sku options. The skuName property is the actual value passed to our SQL module to choose the correct sku. DatabaseMaxSize is the value passed for the storage capacity of the sku. Standard sku's also include specific step increases for the database size. By providing the user with this information from our template, choosing a sku results in all the required parameters being present. Here is an example of the intellisense provided in a Bicep parameter file using our SQL template. The user is provided meaningful information to determine what sku and required properties are needed for their deployment without needing to leave the parameter file.

While this improves the user experience for database sku selection, there some considerations with this approach.

Pros

  • Users have convenient access to most required properties to choose a sku.
  • Only valid options are presented to the user.
  • Option values that are sku specific are only displayed for the appropriate sku.

Cons:

  • Sku's availability is region specific, some sku's may not be available in all regions.
  • Testing all sku options can be difficult to automate as part of a CI/CD pipeline.
  • For more complex or unique DB deployments, additional guidance may still be needed for Azure Documentation.

Let's map out our design so far!

Our sqlDatabaseSkuOptions type contains the sku tiers defined in 5 types. Each sku tier type contains all the available skus for that tier. Those individual skus will then contain the required properties for deploying that sku.

This strategy, while complex, provides a huge benefit to consumers of our template. Ensuring users can only choose the required and valid options for their sku increases the success of our template deployment. There is no need to go read additional documentation or question which properties are needed for your chosen sku. By dividing the SQL Database sku tier's into User Defined Types, we are able to create nested types that contain the sku specific options that are only presented when the user decides.

To drive the point home, lets look at the GeneralPurposeProvisioned Type:

 1@export()
 2type sqlDatabaseSkuGeneralPurposeProvisioned = {
 3  type: 'GeneralPurposeProvisioned'
 4  skuTier: 'GeneralPurpose'
 5  @description(
 6    'The General Purpose Provisioned Tier of the SQL Server you want to deploy.'
 7  )
 8  sku: generalPurposeProvisionedType
 9}
10@discriminator('vCores')
11type generalPurposeProvisionedType = GP_Gen5_2 | GP_Gen5_4 | GP_Gen5_6 | GP_Gen5_8 | GP_Gen5_10
12 | GP_Gen5_12 | GP_Gen5_14 | GP_Gen5_16 | GP_Gen5_18 | GP_Gen5_20 | GP_Gen5_24
13 | GP_Gen5_32 | GP_Gen5_40 | GP_Gen5_80
14
15@sealed()
16type GP_Gen5_2 = {
17  @description('General Purpose Provisioned Tier SKU Name')
18  skuName: 'GP_Gen5_2'
19  @maxValue(1024)
20  @minValue(1)
21  @description('Max data storage size is 1024 GB')
22  databaseMaxSize: int
23  @description('2 vCores assigned with SKU GP_Gen5_2 ')
24  vCores: '2'
25}

Instead of using dtu, the generalPurposeProvisionedType discriminated union uses vCores for it's joining type. This helps the user in deciding which sku to choose based on the performance needed for their deployment (vCores being one of the biggest factors). Each sku type has 3 properties:

  • skuName
  • databaseMaxSize
  • vCores

Both skuName and databaseMaxSize are needed for the deployment of the SQL DB, whereas vCores is only used to provide a user readable convention for choosing the sku they need. GP_Gen5_2, while being the required value for deployment, does not provide a good description for most users. You can also see in this case that the GP_Gen5_2 sku allows any value for databaseMaxSize instead of set size limits like the Standard sku we looked at previously. Each User Defined Type for each sku tier is slightly different to handle the specific requirements of that tier. Please continue to investigate the template on my GitHub to see the different configurations of the other SQL sku tiers.

Conclusion

User Defined Types represent a major change in how we can author Bicep templates and modules. Having the ability to create complex logical types to provide our template easy to digest parameters is a huge win. Removing some of the guesswork and confusing parameter requirements from our template and module deployments will go a long way in increasing user adoption. I hope this post has helped you understand the potential of this new feature. Happy Coding!