
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:

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:
service-status:payments-api = HealthyIf it expires, PulseOps can probe the service again.
An incident is different:
Incident #42
Payments API unavailable
Opened: 10:32 UTC
Resolved: 10:51 UTCIf that disappears because a cache entry expired, we have built a surprisingly optimistic incident-management system.
So M2 introduces a second state boundary:
| State | Owner |
|---|---|
| Service definitions | API process, for now |
| Latest service status | Redis |
| Incidents | PostgreSQL |
| Incident status history | PostgreSQL |
Redis is not being replaced. It still fits the data it owns.
The new PulseOps topology
The architecture now grows in one direction:

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:
var cache = builder.AddRedis("cache");Now PostgreSQL becomes another application resource:
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:
host=localhost
port=5432
password=whatever-I-used-last-weekdoes 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:
.WithReference(cache)
.WaitFor(cache)Now it also references the database:
.WithReference(database)
.WaitFor(database)A simplified AppHost starts to look like this:
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:
.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:
builder.AddNpgsqlDbContext<PulseOpsDbContext>("pulseops");The name matches the database resource:
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:
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:
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:
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:
What is the incident status now?and:
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:
PulseOps.Api replica A
PulseOps.Api replica BA very convenient startup pattern is:
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:
- connect to the PulseOps database;
- run pending EF Core migrations;
- exit successfully or fail;
- let the API start only after successful completion.
The core is deliberately boring:
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:

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:
POST /incidents
GET /incidents
GET /incidents/{id}
PUT /incidents/{id}/statusCreating an incident writes both the current state and the first history entry:
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:
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:
PostgreSQL container = healthyThe 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:

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:
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:

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:
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:

M2 solves a different problem:

The architecture now has clear ownership:

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
Starting point: pulseops-01-redisThe finished M2 snapshot is tagged pulseops-02-postgres.
main will keep evolving. Published article tags stay immutable.