Skip to content
S
.NET

When Cached State Isn't Enough: Adding PostgreSQL with .NET Aspire

In part one, PulseOps hit its first distributed-systems problem.

Two API replicas each owned their own cache, so the same service could look healthy through one replica and unhealthy through another. Moving that short-lived status into Redis fixed the ownership boundary:

Two PulseOps API replicas share one Redis cache, so both observe the same short-lived service status.

Both replicas now see the same cached status.

That solves shared state.

It does not solve durable state.

If PulseOps creates an incident at 10:32, I still want that incident at 10:42 after a deployment, API restart, cache expiration, or Redis restart.

That gives us the next architectural requirement: PostgreSQL.

This is part two of Building PulseOps. As before, the database is not being added because an architecture diagram looked lonely. It has one job to earn: preserve operational state that must outlive the processes serving it.

What we’ll change

By the end of M2, PulseOps should have:

  • Redis still owning short-lived service status;
  • PostgreSQL owning incidents and incident status history;
  • both API replicas reading the same durable state;
  • one explicit owner for EF Core migrations;
  • startup ordering that prevents API replicas racing schema changes;
  • integration tests that prove the state boundary actually changed.

The important distinction is not Redis versus PostgreSQL.

It is replaceable state versus durable state.

Shared doesn’t mean durable

M1 deliberately stores service status in Redis with a short expiration.

Something like this is fine to lose:

Plain text
service-status:payments-api = Healthy

If it expires, PulseOps can probe the service again.

An incident is different:

Plain text
Incident #42
Payments API unavailable
Opened:   10:32 UTC
Resolved: 10:51 UTC

If that disappears because a cache entry expired, we have built a surprisingly optimistic incident-management system.

So M2 introduces a second state boundary:

StateOwner
Service definitionsAPI process, for now
Latest service statusRedis
IncidentsPostgreSQL
Incident status historyPostgreSQL

Redis is not being replaced. It still fits the data it owns.

The new PulseOps topology

The architecture now grows in one direction:

PulseOps Web calls two API replicas, which use Redis for transient status and PostgreSQL for durable incidents, while a separate migration process owns schema changes.

There are two details worth calling out.

First, both API replicas share the same PostgreSQL database, just as they share Redis.

Second, the API replicas do not own schema migration execution.

That second point matters more than adding PostgreSQL itself.

Add PostgreSQL to the Aspire application model

M1 already has Redis in the AppHost:

C#
var cache = builder.AddRedis("cache");

Now PostgreSQL becomes another application resource:

C#
var postgres = builder
    .AddPostgres("postgres")
    .WithDataVolume();

var database = postgres.AddDatabase("pulseops");

The PostgreSQL server and the PulseOps database are now part of the same application model as the web frontend, API, and Redis.

For local development, Aspire can start PostgreSQL and provide the database connection information to projects that reference pulseops.

That means this:

Plain text
host=localhost
port=5432
password=whatever-I-used-last-week

does not become part of the application’s setup instructions.

The dependency is described in code instead.

I am also using a data volume for PostgreSQL. Redis status is intentionally disposable. Incident history is not.

The API references both Redis and PostgreSQL

The API still needs Redis:

C#
.WithReference(cache)
.WaitFor(cache)

Now it also references the database:

C#
.WithReference(database)
.WaitFor(database)

A simplified AppHost starts to look like this:

C#
var builder = DistributedApplication.CreateBuilder(args);

var cache = builder.AddRedis("cache");

var postgres = builder
    .AddPostgres("postgres")
    .WithDataVolume();

var database = postgres.AddDatabase("pulseops");

var migrations = builder
    .AddProject<Projects.PulseOps_Migrations>("migrations")
    .WithReference(database)
    .WaitFor(database);

var api = builder
    .AddProject<Projects.PulseOps_Api>("api")
    .WithReference(cache)
    .WaitFor(cache)
    .WithReference(database)
    .WaitFor(database)
    .WaitForCompletion(migrations)
    .WithReplicas(2);

builder.AddProject<Projects.PulseOps_Web>("web")
    .WithReference(api)
    .WaitFor(api);

builder.Build().Run();

The interesting line is not AddPostgres.

It is:

C#
.WaitForCompletion(migrations)

We will come back to why.

Register EF Core through Aspire

