Scaling .NET Applications: Implementing CQRS and Multi-Host PostgreSQL with Marten and Wolverine

Scaling .NET Applications: Implementing CQRS and Multi-Host PostgreSQL with Marten and Wolverine

In high-traffic environments, a single database instance is a bottleneck and a single point of failure. While the CQRS (Command Query Responsibility Segregation) pattern provides the logical blueprint for scaling, Multi-Host PostgreSQL provides the physical infrastructure to make it a reality.

By leveraging Npgsql's native multi-host support alongside Marten and Wolverine, we can build applications that automatically distribute traffic between Primary and Standby nodes, ensuring that "Write" performance is protected from "Read" heavy operations.

Tech Stack

  • Runtime: .NET 10, C# 14
  • Orchestration: .NET Aspire 13.1.0
  • Web Framework: ASP.NET Core with WolverineFx.Http 5.9.2
  • Persistence: Marten 8.17.0
  • Driver: Npgsql 10.0.1

1. The Multi-Host Connection String: The Engine Room

The secret to this architecture is the Multi-Host connection string. Unlike traditional strings, Npgsql allows you to list multiple endpoints. The driver then manages the connection state, health checks, and routing.

Host=host-primary,host-replica-1,host-replica-2;Port=5432;Database=myappdb;Username=postgres;Password=...;

When the application requests a connection, Npgsql uses the TargetSessionAttributes to decide which host to pick:

  • Primary: Connects to the node currently accepting writes.
  • PreferStandby: Connects to a read-only replica, but falls back to the primary if replicas are down.

2. Simulating the Cluster with .NET Aspire

In production, you have a physical cluster. For local development, we want to test our multi-host logic without the overhead of three running containers. We use .NET Aspire to simulate this by repeating the host address in the connection string.

// AppHost.cs
var postgres = builder.AddPostgres("postgres")
    .WithImage("postgres:17")
    .WithDataVolume("myapp_data_volume");

var db = postgres.AddDatabase("db", "myappdb");

builder.AddProject<Projects.MyProject_WebApi>("web-api")
    .WithReference(db)
    // We pass the SAME host address twice: Host=localhost,localhost;
    // This forces the app logic to treat it as a multi-host cluster
    .WithEnvironment("ConnectionStrings__Database", 
        ReferenceExpression.Create($"Host={postgres.Resource.PrimaryEndpoint.Property(EndpointProperty.Host)},{postgres.Resource.PrimaryEndpoint.Property(EndpointProperty.Host)};Port={postgres.Resource.PrimaryEndpoint.Property(EndpointProperty.Port)};Database={db.Resource.DatabaseName};Username=postgres;Password={postgres.Resource.PasswordParameter}"))
    .WaitFor(db);

3. Smart Routing in Service Configuration

To actually utilize the multi-host setup, we register two separate NpgsqlDataSource instances. One is configured for the "Write" role (Primary), and the other is configured for the "Read" role (Standby).

// Program.cs
var connectionString = builder.Configuration.GetConnectionString("Database");

// Create the shared MultiHost source
var multiHostSource = new NpgsqlDataSourceBuilder(connectionString).BuildMultiHost();

// Write Source: Default behavior targets the Primary node
builder.Services.AddSingleton<NpgsqlDataSource>(multiHostSource);

// Read Source: Keyed service targeting Standby/Read Replicas
builder.Services.AddKeyedSingleton<NpgsqlDataSource>("ReadSource", 
    multiHostSource.WithTargetSession(TargetSessionAttributes.PreferStandby));

4. Integrating with Marten and Wolverine

Marten is a document store and event store for PostgreSQL. we configure it to use these distinct sources by defining a Main Store (Writes) and a Read Store (Queries).

// Main Store for Writes
builder.Services.AddMarten(opts => { 
        opts.DatabaseSchemaName = "public";
    })
    .UseNpgsqlDataSource() // Uses default (Primary) source
    .IntegrateWithWolverine();

// Specialized Store for Reads
builder.Services.AddMartenStore<IReadStore>(opts => {
    // Replicas are read-only; schema changes are forbidden here
    opts.AutoCreateSchemaObjects = AutoCreate.None; 
});

// Configure the Read Store with the Keyed ReadSource
builder.Services.AddTransient<IConfigureMarten<IReadStore>>(sp => {
    var readSource = sp.GetRequiredKeyedService<NpgsqlDataSource>("ReadSource");
    return new ConfigureReadStore(readSource);
});

5. Seamless Multi-Host Handlers

Because we mapped IQuerySession to our IReadStore, developers don't need to worry about connection strings. Wolverine handlers simply ask for the session they need.

The Write Path (Command)

Inject IDocumentSession. Traffic goes to host-primary.

[WolverinePost("/items")]
public static async Task Handle(CreateItemCommand cmd, IDocumentSession session)
{
    session.Store(new MyItem(cmd.Name));
    await session.SaveChangesAsync(); // Writes to Primary
}

The Read Path (Query)

Inject IQuerySession. Traffic goes to host-replica-1 or host-replica-2.

[WolverineGet("/items/{id}")]
public static async Task<IResult> Handle(Guid id, IQuerySession querySession)
{
    // Reads from Standby
    var item = await querySession.LoadAsync<MyItem>(id);
    return item is not null ? Results.Ok(item) : Results.NotFound();
}

6. Production Scenarios: Cloud Providers

Moving to the cloud requires understanding how different providers handle read replicas and high availability.

AWS (Aurora & RDS)

  • Aurora PostgreSQL: Uses a Cluster Endpoint for writes and a Reader Endpoint for load-balanced reads. Listing both in the connection string allows Npgsql to handle instance-level failover faster than DNS.
  • Multi-AZ RDS: You list the Primary and Read Replica DNS names. Npgsql detects the new Primary automatically during a failover, bypassing CNAME propagation delays.

Azure (Database for PostgreSQL - Flexible Server)

  • High Availability (HA): When enabled, Azure provides a standby replica in a different availability zone. During failover, the same DNS name points to the new primary. However, adding read replicas creates additional endpoints.
  • Resilience: By including both the primary and replica endpoints, Npgsql ensures that if the replica is momentarily unavailable during a maintenance window, your IQuerySession traffic can automatically fall back to the primary without application errors.

Read Replicas: You can list the primary server and up to five read replicas in the connection string.

Host=myapp-primary.postgres.database.azure.com,myapp-replica-1.postgres.database.azure.com;...

Summary of Benefits

Implementing a Multi-Host strategy with Marten and Wolverine provides:

  1. Horizontal Scalability: Add more read replicas to your PostgreSQL cluster to handle massive query loads without touching the application code.
  2. Automatic Failover: If a replica goes down, Npgsql automatically routes queries to another healthy replica.
  3. Read-After-Write Safety: By separating these at the DI level, you can selectively decide when a query must hit the primary (for immediate consistency) vs when a replica is sufficient.

By leveraging the multi-host capabilities of Npgsql at the core, your .NET application becomes truly cloud-native and cluster-aware.

Alexandre Cuva

Alexandre Cuva

Nyon, Switzerland