In today’s cloud-native landscape, building scalable and maintainable multi-service applications is both a challenge and an opportunity. Azure Container Apps has quickly become one of my favorite services for running these types of workloads. Its seamless integration with tools like KEDA and Dapr takes much of the operational complexity out of the equation, enabling developers to focus on building value.
Among these, Dapr often feels like the unsung hero. It’s an incredibly powerful yet underestimated tool for solving common distributed systems challenges, from state management to pub/sub communication. In this post, I’ll dive into how Dapr can simplify pub/sub patterns in a multi-service environment, leveraging the capabilities of Azure Container Apps to deliver an elegant, scalable solution.
Don’t use the term Microservices
The term “microservices” has always felt a bit limiting to me, as it implies strict constraints around size and adherence to specific architectural principles. Instead, I prefer to think in terms of “multi-service environments”. This broader term better captures the reality of modern applications, where services may vary in size and complexity but share a cohesive relationship through dependencies or intercommunication. By shifting the conversation to multi-service environments, we can focus less on rigid definitions and more on the collaboration and integration that truly drive these architectures.
Having said that, let’s dive into the goodness.
Dapr
Distributed Application Runtime of Dapr provides a set of APIs for several kinds of building blocks you as a programmer would like to take advantage of when working in a multi-service environment. The Dapr APIs are configured to use (cloud native or not) underlying technologies that under the hood actually provide the service you’re looking for.
So for example, if you want to store the state of an object, you can call the SaveStateAsync()
function of your Dapr client. Now when Dapr is configured to use (for example) Redis Cache, to store state values, the result of calling the SaveStateAsync()
method is a value stored in Redis Cache. Alternatively, when Dapr is configured to use Azure Cosmos DB, the value would end up there, without having to change a single line of code.
One of the biggest benefits now is that Dapr can be used for local development and configured to use technologies hosted on your local development environment. But when you bring your system into production, you can take advantage of more robust and highly scalable systems. Also, Dapr provides resiliency out of the box. You can take control of resiliency policies, but the defaults are already pretty good, so no worries about retries because Dapr will take care of that.
Apis everywhere
In a multi-service environment, several APIs work in unison to deliver a cohesive software system, with a reverse proxy like Azure API Management serving as the entry point for external consumers. For instance, a Users service might allow users to register and update their profiles, while another service collects user-specific data to generate detailed reports. Despite their interdependence at the system level, these services are designed to run independently to ensure resilience and scalability. Each service maintains its own repository, encapsulating its data and logic, which minimizes coupling and simplifies maintenance. Both services are deployed as Azure Container Apps within the same Azure Container Apps environment, leveraging the platform’s capabilities to ensure efficient resource utilization, streamlined scaling, and secure inter-service communication. But there is more!
Because both services are hosted in the same Azure Container Apps environment, we can now take advantage of Dapr to notify other services in case an event happens somewhere in the system. For example, let’s say a user updates their profile. The reporting service must be notified to make sure the updated information appears on newly generated reports. This is where inter-communication between these services needs to take place and there are several ways to do so.
Scenario A - Service invocation
What you can do, is to make a a direct call from the Users service to the Reporting service that will update the user profile information. Dapr can do this with an API called Service Invocation so you still take advantage of the built-in resiliency policies so all fine there. However, there are a couple of downsides:
- Dependencies - In a multi-service environment, you want to avoid direct dependencies to other services. By making a direct request to a different service, you implicitly introduce a direct dependency on that service.
- Flexibility - What if a third service is introduced, that also relies on updated profile information. You now need to change the code in the Users service to make an additional request to the third service in order to also update profile information in that service. In reality you want services to be able to come and go.
- Chatty services - What if the Reporting service in turn, also makes requests to other services? You now invoke a chain reaction of services calling each other with too many dependencies making the services too chatty. In reality, you want to limit the direct communication between services to the bare minimum.
Scenario B - Publish & Subscribe
Another scenario is to take advantage of Dapr’s Publish & Subscribe (Pub/Sub) API. With pub/sub, one service publishes a message, that other services can subscribe to. So in the example above, let’s say we define a message that contains the updated user profile information:
public record UserProfileUpdatedEventData(Guid UserProfileId, string DisplayName, string EmailAddress);
And every time a user profile is updated in the Users service, the Users service publishes the message above, other services can subscribe to that message if it is a newspaper. Now when that message is published, it will be delivered to all subscribers. Other services can subscribe to the message when they come, and unsubscribe when they go, so there is not a single dependency between these services. If there is one downside to the pub/sub scenario, it would be that there is an additional service required to handle the pub/sub-messages. As explained above, Dapr can be configured to use several underlying technologies, for pub/sub this can be Redis, Kafka, and RabbitMQ, but also cloud-native services like Azure Service Bus, AWS SNS/SQS or GCP Pub/Sub (view a full list of supported underlying services).
Publish & Subscribe with ASP.NET APIs and Dapr
Now obviously, Scenario B is way more elegant and flexible to work with, so let’s set up pub/sub! If you’re completely new to Dapr, you can use this post to get up and running with Dapr and how to configure Dapr for Azure Container Apps. For my local development, I need to have a Dapr pub/sub component. Since Redis is already running in a container (that comes with Dapr), I can create a new component to do Pub/Sub for me, and configure it to use the Redis container:
apiVersion: dapr.io/v1alpha1
kind: Component
metadata:
name: demo-pubsub
spec:
type: pubsub.redis
version: v1
metadata:
- name: redisHost
value: localhost:6379
- name: redisPassword
value: ""
The yaml file above configures a pub/sub-component with the name demo-pubsub
and uses the Redis container as an underlying mechanism to do the actual pubsub for us.
Now create an API and make sure to take a dependency on the Dapr.AspNetCore
NuGet package. Then in the startup of your API, call services.AddDaprClient()
to configure dependency injection for your Dapr client.
As of .NET 9, you can use .NET Aspire to fire up your services and configure Dapr for you. .NET Aspire is not new, but really matured over the course of the last year and I really recommend you look into it, but it is outside the scope of this blog post and planned for a future post
Now your API project is ready and configured to use Dapr. Because you configured the pub/sub Dapr component, you can immediately use the Pub/Sub API in Dapr.
Publishing messages
Now comes the tricky part. In the example above, you see a message with a fairly simple payload, a Guid, and two strings for both the name and email of a user profile. But, these payloads will change over time. For example, your user profile may also support uploading a profile picture and now you want to also distribute the URL to that profile picture in your message payload. Versioning is key here. Because all your services run independently, you should also keep in mind that when you publish a new version of a certain message, the consumer may now (yet) have been updated and so is not capable of consuming the latest version of your message. Thus… try to make newer versions of messages backward compatible, implement a versioning mechanism for your messages, and keep in mind not all services consume the latest and greatest.
For my convenience, I always create a separate project that contains all messages sent from a service (or multiple services) and share that through a NuGet package. Consumers now only have to update that package to be able to consume a newer version.
Now to publish a message using Dapr, you must inject the DaprClient
and call the PublishEventAsync()
method to send the message:
public class UsersProfileService(DaprClient client) {
private async Task RaiseUserProfileChangedEvent(
IUserProfile userProfile,
CancellationToken cancellationToken)
{
var eventPayload = new UserProfileUpdatedEventData(
userProfile.Id,
userProfile.DisplayName,
userProfile.EmailAddress);
await daprClient.PublishEventAsync(
"demo-pubsub",
"users-profile-updated"
eventPayload,
cancellationToken: cancellationToken);
}
}
The code above is a service where the DaprClient
gets injected. When the function RaiseUserProfileChangedEvent()
is called, it creates the message payload using the UserProfileUpdatedEventData
record and publishes that payload as a message to the demo-pubsub
Dapr component, to a topic called users-profile-updated
.
Subscribing to messages
When you create an ASP.NET API, I think subscribing to the messages with Dapr is one of the most elegant ways. You only have to create an endpoint somewhere and decorate that endpoint with the Topic
attribute. If that attribute is there, Dapr will create an HTTP POST request to that endpoint, with the message payload in the body of the request. But, first things first, you need to configure Dapr so it will look for endpoints decorated with the Topic attribute and create the appropriate subscriptions for you. This is a single line in the startup of your app:
app.MapSubscribeHandler();
There you go, upon startup, Dapr will now go ahead and find endpoints that have this Topic
attribute, and create a subscription for each and every on of them.
Now to handle the message published above, I created an ASP.NET MVC Api, with controllers, added the Dapr.AspNetCore
package, and added Dapr with Dependency Injection (services.AddDaprClient()
) and called the MapSubscriptionHandler()
function described above. Now all that is left to handle the message, is to create an HTTP Post endpoint:
[HttpPost("UserProfileUpdatedEventHandler")]
[Topic("demo-pubsub", "users-profile-updated")]
public async Task<IActionResult> UserProfileUpdatedEventHandler(UserProfileUpdatedEventData payload)
{
logger.LogInformation($"Handling event for user profile updated {payload.UserProfileId}");
// TODO : Handle the message and do scary stuff
return Ok();
}
Upon startup, Dapr will call an endpoint on your API to actually create the subscription. This can take up to a couple of seconds after your service started. Once the subscription is there, whenever a message is published to the topic, your HTTP endpoint is called and you can handle the message.
This is called a programmatic subscription, the one I prefer. But if you’d like a different flavor, here is where you can find all different types of subscriptions Dapr supports.
Note that the endpoint returns a 200 OK
response. This is important to indicate whether or not handling the message succeeded. It is important to return an error code in case the message fails to indicate a failure. This causes Dapr to deliver the message again at a later time. Returning a successful HTTP status code will cause Dapr to mark the message as handled successfully, so it will not be delivered again.
Last modified on 2025-01-02