Contents

Dotnet Aspire - Deploying custom Azure resources

Over the last year or so of using Dotnet Aspire I’ve had a lot of successes, including getting other teams to also adopt Aspire within their solutions. When you stick to the well trodden path a lot of things just work, e.g.:

  • Creating local instances of things like SQL Server and RabbitMQ
  • Service discovery between projects (a bit of magic involved 🧙‍♂️)
  • OpenTelemetry traces

However, things start to get a bit more complicated when you want to veer off this well trodden path and start pushing the boundaries of what’s possible. In this post I’m going to show how to deploy a custom Azure resource, and especially how to blend these custom resources with other Aspire resources.

Azure Health Data Services - FHIR Service

A bit of a mouthful, but this is the Azure hosted FHIR Service which is often used in healthcare applications. Aspire has lots of great integrations with Azure that work out of the box, there isn’t one for Health Data Services (which isn’t all that surprising given it isn’t widely used) however Aspire provides plenty of extensibility points.

One of the underrated features of the Azure Health Data Services FHIR Service is that it can be configured to raise events when resources are created, updated and deleted 1, which was the main reason that I chose to use Health Data Services.

Using .AddBicepTemplate() to deploy Health Data Services

The main extensibility point that Aspire has to deploy custom resources is .AddBicepTemplate()2

For example,

1
2
3
var builder = DistributedApplication.CreateBuilder(args);

builder.AddBicepTemplate(name, "Bicep/azurehealthfhirserver.bicep");

This will deploy the Bicep template azurehealthfhirserver.bicep

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
@description('The location for the resource(s) to be deployed.')
param location string = resourceGroup().location

@description('The name of the Health Data Services workspace to be deployed.')
param name string

resource workspace 'Microsoft.HealthcareApis/workspaces@2024-03-31' = {
  name: workspaceName
  location: location
  properties: {
    publicNetworkAccess: 'Enabled'
  }
}

resource fhirServer 'Microsoft.HealthcareApis/workspaces/fhirservices@2024-03-31' = {
  parent: workspace
  name: fhirServerName
  location: location
  kind: 'fhir-R4'
  identity: { type: 'None' }
  properties: {
    authenticationConfiguration: {
      authority: authority
      audience: audience
      smartIdentityProviders: []
      smartProxyEnabled: false
    }
    publicNetworkAccess: 'Enabled'
  }
}

To make this a bit more useful we can pass through parameters, e.g.

1
2
3
4
var builder = DistributedApplication.CreateBuilder(args);

builder.AddBicepTemplate("myhealthdataservice", "Bicep/azurehealthfhirserver.bicep")
    .WithParameter("name", "myhealthdataservice");

This will pass through "myhealthdataservice" as the value for name which is a parameter in the Bicep template

1
2
@description('The name of the Health Data Services workspace to be deployed.')
param name string

Making things a bit neater

We can make this a little bit neater by extracting .AddBicepTemplate() into an extension method

1
2
3
4
5
public static IResourceBuilder<AzureBicepResource> AddHealthDataFhirServer(this IDistributedApplicationBuilder builder, string name)
{
    return builder.AddBicepTemplate(name, "Bicep/azurehealthfhirserver.bicep")
        .WithParameter("name", name);
}

Which can then be used as follows

1
2
3
var builder = DistributedApplication.CreateBuilder(args);

builder.AddHealthDataFhirServer("myhealthdataservice");

This looks a lot cleaner.

Adding a storage account

As mentioned above, I want to setup Event Grid subscriptions. To do that I need to connect my FHIR Service to Event Grid and then Event Grid to a queue in a storage account.

To do this will require a queue to be created in a storage account during infrastructure deployment.

Adding a storage account is easy

1
2
3
4
var builder = DistributedApplication.CreateBuilder(args);

var storageAccount = builder.AddAzureStorage("mystorageacct");
var fhirServer = builder.AddHealthDataFhirServer("myazurehealth");

So far we’ve not strayed too far off the well trodden Aspire path, using .AddBicepTemplate() is a little niche but not particularly complicated…yet.

Where things get interesting…

Above we have our Health Data Services workspace being deployed, and a FHIR service being created as well as a storage account. Now we need a queue to be created during infrastructure provisioning.

In theory the following is possible with Aspire

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
var storageAccount = builder.AddAzureStorage("mystorageacct")
  .ConfigureInfrastructure(infra =>
  {
      // we want a queue created as part of the infrastruture
      var storageAccount = infra.GetProvisionableResources().OfType<Azure.Provisioning.Storage.StorageAccount>().Single();

      var queueService = new QueueService("qs");
      queueService.Parent = storageAccount;
      infra.Add(queueService);

      var myqueue = new StorageQueue("myqueue");
      myqueue.Name = "myqueue";
      myqueue.Parent = queueService;
      infra.Add(myqueue);
  });

The above snippet makes use of Azure.Provisioning.Storage, which is how Aspire under the hoods turns an Aspire project into deployable Bicep. I said in theory it’s possible, up until recently there was a bug in this library which has been fixed however as of writing there hasn’t been a new version of the Provisioning library published - so the above will still fail.