Inside the API, the PostgreSQL EF Core integration gives us a normal DbContext while using the connection supplied through Aspire:

C#
builder.AddNpgsqlDbContext<PulseOpsDbContext>("pulseops");

The name matches the database resource:

C#
postgres.AddDatabase("pulseops");

The application code can stay focused on EF Core instead of knowing where PostgreSQL happens to be listening during local development.

The first database context is intentionally small:

C#
public sealed class PulseOpsDbContext(
    DbContextOptions<PulseOpsDbContext> options)
    : DbContext(options)
{
    public DbSet<Incident> Incidents => Set<Incident>();

    public DbSet<IncidentStatusHistory> IncidentStatusHistory =>
        Set<IncidentStatusHistory>();
}

No generic repository.

No home-grown unit of work.

EF Core already gives us a useful persistence boundary. M2 does not need another abstraction just to make the folder tree look architectural.

Keep the incident model boring

The first durable model only needs enough information to demonstrate why the database exists.

An incident can start with:

C#
public sealed class Incident
{
    public Guid Id { get; set; }

    public required string ServiceId { get; set; }

    public required string Summary { get; set; }

    public IncidentStatus Status { get; set; }

    public DateTimeOffset CreatedAtUtc { get; set; }

    public DateTimeOffset UpdatedAtUtc { get; set; }

    public List<IncidentStatusHistory> History { get; set; } = [];
}

And each status transition can be recorded separately:

C#
public sealed class IncidentStatusHistory
{
    public Guid Id { get; set; }

    public Guid IncidentId { get; set; }

    public IncidentStatus Status { get; set; }

    public DateTimeOffset ChangedAtUtc { get; set; }
}

That is enough to answer two different questions:

Plain text
What is the incident status now?

and:

Plain text
How did the incident get here?

We do not need event sourcing to answer the second one.

Who owns database migrations?

This is where multiple replicas become interesting again.

PulseOps runs:

Plain text
PulseOps.Api replica A
PulseOps.Api replica B

A very convenient startup pattern is:

C#
await db.Database.MigrateAsync();

Put that inside API startup and the schema is always updated before requests arrive.

Nice.

Except now both replicas are allowed to mutate the schema.

They can start within milliseconds of each other. Startup becomes a race between application processes that were never supposed to coordinate one-shot work.

This is the same general class of problem I covered in When Three Kubernetes Pods Try to Run One Database Migration.

Replicas are useful for serving application traffic.

They are not a coordination mechanism.

Give migrations one process

For M2, schema changes belong to a small migration process.

Its job is simple:

  1. connect to the PulseOps database;
  2. run pending EF Core migrations;
  3. exit successfully or fail;
  4. let the API start only after successful completion.

The core is deliberately boring:

C#
var builder = Host.CreateApplicationBuilder(args);

builder.AddServiceDefaults();
builder.AddNpgsqlDbContext<PulseOpsDbContext>("pulseops");

var host = builder.Build();

await using var scope = host.Services.CreateAsyncScope();

var db = scope.ServiceProvider
    .GetRequiredService<PulseOpsDbContext>();

await db.Database.MigrateAsync();

return 0;

The AppHost expresses the lifecycle:

PostgreSQL becomes healthy, the one-shot migration process runs and exits successfully, and only then do both API replicas start.

That is a much clearer ownership model than hoping two replicas politely avoid stepping on each other.

This does not solve every production deployment concern. A real platform may run migrations as a release job, deployment stage, Kubernetes Job, or another one-shot mechanism.

The useful property is the same:

schema mutation has one explicit owner.

Create incidents through either replica

Once the database owns incident state, the API surface can stay small:

HTTP
POST /incidents
GET  /incidents
GET  /incidents/{id}
PUT  /incidents/{id}/status

Creating an incident writes both the current state and the first history entry:

C#
var now = TimeProvider.System.GetUtcNow();

var incident = new Incident
{
    Id = Guid.NewGuid(),
    ServiceId = request.ServiceId,
    Summary = request.Summary.Trim(),
    Status = IncidentStatus.Open,
    CreatedAtUtc = now,
    UpdatedAtUtc = now,
    History =
    [
        new IncidentStatusHistory
        {
            Id = Guid.NewGuid(),
            Status = IncidentStatus.Open,
            ChangedAtUtc = now
        }
    ]
};

