A real-world sample showing how to build, run, and scale distributed .NET applications with Foundatio, Foundatio.Mediator, .NET 10, Aspire, and OpenTelemetry.
Start local with zero infrastructure, then scale to production by swapping a connection string.
Every Foundatio service (cache, queues, messaging, locking, storage) starts with an in-memory implementation. The Insulation layer replaces them with Redis-backed versions automatically when a connection string is present — no code changes, no feature flags.
| Foundatio Feature | What It Does Here |
|---|---|
| Foundatio.Mediator | Convention-based handlers → auto-generated minimal API endpoints, SSE streaming, cascading events, middleware pipeline — zero boilerplate |
| Queues | IQueue<T> for reliable async work dispatch — in-memory locally, Redis in production |
| Jobs | QueueJobBase<T> for dedicated queue processors, WorkItemJob for shared work-item pools |
| Caching | ICacheClient for read-through caching of processed values |
| File Storage | IFileStorage for document persistence (in-memory or swap to S3/Azure/disk) |
| Messaging | IMessageBus for pub/sub events across processes — powers SSE streaming and job coordination |
| Locking | ILockProvider for distributed lock coordination backed by cache + message bus |
| Aspire | Orchestrates Redis, the API, and a scaled-out job worker — dashboard shows traces, logs, metrics |
| OpenTelemetry | ASP.NET Core, HTTP client, runtime metrics, and Foundatio activity sources export to OTLP |
| Health Checks | /health for liveness, /ready for readiness with queue depth monitoring |
src/
├── Samples.Core/ # Domain logic — in-memory Foundatio defaults
│ ├── Bootstrapper.cs # Registers all services; runJobsInProcess flag
│ ├── Handlers/ # Mediator handlers (auto-generate API routes)
│ ├── Jobs/ # Background queue processor + work item handler
│ ├── Messages/ # Command/query/stream message records
│ ├── Models/ # Domain types and events
│ └── Middleware/ # Mediator pipeline middleware
├── Samples.Insulation/ # Production swaps — Redis for all the things
│ └── Bootstrapper.cs # Replace in-memory → Redis via DI Replace()
├── Samples.Web/ # ASP.NET Core composition root
│ ├── Program.cs # Wires Core + Insulation + OTel + Mediator
│ └── QueueHealthCheck.cs # Queue depth health check
├── Samples.Jobs/ # Worker process — just background jobs
│ └── Program.cs # Same Core + Insulation, no HTTP
└── Samples.AppHost/ # Aspire orchestration
└── Program.cs # Redis + Web + Jobs(×3 replicas)
dotnet run --project src/Samples.WebEverything runs in-process with in-memory services. Open http://localhost:5210/scalar for interactive API docs.
aspire runAspire provisions Redis, starts the API (jobs off-loaded), and spins up 3 job worker replicas competing for queue work. Open the Aspire dashboard URL from the console to see distributed traces, structured logs, and metrics across all replicas.
Routes are auto-generated by Foundatio.Mediator from handler method conventions:
| Method | Route | Handler | Description |
|---|---|---|---|
POST |
/api/values |
ValueHandler |
Store a value → file storage → enqueue for processing |
GET |
/api/values/{id} |
ValueHandler |
Retrieve a processed value from cache |
DELETE |
/api/values/{id} |
ValueHandler |
Enqueue a work item for background deletion |
GET |
/api/events |
EventStreamHandler |
SSE stream of real-time EntityChanged events |
GET |
/health |
built-in | Liveness check |
GET |
/ready |
built-in | Readiness check (includes queue depth) |
GET |
/scalar |
Scalar | Interactive API documentation |
POST /api/values { "value": "hello" }
→ ValueHandler saves to IFileStorage, enqueues ValuesPost (carries Id + Value)
→ Returns 201 Created + publishes EntityChanged via cascading tuple return
→ ValuesPostJob (in worker) dequeues, caches value, publishes EntityChanged
→ Cache now has the value; GET /api/values/{id} returns it
→ EntityChangedHandler logs it; EventStreamHandler pushes it to SSE clients
DELETE /api/values/{id}
→ ValueHandler enqueues DeleteValueWorkItem to IQueue<WorkItemData>
→ WorkItemJob picks it up, runs DeleteValueWorkItemHandler
→ Handler removes from cache, reports progress (0% → 50% → 100%)
→ Publishes EntityChanged → SSE stream notifies clients
Connect to GET /api/events from any HTTP client. The EventStreamHandler calls mediator.SubscribeAsync<EntityChanged>() which bridges the Foundatio IMessageBus into an IAsyncEnumerable<T> — Server-Sent Events with zero additional dependencies. Works across processes because the message bus is backed by Redis in production.
This follows the Exceptionless pattern:
Samples.Core registers every Foundatio service with in-memory implementations. The entire app works out of the box:
Samples.Core.Bootstrapper.RegisterServices(services, runJobsInProcess: true);Samples.Insulation conditionally replaces registrations with Redis-backed implementations when a connection string is present:
Samples.Insulation.Bootstrapper.RegisterServices(services, redisConnectionString);
// Replaces: ICacheClient, IMessageBus, IQueue<ValuesPost>,
// IQueue<WorkItemData>, ILockProviderSamples.Web is the HTTP composition root. Samples.Jobs is the headless worker. Both call the same two bootstrappers — the only difference is whether jobs run in-process.
The AppHost demonstrates real-world scale-out:
// AppHost/Program.cs
builder.AddProject<Projects.Samples_Web>("web")
.WithEnvironment("RunJobsInProcess", "false") // API does NOT run jobs
.WithHttpHealthCheck("/health");
builder.AddProject<Projects.Samples_Jobs>("jobs")
.WithReplicas(3); // 3 workers competing for queue itemsBecause Foundatio queues guarantee at-most-once delivery, multiple workers safely compete for items without duplicate processing. Need more throughput? Change WithReplicas(3) to WithReplicas(10) — that's it.
This same pattern scales from a single process to multiple containers to Kubernetes pods.
| Endpoint | Purpose | What It Checks |
|---|---|---|
/health |
Liveness | Nothing — just confirms the process responds (no I/O) |
/ready |
Readiness | Queue depth < 1000 (reports queued, working, deadletter, errors) |
Liveness runs zero checks by design. A network call to Redis or any external dependency during liveness is wrong — if Redis is down the process is still alive and should not be restarted. Only readiness gates traffic.
{
"ConnectionStrings": {
"Redis": ""
},
"RunJobsInProcess": true,
"Sample": {
"AppName": "Foundatio Sample"
}
}| Key | Default | Description |
|---|---|---|
ConnectionStrings:Redis |
"" (empty) |
When empty, all services use in-memory. Set to a Redis URL to enable distributed mode. |
RunJobsInProcess |
true |
When true, background jobs run in the web process. Set to false when using separate Samples.Jobs workers. |
Sample:AppName |
"Foundatio Sample" |
Service name for OpenTelemetry resource tags. |
When running via Aspire, Redis is provisioned automatically, RunJobsInProcess is set to false, and connection strings are injected via service discovery.
- Foundatio — Pluggable foundation blocks for building distributed apps
- Foundatio.Mediator — Source-generated mediator with auto API endpoints
- Foundatio.Mediator Docs — Complete documentation
- Exceptionless — Real-world app built on Foundatio at scale
- Discord — Community chat