The alternative is to handle queue creation in our Bicep template

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
@description('The name of the storage account where FHIR Server events should be sent to.')
param storageAccountName string = ''

@description('The name of the storage account queue that should be sent events.')
param storageAccountQueueName string = 'fhir'

resource storageAccount 'Microsoft.Storage/storageAccounts@2024-01-01' existing = {
  name: storageAccountName
}

resource qs 'Microsoft.Storage/storageAccounts/queueServices@2024-01-01' = {
  name: 'default'
  parent: storageAccount
}

resource fhirQueue 'Microsoft.Storage/storageAccounts/queueServices/queues@2024-01-01' = {
  name: storageAccountQueueName
  parent: qs
}

Where things get REALLY interesting…

If you had only glanced over the above Bicep template you would have missed a really important detail. The Bicep template now expects the name of the Storage Account.

This is trickier than it sounds, due to the way that Aspire provisions infrastructure.

/dotnet-aspire-deploying-custom-azure-resources/images/02-resource-group.png

When Aspire provisions resources they get a unique string appended to the end, this prevents clashes with someone else who might also be using Aspire to run cloud services. Without the unique string the two users would collide. It took me a lot of time, reading various GitHub issues, looking through the Aspire source code etc, trying out lots of different things to end up with the following additional 2 lines of code 🤯

Update 2025-05-19: thanks to David Fowler it turns out that I had overcomplicated the way to get the storage account name. My original implementation looked like this

1
2
3
4
5
6
7
8
public static IResourceBuilder<AzureBicepResource> AddHealthDataFhirServer(this IDistributedApplicationBuilder builder, string name, IResourceBuilder<AzureStorageResource> storage)
{
    var storageAccountName = ReferenceExpression.Create($"{storage.GetOutput("name")}");

    return builder.AddBicepTemplate(name, "Bicep/azurehealthfhirserver.bicep")
        .WithParameter("storageAccountName", () => storageAccountName)
        .WithParameter("name", name);
}

but it turns out to be much simpler

1
2
3
4
5
6
public static IResourceBuilder<AzureBicepResource> AddHealthDataFhirServer(this IDistributedApplicationBuilder builder, string name, IResourceBuilder<AzureStorageResource> storage)
{
    return builder.AddBicepTemplate(name, "Bicep/azurehealthfhirserver.bicep")
        .WithParameter("storageAccountName", storage.GetOutput("name"))
        .WithParameter("name", name);
}

The ReferenceExpression is not needed in this situation.

Creating an Event Grid subscription

So we’ve navigated bugs in the provisioning library and pushed a bit more into our Bicep template, a queue is now being created. The last stage of this journey was to setup the Event Grid subscriptions.

All of this is neatly handled in the Bicep template

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// Create event grid system topic and subscription for FhirResource events
resource fhirEventsSystemTopic 'Microsoft.EventGrid/systemTopics@2025-02-15' = {
  name: 'fhir-service-events'
  location: 'uksouth'
  properties: {
    source: workspace.id
    topicType: 'Microsoft.HealthcareApis.Workspaces'
  }
}

resource eventSubscription 'Microsoft.EventGrid/systemTopics/eventSubscriptions@2025-02-15' = {
  parent: fhirEventsSystemTopic
  name: 'test1'
  properties: {
    destination: {
      properties: {
        resourceId: storageAccount.id
        queueName: storageAccountQueueName
        queueMessageTimeToLiveInSeconds: 604800
      }
      endpointType: 'StorageQueue'
    }
    filter: {
      includedEventTypes: [
        'Microsoft.HealthcareApis.FhirResourceCreated'
        'Microsoft.HealthcareApis.FhirResourceUpdated'
        'Microsoft.HealthcareApis.FhirResourceDeleted'
      ]
      enableAdvancedFilteringOnArrays: true
    }
    labels: []
    eventDeliverySchema: 'CloudEventSchemaV1_0'
    retryPolicy: {
      maxDeliveryAttempts: 30
      eventTimeToLiveInMinutes: 1440
    }
  }
}

Wrapping up

Dotnet Aspire is incredibly powerful, extremely useful and (most of the time) incredibly easy to use. When you want to start going off the well trodden path it becomes difficult. Figuring things out requires reading source code (Which thankfully is available on GitHub, yay for open-source 🎉), or tweeting David Fowler (thankfully he’s been very responsive).

I think over time these APIs will become better documented, and easier to use. However, I’m not complaining, Aspire has saved me (and my team) tons of time over the last year, and has provided the best local developer experience I have ever had and I suspect it’s only going to get better.

Additional Resources

All source code for the above post is available on GitHub - https://github.com/kzhen/aspire-custom-infrastructure-bicep-example/ - the Bicep isn’t perfect, e.g. as I was writing this I’ve realised the event grid subscription name needs a unique-string appended to the end to prevent collisions.

🍪 I use Disqus for comments

Because Disqus requires cookies this site doesn't automatically load comments.

I don't mind about cookies - Show me the comments from now on (and set a cookie to remember my preference)