db.Incidents.Add(incident);

await db.SaveChangesAsync(cancellationToken);

The important thing is not the endpoint.

It is that the process which handled the request no longer owns the incident.

PostgreSQL does.

If replica A disappears immediately afterward, replica B can still retrieve the same incident.

Status history belongs in the same durable boundary

Resolving an incident updates the current status and adds a history entry:

C#
incident.Status = IncidentStatus.Resolved;
incident.UpdatedAtUtc = now;

incident.History.Add(new IncidentStatusHistory
{
    Id = Guid.NewGuid(),
    Status = IncidentStatus.Resolved,
    ChangedAtUtc = now
});

await db.SaveChangesAsync(cancellationToken);

Current state makes reads easy.

History makes the lifecycle explainable.

Both belong in PostgreSQL because both matter after the current process is gone.

Verify the property, not the container

As with Redis in M1, this test is not useful enough:

Plain text
PostgreSQL container = healthy

The property we actually changed is:

durable incident state is visible independently of the API process that created it.

So the integration test should use two distinct API replicas.

Conceptually:

API replica A creates an incident in PostgreSQL, and API replica B retrieves the same durable incident.

M1 already introduced a small instance identifier so the tests can prove that two separate API processes are involved.

The M2 test can reuse that mechanism:

C#
Assert.NotEqual(instanceA, instanceB);

var incident = await CreateIncidentAsync(clientA);

var loaded = await GetIncidentAsync(
    clientB,
    incident.Id);

Assert.Equal(incident.Id, loaded.Id);
Assert.Equal("payments-api", loaded.ServiceId);

A second test should prove that the incident remains after an API process restart.

That is the distinction PostgreSQL was introduced to provide.

If the test only proves that EF Core can insert a row, we have tested the library rather than our architecture.

Redis still has a job

At this point PulseOps has two shared state systems.

That is not automatically a smell.

They have different contracts:

Redis owns replaceable short-lived service status, while PostgreSQL owns durable incidents and status history.

Trying to force both kinds of state into whichever dependency we already had would make the architecture simpler on paper and less precise in behavior.

The interesting question is always:

What guarantee does this state need?

For service status, a cache is enough.

For incident history, it is not.

What about database failure?

Adding PostgreSQL also gives PulseOps a new failure mode.

That is useful.

The API now has to distinguish between:

  • being alive;
  • being able to reach Redis;
  • being able to reach PostgreSQL;
  • being ready to perform operations that depend on durable state.

Aspire’s PostgreSQL integration can surface connection health and telemetry alongside the rest of the application, but that signal does not decide our policy for us.

We still need to answer questions such as:

Plain text
Should incident reads fail when PostgreSQL is unavailable?
Should writes retry?
Should the API remain ready?
How long should callers wait?

Those are operational decisions, not database configuration.

M2 makes the dependency explicit.

A later observability milestone will make failures easier to follow across the system.

Where M2 leaves PulseOps

M1 solved:

Multiple API processes combined with process-local caches can return different answers for the same service.

M2 solves a different problem:

Because application processes restart and operational history matters, durable state must live outside those processes.

The architecture now has clear ownership:

Two PulseOps API replicas use Redis for shared transient state and PostgreSQL for durable incident state.

And schema changes have a separate owner instead of quietly becoming something every API replica can attempt.

That is the part worth carrying into other systems.

Adding a database is easy.

Defining what belongs there, who can mutate its schema, and which process owns one-shot work is the engineering decision.

What’s next

PulseOps now has enough moving parts to become genuinely annoying when something fails.

Perfect.

The next milestone adds structured logs, distributed traces, and metrics. Then we can break Redis, PostgreSQL, and downstream calls on purpose and follow the failure through the Aspire dashboard rather than staring at five terminals and developing theories.

After that, the system will finally have enough operational context to make an incident agent useful.

Code snapshots

Plain text
Starting point: pulseops-01-redis

The finished M2 snapshot is tagged pulseops-02-postgres.

main will keep evolving. Published article tags stay immutable.

References

Stay in the loop

Practical engineering notes, without the inbox noise.

Notes on distributed systems, resilient software, and engineering in the real world - usually once or twice a month.

Unsubscribe anytime. See what you get, or prefer a feed? Subscribe via RSS.