ASP.NET Core 10: Using Health check in ASP.NET Core 10 MVC Application
Most health check tutorials stop at returning the word "Healthy" from an empty
project. That teaches the syntax and none of the judgement. This article wires
AspNetCore.Diagnostics.HealthChecks into an order processing application that
genuinely depends on four external systems, and shows what each probe is actually telling
you.
The application captures an order in SQL Server, publishes it to RabbitMQ, processes it in a
background service, writes the decision to PostgreSQL, and uses Redis to show live activity. Every
one of those four dependencies can fail independently, which is exactly what makes it a useful
subject. We will build it from the first model class to the last line of
Program.cs.
1. What is a health check in ASP.NET Core, and why does it matter?
A health check is an endpoint your application exposes that answers one question: can I currently do my job? Not "is the process running", which the operating system already knows, but "are the things I depend on reachable and responding".
The distinction matters because a web application can be perfectly alive and completely useless. The process is up, Kestrel is accepting connections, and every request returns a 500 because the database refused the connection. From the outside, a plain TCP check sees a healthy port. A health check sees the truth.
ASP.NET Core ships this as a first class feature in
Microsoft.Extensions.Diagnostics.HealthChecks, with no extra package needed for the
infrastructure itself. You register checks with AddHealthChecks() and expose them
with MapHealthChecks(). Each check reports one of three states:
- Healthy. Working normally.
- Degraded. Working, but not well. Slow, or a non essential dependency is missing. The endpoint still returns 200 by default, because you usually do not want a load balancer pulling the instance out of rotation over a degraded cache.
- Unhealthy. Not working. Returns 503 by default.
The aggregate status of the application is the worst status among its checks. One unhealthy database makes the whole report unhealthy.
Why it matters in practice
Three concrete uses justify the work.
Orchestrators use it to make decisions. Kubernetes distinguishes liveness from readiness. A failing liveness probe restarts the container; a failing readiness probe stops traffic being routed to it while leaving it running. That is why this article exposes them separately. Restarting a container because PostgreSQL is briefly unreachable helps nobody, and a restart loop during a database blip turns a small incident into an outage.
Load balancers use it to route. An instance that cannot reach its database should stop receiving requests until it can.
Humans use it to diagnose. This is the one that gets underrated. When a deployment misbehaves at two in the morning, a single endpoint that names which of four dependencies is refusing connections, and how long each took to respond, turns a half hour of guessing into a glance. That is why this article replaces the default plain text body with JSON that names every check.
A health check should test reachability, not correctness. Run
SELECT 1, not a report query. A probe that is expensive will be called every few seconds by every orchestrator and load balancer in the system, and an expensive probe under load becomes the thing that takes you down.
2. The packages, and the classes that matter
The built in feature gives you the plumbing but no probes. Writing a check for SQL Server by
hand is not hard, but writing one for every dependency, correctly, is a lot of repeated work.
AspNetCore.Diagnostics.HealthChecks is a widely used open source project (published
by Xabaril) providing ready made probes for most things you would depend on. The packages are
independent, so you install only what you need.
| Package | Version | What it provides |
|---|---|---|
AspNetCore.HealthChecks.SqlServer | 9.0.0 | AddSqlServer(). Opens a connection and runs a probe query against SQL Server. |
AspNetCore.HealthChecks.NpgSql | 9.0.0 | AddNpgSql(). The same idea for PostgreSQL, through Npgsql. |
AspNetCore.HealthChecks.Redis | 9.0.0 | AddRedis(). Pings Redis, either from a connection string or from a multiplexer you already own. |
AspNetCore.HealthChecks.Rabbitmq | 9.0.0 | AddRabbitMQ(). Opens a channel on a RabbitMQ connection supplied by a factory delegate. |
AspNetCore.HealthChecks.UI | 9.0.0 | AddHealthChecksUI() and MapHealthChecksUI(). The polling dashboard. |
AspNetCore.HealthChecks.UI.Client | 9.0.0 | UIResponseWriter. Writes the JSON shape the dashboard understands. |
AspNetCore.HealthChecks.UI.InMemory.Storage | 9.0.0 | AddInMemoryStorage(). Keeps the dashboard's check history in memory. |
Install them alongside the data access libraries:
dotnet add package Microsoft.EntityFrameworkCore.SqlServer
dotnet add package Npgsql.EntityFrameworkCore.PostgreSQL
dotnet add package Microsoft.EntityFrameworkCore.InMemory
dotnet add package StackExchange.Redis
dotnet add package RabbitMQ.Client
dotnet add package AspNetCore.HealthChecks.SqlServer
dotnet add package AspNetCore.HealthChecks.NpgSql
dotnet add package AspNetCore.HealthChecks.Redis
dotnet add package AspNetCore.HealthChecks.Rabbitmq
dotnet add package AspNetCore.HealthChecks.UI
dotnet add package AspNetCore.HealthChecks.UI.Client
dotnet add package AspNetCore.HealthChecks.UI.InMemory.Storage
Two compatibility notes from building this on .NET 10. First,
AspNetCore.HealthChecks.UI.InMemory.Storage9.0.0 resolves EF Core 8's InMemory provider, which calls a method that no longer exists in EF Core 10 and crashes at startup withMissingMethodException. ReferencingMicrosoft.EntityFrameworkCore.InMemory10.x explicitly fixes it, which is why it appears in the list above. Second,AspNetCore.HealthChecks.UI9.0.0 pulls inKubernetesClient15.0.1, which carries a moderate severity advisory and surfaces as an NU1902 warning on every build. The/healthendpoints do not depend on the UI packages, so you can drop all three UI packages if you would rather not carry it.
The important classes
| Type | Role |
|---|---|
IHealthCheck | The interface behind every check. One method, CheckHealthAsync, returning a HealthCheckResult. You implement this only when you write a custom check; the packages above implement it for you. |
HealthCheckResult | The result of one check: Healthy, Degraded or Unhealthy, with an optional description, exception and data dictionary. |
HealthStatus | The three value enum. Ordered worst to best, which is how the aggregate status is computed. |
IHealthChecksBuilder | What AddHealthChecks() returns. Every Add* extension method in the table above hangs off this, which is why registrations chain. |
HealthCheckRegistration | One registered check: its name, factory, failure status, tags and timeout. The Predicate on an endpoint filters over these. |
HealthCheckService | The service that actually runs the checks. Resolve it from DI to run checks from your own code, which is what the dashboard controller does. |
HealthReport | The aggregate result: an overall Status, a TotalDuration and an Entries dictionary. |
HealthReportEntry | One entry in that dictionary: status, description, duration, tags, exception and data. |
HealthCheckOptions | The per endpoint options passed to MapHealthChecks. Carries Predicate, ResponseWriter and ResultStatusCodes. |
UIResponseWriter | From the UI.Client package. A ready made ResponseWriter producing the payload the dashboard polls. |
In this application we never implement IHealthCheck ourselves, because the four
packages cover every dependency. We do use HealthReport and
HealthReportEntry directly in two places: the custom response writer, and the
dashboard view.
3. Running Redis and RabbitMQ in Docker
SQL Server and PostgreSQL run as local installations in this setup. Redis and RabbitMQ run as containers, which is the quickest way to get both.
Pull the images first. This step is optional, since docker run pulls
automatically when the image is not present locally, but doing it explicitly separates a slow
download from a failed startup:
docker pull redis:7-alpine
docker pull rabbitmq:3-management
Then start them:
docker run -d --name hc-redis -p 6379:6379 redis:7-alpine
docker run -d --name hc-rabbitmq -p 5672:5672 -p 15672:15672 ^
-e RABBITMQ_DEFAULT_USER=guest ^
-e RABBITMQ_DEFAULT_PASS=guest ^
rabbitmq:3-management
On PowerShell or bash use a backtick or a backslash for line continuation instead of the caret, or simply put the command on one line.
Reading the RabbitMQ flags: -d runs detached, --name gives the
container a stable name so you can stop and start it by name, and -p host:container
publishes a port. Two ports are published because RabbitMQ serves two things. Port 5672 is the
AMQP protocol port your application connects to. Port 15672 is the management web UI, which is
only present because the image tag is 3-management rather than plain
3. The plain image has no dashboard.
Verify both are up:
docker ps --format "table {{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}"
# Redis should answer PONG
docker exec hc-redis redis-cli ping
# Inspect the Redis keys this application writes
docker exec hc-redis redis-cli KEYS "orders:*"
docker exec hc-redis redis-cli LRANGE orders:list:processed 0 4
The RabbitMQ management UI is at http://localhost:15672, username
guest, password guest. The Queues tab is useful while developing: you can
watch order-processing-queue fill and drain as the worker consumes.
Stopping and restarting later, without losing the containers:
docker stop hc-redis hc-rabbitmq
docker start hc-redis hc-rabbitmq
# remove them entirely
docker rm -f hc-redis hc-rabbitmq
Neither container declares a volume here, so data does not survive
docker rm. That is deliberate for a demo: Redis holds only activity data this application can rebuild, and the RabbitMQ queue should be empty in steady state. For anything long lived, mount a volume.
4. Building the application, step by step
Start from the MVC template, which gives the controllers, views and wwwroot assets:
dotnet new mvc -n MVC_HealthCheck
What follows adds 35 files on top of that template. The order below is dependency order, so each file only refers to things already created.
Step 1: Create Models\Customer.cs
The order pipeline starts with four SQL Server tables, and the first of them is the customer.
This is a plain entity class. Entity Framework Core infers the table from the
DbSet name and the primary key from the Id convention, so the only
thing worth adding by hand is validation and the navigation property back to the orders.
The [Required] and [StringLength] attributes do double duty. They
drive client side and server side validation in the MVC layer, and they also tell EF Core the
column types to generate, so Name becomes nvarchar(150) rather than
nvarchar(max).
using System.ComponentModel.DataAnnotations;
namespace MVC_HealthCheck.Models;
/// <summary>SQL Server entity. Placed an order is always tied to exactly one customer.</summary>
public class Customer
{
public int Id { get; set; }
[Required, StringLength(150)]
public string Name { get; set; } = string.Empty;
[Required, EmailAddress, StringLength(200)]
public string Email { get; set; } = string.Empty;
[StringLength(20)]
public string? Phone { get; set; }
[StringLength(100)]
public string? City { get; set; }
public ICollection<Order> Orders { get; set; } = new List<Order>();
}
Step 2: Create Models\Product.cs
The product is the catalogue item that order lines point at. Two details matter here.
[Column(TypeName = "decimal(18,2)")] on UnitPrice stops EF Core from
falling back to its default decimal precision, which would silently truncate or round money. Any
property that holds currency in this application carries that attribute. The Sku
gets a unique index later in the DbContext, so the catalogue cannot hold two
products with the same stock code.
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace MVC_HealthCheck.Models;
/// <summary>SQL Server entity. Catalogue item that order lines point at.</summary>
public class Product
{
public int Id { get; set; }
[Required, StringLength(150)]
public string Name { get; set; } = string.Empty;
[Required, StringLength(50)]
public string Sku { get; set; } = string.Empty;
[Column(TypeName = "decimal(18,2)")]
[Range(0.01, 1_000_000)]
public decimal UnitPrice { get; set; }
[Range(0, int.MaxValue)]
public int StockQuantity { get; set; }
public ICollection<OrderDetail> OrderDetails { get; set; } = new List<OrderDetail>();
}
Step 3: Create Models\OrderStatus.cs
This enum is the spine of the whole application. An order walks through these states as it moves from the web tier, to the broker, to the background worker, and finally to PostgreSQL. Having the states named in one place means the controller, the worker and the views all agree on what "in flight" means.
Failed is worth calling out. It is not a business outcome. It means the worker
could not finish processing because of an infrastructure problem, for example PostgreSQL being
unreachable. Keeping it separate from Rejected stops an outage from looking like a
business decision.
namespace MVC_HealthCheck.Models;
/// <summary>
/// Lifecycle of an order as it moves SQL Server -> RabbitMQ -> background worker -> PostgreSQL.
/// </summary>
public enum OrderStatus
{
/// <summary>Persisted to SQL Server but not yet handed to the broker.</summary>
Received = 0,
/// <summary>Published to RabbitMQ, waiting for the background worker.</summary>
Queued = 1,
/// <summary>The worker picked the message up and is applying the advance rule.</summary>
Processing = 2,
/// <summary>Advance was at least 50% of the total. Written to PostgreSQL.</summary>
Accepted = 3,
/// <summary>Advance was below 50% of the total. Written to PostgreSQL.</summary>
Rejected = 4,
/// <summary>The worker could not process the message (infrastructure error).</summary>
Failed = 5
}
Step 4: Create Models\Order.cs
The order carries the two numbers the business rule compares: TotalAmount and
AdvanceAmount. Both are written by the server, never taken from the posted form.
AdvancePercentage is marked [NotMapped], so it is computed in memory
and never stored. It guards against division by zero by treating a zero total as zero percent.
The views use this property to colour the advance meter, and the background worker recomputes the
same figure independently from the queue message.
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace MVC_HealthCheck.Models;
/// <summary>SQL Server entity. The order as captured by the web tier, before processing.</summary>
public class Order
{
public int Id { get; set; }
[Required, StringLength(30)]
public string OrderNumber { get; set; } = string.Empty;
public int CustomerId { get; set; }
public Customer? Customer { get; set; }
public DateTime OrderDate { get; set; } = DateTime.UtcNow;
/// <summary>Sum of all order line totals. Computed server side, never trusted from the form.</summary>
[Column(TypeName = "decimal(18,2)")]
public decimal TotalAmount { get; set; }
/// <summary>Money paid up front. The accept/reject rule compares this against <see cref="TotalAmount"/>.</summary>
[Column(TypeName = "decimal(18,2)")]
public decimal AdvanceAmount { get; set; }
public OrderStatus Status { get; set; } = OrderStatus.Received;
[StringLength(500)]
public string? StatusMessage { get; set; }
public DateTime? ProcessedOn { get; set; }
public ICollection<OrderDetail> OrderDetails { get; set; } = new List<OrderDetail>();
/// <summary>Advance as a percentage of the total. Zero-total orders are treated as 0%.</summary>
[NotMapped]
public decimal AdvancePercentage =>
TotalAmount <= 0 ? 0 : Math.Round(AdvanceAmount / TotalAmount * 100m, 2);
}
Step 5: Create Models\OrderDetail.cs
One row per product line. The important design decision is that UnitPrice is
copied onto the line rather than read through the Product navigation property at
display time.
That is a price snapshot. If somebody edits the catalogue next week, last week's orders still show what the customer actually agreed to pay. Without the snapshot, changing a product price would silently rewrite the history of every order that referenced it.
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace MVC_HealthCheck.Models;
/// <summary>SQL Server entity. One product line on an order.</summary>
public class OrderDetail
{
public int Id { get; set; }
public int OrderId { get; set; }
public Order? Order { get; set; }
public int ProductId { get; set; }
public Product? Product { get; set; }
[Range(1, 10_000)]
public int Quantity { get; set; }
/// <summary>Price snapshot taken when the order was placed, so later catalogue edits do not rewrite history.</summary>
[Column(TypeName = "decimal(18,2)")]
public decimal UnitPrice { get; set; }
[Column(TypeName = "decimal(18,2)")]
public decimal LineTotal { get; set; }
}
Step 6: Create Models\ProcessedOrder.cs
This is the only entity that lives in PostgreSQL. It is the decision record the background worker writes after applying the advance rule.
It is deliberately denormalised. It copies the customer name and the amounts instead of
holding foreign keys, because it never joins back to SQL Server and never can: it is in a
different database engine entirely. Treating it as a self contained read model is what makes the
split storage workable. The snake_case column names come from the explicit
[Column] attributes, which keeps the table idiomatic for PostgreSQL rather than
carrying .NET naming into the database.
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace MVC_HealthCheck.Models;
/// <summary>
/// PostgreSQL entity. The outcome record the background worker writes after applying the
/// advance rule. Deliberately denormalised: it is a read model and never joins back to SQL Server.
/// </summary>
[Table("processed_orders")]
public class ProcessedOrder
{
[Column("id")]
public int Id { get; set; }
/// <summary>Primary key of the originating row in the SQL Server Orders table.</summary>
[Column("order_id")]
public int OrderId { get; set; }
[Column("order_number"), StringLength(30)]
public string OrderNumber { get; set; } = string.Empty;
[Column("customer_id")]
public int CustomerId { get; set; }
[Column("customer_name"), StringLength(150)]
public string CustomerName { get; set; } = string.Empty;
[Column("total_amount", TypeName = "numeric(18,2)")]
public decimal TotalAmount { get; set; }
[Column("advance_amount", TypeName = "numeric(18,2)")]
public decimal AdvanceAmount { get; set; }
[Column("advance_percentage", TypeName = "numeric(9,2)")]
public decimal AdvancePercentage { get; set; }
/// <summary>"Accepted" or "Rejected". Stored as text so the read model stays decoupled from the enum.</summary>
[Column("decision"), StringLength(20)]
public string Decision { get; set; } = string.Empty;
[Column("reason"), StringLength(400)]
public string Reason { get; set; } = string.Empty;
[Column("item_count")]
public int ItemCount { get; set; }
[Column("order_placed_on_utc")]
public DateTime OrderPlacedOnUtc { get; set; }
[Column("processed_on_utc")]
public DateTime ProcessedOnUtc { get; set; }
[Column("processing_duration_ms")]
public long ProcessingDurationMs { get; set; }
[NotMapped]
public bool IsAccepted => string.Equals(Decision, "Accepted", StringComparison.OrdinalIgnoreCase);
}
Step 7: Create Messaging\OrderMessage.cs
This is the contract between the web tier and the background worker, serialised as JSON onto the queue.
It carries everything the worker needs to make its decision, which means the worker does not
have to read the order back from SQL Server before it can evaluate the rule. It is a
record with required members, so a message that is missing a field
fails to deserialise rather than quietly arriving with a zero total, which would make every
order look rejected.
namespace MVC_HealthCheck.Messaging;
/// <summary>
/// The RabbitMQ payload. Carries everything the worker needs so it does not have to read
/// back from SQL Server before making the accept/reject decision.
/// </summary>
public sealed record OrderMessage
{
public required int OrderId { get; init; }
public required string OrderNumber { get; init; }
public required int CustomerId { get; init; }
public required string CustomerName { get; init; }
public required decimal TotalAmount { get; init; }
public required decimal AdvanceAmount { get; init; }
public required int ItemCount { get; init; }
public required DateTime OrderPlacedOnUtc { get; init; }
}
Step 8: Create Options\RabbitMqOptions.cs
A strongly typed settings class bound to the RabbitMq section of
appsettings.json. The SectionName constant keeps the magic string in
one place instead of scattered through Program.cs.
namespace MVC_HealthCheck.Options;
/// <summary>Bound from the "RabbitMq" section of appsettings.json.</summary>
public sealed class RabbitMqOptions
{
public const string SectionName = "RabbitMq";
public string HostName { get; set; } = "localhost";
public int Port { get; set; } = 5672;
public string UserName { get; set; } = "guest";
public string Password { get; set; } = "guest";
public string VirtualHost { get; set; } = "/";
/// <summary>Durable queue the web tier publishes to and the background worker consumes from.</summary>
public string QueueName { get; set; } = "order-processing-queue";
}
Step 9: Create Options\OrderProcessingOptions.cs
The business rule itself is configuration, not a hard coded constant.
MinimumAdvancePercentage defaults to 50, which is the rule in the brief, but it can
be changed without recompiling.
SimulatedWorkMilliseconds adds a deliberate delay inside the worker. It exists
purely so the Received, Queued and Processing transitions are visible in the UI instead of
flashing past. Set it to zero in a real system.
namespace MVC_HealthCheck.Options;
/// <summary>Bound from the "OrderProcessing" section of appsettings.json.</summary>
public sealed class OrderProcessingOptions
{
public const string SectionName = "OrderProcessing";
/// <summary>
/// Minimum advance, as a percentage of the order total, for an order to be accepted.
/// The business rule in the brief is 50%.
/// </summary>
public decimal MinimumAdvancePercentage { get; set; } = 50m;
/// <summary>Artificial delay so the Queued -> Processing -> Accepted transition is visible in the UI.</summary>
public int SimulatedWorkMilliseconds { get; set; } = 750;
/// <summary>How many recent orders each Redis activity list keeps.</summary>
public int CacheListLength { get; set; } = 50;
}
Step 10: Create Data\OrdersDbContext.cs
The SQL Server side of the application. This context owns Customers, Products, Orders and OrderDetails.
Three configuration choices are worth reading closely. The status is persisted with
HasConversion<string>(), so the Orders table holds the word
Accepted rather than the integer 3, which makes the table readable
straight from SSMS. The customer relationship uses DeleteBehavior.Restrict, so you
cannot delete a customer who has orders. The order to order detail relationship uses
Cascade, because a line has no meaning without its order.
using Microsoft.EntityFrameworkCore;
using MVC_HealthCheck.Models;
namespace MVC_HealthCheck.Data;
/// <summary>
/// SQL Server write model: Customers, Products, Orders, OrderDetails.
/// This is the system of record for orders as they are captured.
/// </summary>
public class OrdersDbContext : DbContext
{
public OrdersDbContext(DbContextOptions<OrdersDbContext> options) : base(options) { }
public DbSet<Customer> Customers => Set<Customer>();
public DbSet<Product> Products => Set<Product>();
public DbSet<Order> Orders => Set<Order>();
public DbSet<OrderDetail> OrderDetails => Set<OrderDetail>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Customer>(e =>
{
e.ToTable("Customers");
e.HasIndex(c => c.Email).IsUnique();
});
modelBuilder.Entity<Product>(e =>
{
e.ToTable("Products");
e.HasIndex(p => p.Sku).IsUnique();
});
modelBuilder.Entity<Order>(e =>
{
e.ToTable("Orders");
e.HasIndex(o => o.OrderNumber).IsUnique();
e.HasIndex(o => o.Status);
// Status is persisted as text so the table stays readable from SSMS.
e.Property(o => o.Status)
.HasConversion<string>()
.HasMaxLength(20);
e.HasOne(o => o.Customer)
.WithMany(c => c.Orders)
.HasForeignKey(o => o.CustomerId)
.OnDelete(DeleteBehavior.Restrict);
});
modelBuilder.Entity<OrderDetail>(e =>
{
e.ToTable("OrderDetails");
e.HasOne(d => d.Order)
.WithMany(o => o.OrderDetails)
.HasForeignKey(d => d.OrderId)
.OnDelete(DeleteBehavior.Cascade);
e.HasOne(d => d.Product)
.WithMany(p => p.OrderDetails)
.HasForeignKey(d => d.ProductId)
.OnDelete(DeleteBehavior.Restrict);
});
base.OnModelCreating(modelBuilder);
}
}
Step 11: Create Data\ProcessedOrdersDbContext.cs
The PostgreSQL side, and it holds exactly one table.
The unique index on OrderId is the important line. It is what makes the worker
safe to re-run: if RabbitMQ redelivers a message, the worker finds the existing row and updates
it instead of inserting a duplicate. The timestamps are explicitly mapped to
timestamp with time zone, because Npgsql is strict about UTC handling and will
throw at runtime if a DateTime with the wrong Kind reaches a
timestamptz column.
using Microsoft.EntityFrameworkCore;
using MVC_HealthCheck.Models;
namespace MVC_HealthCheck.Data;
/// <summary>
/// PostgreSQL read model: the single processed_orders table the background worker writes to
/// once the advance rule has been applied.
/// </summary>
public class ProcessedOrdersDbContext : DbContext
{
public ProcessedOrdersDbContext(DbContextOptions<ProcessedOrdersDbContext> options) : base(options) { }
public DbSet<ProcessedOrder> ProcessedOrders => Set<ProcessedOrder>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<ProcessedOrder>(e =>
{
e.ToTable("processed_orders");
// The worker is idempotent: a redelivered message must not create a second row.
e.HasIndex(p => p.OrderId).IsUnique();
e.HasIndex(p => p.Decision);
e.HasIndex(p => p.ProcessedOnUtc);
e.Property(p => p.OrderPlacedOnUtc).HasColumnType("timestamp with time zone");
e.Property(p => p.ProcessedOnUtc).HasColumnType("timestamp with time zone");
});
base.OnModelCreating(modelBuilder);
}
}
Step 12: Create Data\DatabaseInitializer.cs
This creates both schemas at startup and seeds the catalogue with five customers and eight products, so the application is usable the first time it runs.
The design decision that matters is the error handling. Both initialisers catch and log instead of throwing. If PostgreSQL is down, the application still starts, and the outage shows up on the health endpoint where it belongs. An initialiser that throws would turn a dependency outage into a process that will not boot, which is strictly less useful: you lose the health endpoint that would have told you what was wrong.
EnsureCreatedAsync is the right tool for a demo because it needs no migration
files. For a system that evolves, swap it for EF Core migrations, since
EnsureCreated cannot upgrade an existing schema.
using Microsoft.EntityFrameworkCore;
using MVC_HealthCheck.Models;
namespace MVC_HealthCheck.Data;
/// <summary>
/// Creates both schemas on startup and seeds the SQL Server catalogue.
/// Failures are logged rather than thrown: a database being down should surface through the
/// health endpoint, not prevent the app from booting.
/// </summary>
public static class DatabaseInitializer
{
public static async Task InitializeAsync(IServiceProvider services, CancellationToken cancellationToken = default)
{
using var scope = services.CreateScope();
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("DatabaseInitializer");
await InitializeSqlServerAsync(scope.ServiceProvider, logger, cancellationToken);
await InitializePostgreSqlAsync(scope.ServiceProvider, logger, cancellationToken);
}
private static async Task InitializeSqlServerAsync(IServiceProvider sp, ILogger logger, CancellationToken ct)
{
try
{
var db = sp.GetRequiredService<OrdersDbContext>();
await db.Database.EnsureCreatedAsync(ct);
await SeedCatalogueAsync(db, ct);
logger.LogInformation("SQL Server schema ready (Customers, Products, Orders, OrderDetails).");
}
catch (Exception ex)
{
logger.LogError(ex, "SQL Server initialisation failed. The sql-server health check will report Unhealthy.");
}
}
private static async Task InitializePostgreSqlAsync(IServiceProvider sp, ILogger logger, CancellationToken ct)
{
try
{
var db = sp.GetRequiredService<ProcessedOrdersDbContext>();
await db.Database.EnsureCreatedAsync(ct);
logger.LogInformation("PostgreSQL schema ready (processed_orders).");
}
catch (Exception ex)
{
logger.LogError(ex, "PostgreSQL initialisation failed. The postgresql health check will report Unhealthy.");
}
}
private static async Task SeedCatalogueAsync(OrdersDbContext db, CancellationToken ct)
{
if (!await db.Customers.AnyAsync(ct))
{
db.Customers.AddRange(
new Customer { Name = "Mahesh Sabnis", Email = "mahesh@example.com", Phone = "9890000001", City = "Pune" },
new Customer { Name = "Leena Kulkarni", Email = "leena@example.com", Phone = "9890000002", City = "Mumbai" },
new Customer { Name = "Arjun Deshpande", Email = "arjun@example.com", Phone = "9890000003", City = "Bengaluru" },
new Customer { Name = "Fatima Shaikh", Email = "fatima@example.com", Phone = "9890000004", City = "Hyderabad" },
new Customer { Name = "Rohit Nair", Email = "rohit@example.com", Phone = "9890000005", City = "Kochi" });
}
if (!await db.Products.AnyAsync(ct))
{
db.Products.AddRange(
new Product { Name = "Mechanical Keyboard", Sku = "KBD-MECH-87", UnitPrice = 7_499.00m, StockQuantity = 120 },
new Product { Name = "27\" 4K Monitor", Sku = "MON-4K-27", UnitPrice = 32_900.00m, StockQuantity = 45 },
new Product { Name = "Wireless Mouse", Sku = "MSE-WL-02", UnitPrice = 2_250.00m, StockQuantity = 300 },
new Product { Name = "USB-C Docking Station", Sku = "DCK-USBC-11", UnitPrice = 14_750.00m, StockQuantity = 60 },
new Product { Name = "Noise Cancelling Headset", Sku = "HDS-NC-44", UnitPrice = 18_999.00m, StockQuantity = 80 },
new Product { Name = "Laptop Stand (Aluminium)", Sku = "STD-ALU-09", UnitPrice = 3_199.00m, StockQuantity = 210 },
new Product { Name = "1080p Webcam", Sku = "CAM-HD-10", UnitPrice = 5_650.00m, StockQuantity = 150 },
new Product { Name = "2TB NVMe SSD", Sku = "SSD-NVME-2T", UnitPrice = 16_400.00m, StockQuantity = 95 });
}
await db.SaveChangesAsync(ct);
}
}
Step 13: Create Services\OrderCacheEntry.cs
Two small records that describe what the application keeps in Redis: the rows of the two activity lists, and the four counters shown on the dashboard tiles.
namespace MVC_HealthCheck.Services;
/// <summary>A row in one of the Redis activity lists shown on the dashboard.</summary>
public sealed record OrderCacheEntry
{
public required int OrderId { get; init; }
public required string OrderNumber { get; init; }
public required string CustomerName { get; init; }
public required decimal TotalAmount { get; init; }
public required decimal AdvanceAmount { get; init; }
public required decimal AdvancePercentage { get; init; }
/// <summary>"Received" for the inbound list, "Accepted"/"Rejected" for the processed list.</summary>
public required string Status { get; init; }
public string? Reason { get; init; }
public required DateTime TimestampUtc { get; init; }
}
/// <summary>Counters rendered on the dashboard tiles.</summary>
public sealed record OrderCacheCounters
{
public long Received { get; init; }
public long Processed { get; init; }
public long Accepted { get; init; }
public long Rejected { get; init; }
}
Step 14: Create Services\IOrderCache.cs
The cache abstraction. Read the comment on the interface carefully, because it is a contract about failure rather than about data: every method swallows connection errors and degrades to an empty result.
That is a deliberate decision about what Redis is for in this application. It holds activity and counters, which are convenience data. The orders themselves are durable in SQL Server. So a Redis outage should make the dashboard go quiet, not take the site down.
namespace MVC_HealthCheck.Services;
/// <summary>
/// Redis-backed activity cache. Every method swallows connection errors and degrades to an
/// empty result, so a Redis outage shows up on /health without taking the UI down.
/// </summary>
public interface IOrderCache
{
Task RecordReceivedAsync(OrderCacheEntry entry, CancellationToken ct = default);
Task RecordProcessedAsync(OrderCacheEntry entry, CancellationToken ct = default);
Task<IReadOnlyList<OrderCacheEntry>> GetReceivedAsync(int count = 25, CancellationToken ct = default);
Task<IReadOnlyList<OrderCacheEntry>> GetProcessedAsync(int count = 25, CancellationToken ct = default);
Task<OrderCacheCounters> GetCountersAsync(CancellationToken ct = default);
/// <summary>True when the multiplexer currently has a live connection to Redis.</summary>
bool IsAvailable { get; }
}
Step 15: Create Services\RedisOrderCache.cs
The implementation, built on StackExchange.Redis.
Writes go through CreateBatch, so the list push, the trim and the counter
increments are pipelined to the server in one round trip instead of four. The
ListTrimAsync call immediately after each push is what stops the activity lists
growing without bound: they keep the newest CacheListLength entries and discard the
rest.
Every public method is wrapped in try/catch, which is what makes good on the promise in the
interface. Note also that IsAvailable reports
IConnectionMultiplexer.IsConnected, so the views can tell the user that counters are
empty because Redis is unreachable rather than because nothing has happened yet.
using System.Text.Json;
using Microsoft.Extensions.Options;
using MVC_HealthCheck.Options;
using StackExchange.Redis;
namespace MVC_HealthCheck.Services;
/// <inheritdoc cref="IOrderCache"/>
public sealed class RedisOrderCache : IOrderCache
{
// Keys are grouped under an "orders:" prefix so they are easy to scan in redis-cli.
private const string ReceivedListKey = "orders:list:received";
private const string ProcessedListKey = "orders:list:processed";
private const string ReceivedCounterKey = "orders:count:received";
private const string ProcessedCounterKey = "orders:count:processed";
private const string AcceptedCounterKey = "orders:count:accepted";
private const string RejectedCounterKey = "orders:count:rejected";
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
private readonly IConnectionMultiplexer _multiplexer;
private readonly ILogger<RedisOrderCache> _logger;
private readonly int _listLength;
public RedisOrderCache(
IConnectionMultiplexer multiplexer,
IOptions<OrderProcessingOptions> options,
ILogger<RedisOrderCache> logger)
{
_multiplexer = multiplexer;
_logger = logger;
_listLength = Math.Max(1, options.Value.CacheListLength);
}
public bool IsAvailable => _multiplexer.IsConnected;
public Task RecordReceivedAsync(OrderCacheEntry entry, CancellationToken ct = default) =>
PushAsync(ReceivedListKey, entry, [ReceivedCounterKey]);
public Task RecordProcessedAsync(OrderCacheEntry entry, CancellationToken ct = default)
{
var counters = entry.Status.Equals("Accepted", StringComparison.OrdinalIgnoreCase)
? new[] { ProcessedCounterKey, AcceptedCounterKey }
: new[] { ProcessedCounterKey, RejectedCounterKey };
return PushAsync(ProcessedListKey, entry, counters);
}
public Task<IReadOnlyList<OrderCacheEntry>> GetReceivedAsync(int count = 25, CancellationToken ct = default) =>
RangeAsync(ReceivedListKey, count);
public Task<IReadOnlyList<OrderCacheEntry>> GetProcessedAsync(int count = 25, CancellationToken ct = default) =>
RangeAsync(ProcessedListKey, count);
public async Task<OrderCacheCounters> GetCountersAsync(CancellationToken ct = default)
{
try
{
var db = _multiplexer.GetDatabase();
var values = await db.StringGetAsync(
[
ReceivedCounterKey, ProcessedCounterKey, AcceptedCounterKey, RejectedCounterKey
]);
return new OrderCacheCounters
{
Received = ToLong(values[0]),
Processed = ToLong(values[1]),
Accepted = ToLong(values[2]),
Rejected = ToLong(values[3])
};
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Could not read order counters from Redis.");
return new OrderCacheCounters();
}
}
private async Task PushAsync(string listKey, OrderCacheEntry entry, string[] counterKeys)
{
try
{
var db = _multiplexer.GetDatabase();
var payload = JsonSerializer.Serialize(entry, JsonOptions);
// Newest first, then trim so the list cannot grow without bound.
var batch = db.CreateBatch();
var tasks = new List<Task> { batch.ListLeftPushAsync(listKey, payload) };
tasks.Add(batch.ListTrimAsync(listKey, 0, _listLength - 1));
tasks.AddRange(counterKeys.Select(k => (Task)batch.StringIncrementAsync(k)));
batch.Execute();
await Task.WhenAll(tasks);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Could not write order {OrderNumber} to the Redis list {ListKey}.",
entry.OrderNumber, listKey);
}
}
private async Task<IReadOnlyList<OrderCacheEntry>> RangeAsync(string listKey, int count)
{
try
{
var db = _multiplexer.GetDatabase();
var values = await db.ListRangeAsync(listKey, 0, Math.Max(1, count) - 1);
return values
.Select(v => v.HasValue ? SafeDeserialize(v!) : null)
.Where(e => e is not null)
.Select(e => e!)
.ToList();
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Could not read the Redis list {ListKey}.", listKey);
return [];
}
}
private OrderCacheEntry? SafeDeserialize(string json)
{
try
{
return JsonSerializer.Deserialize<OrderCacheEntry>(json, JsonOptions);
}
catch (JsonException ex)
{
_logger.LogWarning(ex, "Skipping malformed cache entry.");
return null;
}
}
private static long ToLong(RedisValue value) => value.HasValue && value.TryParse(out long l) ? l : 0;
}
Step 16: Create Messaging\RabbitMqConnectionProvider.cs
A RabbitMQ connection is expensive and is meant to be long lived and shared, while channels are cheap and are not thread safe. This class owns the one shared connection used by the publisher, the consumer and the health check.
The reconnection logic is the reason this class exists rather than a plain singleton
registration. GetConnectionAsync checks IsOpen on every call and
rebuilds a dropped connection behind a SemaphoreSlim, so only one caller does the
rebuilding. If the connection were simply registered as a DI singleton, a broker restart would
leave the application holding a dead object forever, and the health check would report a failure
that could never clear itself.
using Microsoft.Extensions.Options;
using MVC_HealthCheck.Options;
using RabbitMQ.Client;
namespace MVC_HealthCheck.Messaging;
/// <summary>
/// Owns the single long-lived RabbitMQ connection shared by the publisher, the consumer and
/// the rabbitmq health check.
///
/// The connection is created lazily and re-created when it drops, so the broker can be
/// restarted underneath a running app and the health check will recover on its own instead of
/// pinning itself to a dead connection.
/// </summary>
public sealed class RabbitMqConnectionProvider : IAsyncDisposable
{
private readonly RabbitMqOptions _options;
private readonly ILogger<RabbitMqConnectionProvider> _logger;
private readonly SemaphoreSlim _gate = new(1, 1);
private IConnection? _connection;
private bool _disposed;
public RabbitMqConnectionProvider(IOptions<RabbitMqOptions> options, ILogger<RabbitMqConnectionProvider> logger)
{
_options = options.Value;
_logger = logger;
}
public string QueueName => _options.QueueName;
public async Task<IConnection> GetConnectionAsync(CancellationToken ct = default)
{
ObjectDisposedException.ThrowIf(_disposed, this);
var existing = _connection;
if (existing is { IsOpen: true })
{
return existing;
}
await _gate.WaitAsync(ct);
try
{
if (_connection is { IsOpen: true })
{
return _connection;
}
if (_connection is not null)
{
_logger.LogWarning("RabbitMQ connection was closed. Reconnecting.");
await SafeDisposeAsync(_connection);
_connection = null;
}
var factory = new ConnectionFactory
{
HostName = _options.HostName,
Port = _options.Port,
UserName = _options.UserName,
Password = _options.Password,
VirtualHost = _options.VirtualHost,
AutomaticRecoveryEnabled = true,
TopologyRecoveryEnabled = true
};
_connection = await factory.CreateConnectionAsync("mvc-healthcheck-order-processing", ct);
_logger.LogInformation("Connected to RabbitMQ at {Host}:{Port}.", _options.HostName, _options.Port);
return _connection;
}
finally
{
_gate.Release();
}
}
/// <summary>Declares the durable work queue. Idempotent, so both publisher and consumer call it.</summary>
public async Task DeclareQueueAsync(IChannel channel, CancellationToken ct = default)
{
await channel.QueueDeclareAsync(
queue: _options.QueueName,
durable: true,
exclusive: false,
autoDelete: false,
arguments: null,
cancellationToken: ct);
}
public async ValueTask DisposeAsync()
{
if (_disposed) return;
_disposed = true;
if (_connection is not null)
{
await SafeDisposeAsync(_connection);
_connection = null;
}
_gate.Dispose();
}
private async Task SafeDisposeAsync(IConnection connection)
{
try
{
await connection.DisposeAsync();
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Ignoring error while disposing a RabbitMQ connection.");
}
}
}
Step 17: Create Messaging\IOrderPublisher.cs
A one method abstraction over publishing, so OrderService does not take a
dependency on the RabbitMQ client types directly.
namespace MVC_HealthCheck.Messaging;
/// <summary>Publishes a captured order onto the RabbitMQ work queue.</summary>
public interface IOrderPublisher
{
Task PublishAsync(OrderMessage message, CancellationToken ct = default);
}
Step 18: Create Messaging\RabbitMqOrderPublisher.cs
The publisher. It takes the shared connection from the provider and opens a fresh channel per publish, which is the recommended pattern: channels are cheap, and they are not safe to share across concurrent requests.
Persistent = true on the message properties, combined with
durable: true on the queue declaration, is what survives a broker restart. You need
both. A durable queue with non persistent messages still loses the messages, and persistent
messages on a non durable queue lose the queue.
Note that this code targets RabbitMQ.Client version 7, where the API is fully
asynchronous. CreateChannelAsync, QueueDeclareAsync and
BasicPublishAsync all replace the synchronous version 6 methods, and
BasicProperties is now instantiated directly rather than created from the
channel.
using System.Text;
using System.Text.Json;
using RabbitMQ.Client;
namespace MVC_HealthCheck.Messaging;
/// <inheritdoc cref="IOrderPublisher"/>
public sealed class RabbitMqOrderPublisher : IOrderPublisher
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
private readonly RabbitMqConnectionProvider _connections;
private readonly ILogger<RabbitMqOrderPublisher> _logger;
public RabbitMqOrderPublisher(RabbitMqConnectionProvider connections, ILogger<RabbitMqOrderPublisher> logger)
{
_connections = connections;
_logger = logger;
}
public async Task PublishAsync(OrderMessage message, CancellationToken ct = default)
{
var connection = await _connections.GetConnectionAsync(ct);
// A channel is cheap and is not thread safe, so each publish gets its own.
await using var channel = await connection.CreateChannelAsync(cancellationToken: ct);
await _connections.DeclareQueueAsync(channel, ct);
var body = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(message, JsonOptions));
var properties = new BasicProperties
{
Persistent = true,
ContentType = "application/json",
MessageId = message.OrderNumber,
Timestamp = new AmqpTimestamp(DateTimeOffset.UtcNow.ToUnixTimeSeconds())
};
await channel.BasicPublishAsync(
exchange: string.Empty,
routingKey: _connections.QueueName,
mandatory: false,
basicProperties: properties,
body: body,
cancellationToken: ct);
_logger.LogInformation("Published order {OrderNumber} to queue {Queue}.",
message.OrderNumber, _connections.QueueName);
}
}
Step 19: Create Services\OrderProcessingWorker.cs
This is the heart of the application: a BackgroundService that consumes from the
queue, applies the advance rule and writes the outcome.
ExecuteAsync wraps the consumer in a retry loop, so if the broker is unavailable
at startup, or goes away later, the worker keeps trying every five seconds instead of dying
silently. BasicQosAsync with a prefetch of 1 means the worker holds one
unacknowledged message at a time.
The acknowledgement strategy is worth understanding. Messages are consumed with
autoAck: false, so a message is only removed from the queue after
BasicAckAsync. If processing throws, the handler calls
BasicNackAsync with requeue: false. Requeueing a message that failed
for a deterministic reason would put it straight back at the head of the queue and loop forever,
a classic poison message. Instead the order is marked Failed in SQL Server, where it
stays visible on the Order Status screen.
Inside ProcessAsync, note that a scope is created per message.
BackgroundService is a singleton, and DbContext is scoped, so the
worker must open its own scope rather than inject a context directly. The write to PostgreSQL is
an upsert guarded by the unique index on order_id, which is what makes a redelivered
message harmless.
using System.Diagnostics;
using System.Text;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using MVC_HealthCheck.Data;
using MVC_HealthCheck.Messaging;
using MVC_HealthCheck.Models;
using MVC_HealthCheck.Options;
using RabbitMQ.Client;
using RabbitMQ.Client.Events;
namespace MVC_HealthCheck.Services;
/// <summary>
/// Consumes orders from RabbitMQ, applies the advance rule, writes the outcome to PostgreSQL,
/// updates the SQL Server order status and pushes the result onto the Redis activity list.
///
/// The rule: an order is Accepted when the advance is at least
/// <see cref="OrderProcessingOptions.MinimumAdvancePercentage"/> (50% by default) of the order
/// total. Anything below that is Rejected.
/// </summary>
public sealed class OrderProcessingWorker : BackgroundService
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
private static readonly TimeSpan ReconnectDelay = TimeSpan.FromSeconds(5);
private readonly RabbitMqConnectionProvider _connections;
private readonly IServiceScopeFactory _scopeFactory;
private readonly OrderProcessingOptions _options;
private readonly ILogger<OrderProcessingWorker> _logger;
public OrderProcessingWorker(
RabbitMqConnectionProvider connections,
IServiceScopeFactory scopeFactory,
IOptions<OrderProcessingOptions> options,
ILogger<OrderProcessingWorker> logger)
{
_connections = connections;
_scopeFactory = scopeFactory;
_options = options.Value;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
// Keep trying to attach to the broker. If RabbitMQ is down at boot the app still starts
// and the rabbitmq health check reports the outage.
while (!stoppingToken.IsCancellationRequested)
{
try
{
await ConsumeAsync(stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
_logger.LogError(ex, "Order consumer dropped out. Retrying in {Delay}s.", ReconnectDelay.TotalSeconds);
try
{
await Task.Delay(ReconnectDelay, stoppingToken);
}
catch (OperationCanceledException)
{
break;
}
}
}
}
private async Task ConsumeAsync(CancellationToken stoppingToken)
{
var connection = await _connections.GetConnectionAsync(stoppingToken);
await using var channel = await connection.CreateChannelAsync(cancellationToken: stoppingToken);
await _connections.DeclareQueueAsync(channel, stoppingToken);
// One unacknowledged message at a time keeps the UI transitions easy to follow.
await channel.BasicQosAsync(prefetchSize: 0, prefetchCount: 1, global: false, cancellationToken: stoppingToken);
var consumer = new AsyncEventingBasicConsumer(channel);
consumer.ReceivedAsync += async (_, args) => await HandleDeliveryAsync(channel, args, stoppingToken);
await channel.BasicConsumeAsync(
queue: _connections.QueueName,
autoAck: false,
consumer: consumer,
cancellationToken: stoppingToken);
_logger.LogInformation("Order processing worker is consuming from {Queue}.", _connections.QueueName);
// Park here until shutdown; the consumer callbacks do the work.
var idle = new TaskCompletionSource();
await using (stoppingToken.Register(() => idle.TrySetResult()))
{
await idle.Task;
}
}
private async Task HandleDeliveryAsync(IChannel channel, BasicDeliverEventArgs args, CancellationToken ct)
{
OrderMessage? message = null;
try
{
var json = Encoding.UTF8.GetString(args.Body.Span);
message = JsonSerializer.Deserialize<OrderMessage>(json, JsonOptions);
if (message is null)
{
_logger.LogWarning("Discarding an unreadable message (delivery tag {Tag}).", args.DeliveryTag);
await channel.BasicNackAsync(args.DeliveryTag, multiple: false, requeue: false, cancellationToken: ct);
return;
}
await ProcessAsync(message, ct);
await channel.BasicAckAsync(args.DeliveryTag, multiple: false, cancellationToken: ct);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to process order {OrderNumber}.", message?.OrderNumber ?? "unknown");
if (message is not null)
{
await MarkFailedAsync(message, ex, ct);
}
// Do not requeue: a poison message would otherwise loop forever. The order is left
// in the Failed state and stays visible on the Order Status screen.
await channel.BasicNackAsync(args.DeliveryTag, multiple: false, requeue: false, cancellationToken: ct);
}
}
private async Task ProcessAsync(OrderMessage message, CancellationToken ct)
{
var stopwatch = Stopwatch.StartNew();
using var scope = _scopeFactory.CreateScope();
var sql = scope.ServiceProvider.GetRequiredService<OrdersDbContext>();
var pg = scope.ServiceProvider.GetRequiredService<ProcessedOrdersDbContext>();
var cache = scope.ServiceProvider.GetRequiredService<IOrderCache>();
var order = await sql.Orders.FirstOrDefaultAsync(o => o.Id == message.OrderId, ct);
if (order is null)
{
_logger.LogWarning("Order {OrderId} is no longer in SQL Server. Dropping the message.", message.OrderId);
return;
}
order.Status = OrderStatus.Processing;
await sql.SaveChangesAsync(ct);
if (_options.SimulatedWorkMilliseconds > 0)
{
await Task.Delay(_options.SimulatedWorkMilliseconds, ct);
}
// --- the business rule ---------------------------------------------------------------
var advancePercentage = message.TotalAmount <= 0
? 0m
: Math.Round(message.AdvanceAmount / message.TotalAmount * 100m, 2);
var accepted = advancePercentage >= _options.MinimumAdvancePercentage;
var reason = accepted
? $"Advance of {advancePercentage:0.##}% meets the required minimum of {_options.MinimumAdvancePercentage:0.##}%."
: $"Advance of {advancePercentage:0.##}% is below the required minimum of {_options.MinimumAdvancePercentage:0.##}%.";
// ---------------------------------------------------------------------------------------
stopwatch.Stop();
var processedOnUtc = DateTime.UtcNow;
// Idempotent: a redelivery updates the existing row rather than inserting a duplicate.
var processed = await pg.ProcessedOrders.FirstOrDefaultAsync(p => p.OrderId == message.OrderId, ct);
var isNew = processed is null;
processed ??= new ProcessedOrder { OrderId = message.OrderId };
processed.OrderNumber = message.OrderNumber;
processed.CustomerId = message.CustomerId;
processed.CustomerName = message.CustomerName;
processed.TotalAmount = message.TotalAmount;
processed.AdvanceAmount = message.AdvanceAmount;
processed.AdvancePercentage = advancePercentage;
processed.Decision = accepted ? "Accepted" : "Rejected";
processed.Reason = reason;
processed.ItemCount = message.ItemCount;
processed.OrderPlacedOnUtc = DateTime.SpecifyKind(message.OrderPlacedOnUtc, DateTimeKind.Utc);
processed.ProcessedOnUtc = processedOnUtc;
processed.ProcessingDurationMs = stopwatch.ElapsedMilliseconds;
if (isNew)
{
pg.ProcessedOrders.Add(processed);
}
await pg.SaveChangesAsync(ct);
order.Status = accepted ? OrderStatus.Accepted : OrderStatus.Rejected;
order.StatusMessage = reason;
order.ProcessedOn = processedOnUtc;
await sql.SaveChangesAsync(ct);
await cache.RecordProcessedAsync(new OrderCacheEntry
{
OrderId = message.OrderId,
OrderNumber = message.OrderNumber,
CustomerName = message.CustomerName,
TotalAmount = message.TotalAmount,
AdvanceAmount = message.AdvanceAmount,
AdvancePercentage = advancePercentage,
Status = processed.Decision,
Reason = reason,
TimestampUtc = processedOnUtc
}, ct);
_logger.LogInformation("Order {OrderNumber} {Decision} ({Percentage:0.##}% advance).",
message.OrderNumber, processed.Decision, advancePercentage);
}
private async Task MarkFailedAsync(OrderMessage message, Exception cause, CancellationToken ct)
{
try
{
using var scope = _scopeFactory.CreateScope();
var sql = scope.ServiceProvider.GetRequiredService<OrdersDbContext>();
var order = await sql.Orders.FirstOrDefaultAsync(o => o.Id == message.OrderId, ct);
if (order is null) return;
order.Status = OrderStatus.Failed;
order.StatusMessage = $"Processing failed: {cause.Message}";
await sql.SaveChangesAsync(ct);
}
catch (Exception ex)
{
_logger.LogError(ex, "Could not mark order {OrderNumber} as failed.", message.OrderNumber);
}
}
}
Step 20: Create Services\OrderService.cs
Order capture. This runs inside the web request and does three things in order: write to SQL Server, record the order on the Redis activity list, then publish to RabbitMQ.
The first thing CreateOrderAsync does is throw away the prices from the form and
re-read them from the catalogue. The posted form supplies only product ids and quantities. This
is not a theoretical concern: if the client could supply the unit price, it could supply a total
of its own choosing, and the accept or reject decision depends directly on that total.
The ordering of the last two steps also matters. The order is already durable in SQL Server
before the publish is attempted. If the broker is unreachable, the catch block marks the order
Failed with the broker error in the status message, rather than losing a capture
that the customer believes succeeded.
using Microsoft.EntityFrameworkCore;
using MVC_HealthCheck.Data;
using MVC_HealthCheck.Messaging;
using MVC_HealthCheck.Models;
using MVC_HealthCheck.ViewModels;
namespace MVC_HealthCheck.Services;
/// <summary>
/// Order capture: writes the order to SQL Server, records it on the Redis "received" list and
/// hands it to RabbitMQ for the background worker to decide on.
/// </summary>
public sealed class OrderService
{
private readonly OrdersDbContext _db;
private readonly IOrderPublisher _publisher;
private readonly IOrderCache _cache;
private readonly ILogger<OrderService> _logger;
public OrderService(
OrdersDbContext db,
IOrderPublisher publisher,
IOrderCache cache,
ILogger<OrderService> logger)
{
_db = db;
_publisher = publisher;
_cache = cache;
_logger = logger;
}
public Task<List<Customer>> GetCustomersAsync(CancellationToken ct = default) =>
_db.Customers.AsNoTracking().OrderBy(c => c.Name).ToListAsync(ct);
public Task<List<Product>> GetProductsAsync(CancellationToken ct = default) =>
_db.Products.AsNoTracking().OrderBy(p => p.Name).ToListAsync(ct);
/// <summary>
/// Persists the order and publishes it. Pricing is recomputed from the catalogue rather than
/// taken from the posted form, so a tampered price cannot change the accept/reject outcome.
/// </summary>
public async Task<Order> CreateOrderAsync(CreateOrderViewModel model, CancellationToken ct = default)
{
var lines = model.Items
.Where(i => i.ProductId > 0 && i.Quantity > 0)
.GroupBy(i => i.ProductId)
.Select(g => new { ProductId = g.Key, Quantity = g.Sum(x => x.Quantity) })
.ToList();
if (lines.Count == 0)
{
throw new InvalidOperationException("An order needs at least one product line.");
}
var productIds = lines.Select(l => l.ProductId).ToList();
var products = await _db.Products
.Where(p => productIds.Contains(p.Id))
.ToDictionaryAsync(p => p.Id, ct);
var missing = productIds.Where(id => !products.ContainsKey(id)).ToList();
if (missing.Count > 0)
{
throw new InvalidOperationException($"Unknown product id(s): {string.Join(", ", missing)}.");
}
var customer = await _db.Customers.FirstOrDefaultAsync(c => c.Id == model.CustomerId, ct)
?? throw new InvalidOperationException($"Unknown customer id {model.CustomerId}.");
var order = new Order
{
OrderNumber = GenerateOrderNumber(),
CustomerId = customer.Id,
OrderDate = DateTime.UtcNow,
AdvanceAmount = model.AdvanceAmount,
Status = OrderStatus.Received,
StatusMessage = "Captured, awaiting processing."
};
foreach (var line in lines)
{
var product = products[line.ProductId];
order.OrderDetails.Add(new OrderDetail
{
ProductId = product.Id,
Quantity = line.Quantity,
UnitPrice = product.UnitPrice,
LineTotal = product.UnitPrice * line.Quantity
});
}
order.TotalAmount = order.OrderDetails.Sum(d => d.LineTotal);
_db.Orders.Add(order);
await _db.SaveChangesAsync(ct);
var cacheEntry = new OrderCacheEntry
{
OrderId = order.Id,
OrderNumber = order.OrderNumber,
CustomerName = customer.Name,
TotalAmount = order.TotalAmount,
AdvanceAmount = order.AdvanceAmount,
AdvancePercentage = order.AdvancePercentage,
Status = "Received",
TimestampUtc = order.OrderDate
};
await _cache.RecordReceivedAsync(cacheEntry, ct);
try
{
await _publisher.PublishAsync(new OrderMessage
{
OrderId = order.Id,
OrderNumber = order.OrderNumber,
CustomerId = customer.Id,
CustomerName = customer.Name,
TotalAmount = order.TotalAmount,
AdvanceAmount = order.AdvanceAmount,
ItemCount = order.OrderDetails.Count,
OrderPlacedOnUtc = order.OrderDate
}, ct);
order.Status = OrderStatus.Queued;
order.StatusMessage = "Queued for processing.";
}
catch (Exception ex)
{
// The order is already durable in SQL Server. Surface the broker problem on the
// status screen instead of losing the capture.
_logger.LogError(ex, "Order {OrderNumber} was saved but could not be queued.", order.OrderNumber);
order.Status = OrderStatus.Failed;
order.StatusMessage = $"Saved, but publishing to RabbitMQ failed: {ex.Message}";
}
await _db.SaveChangesAsync(ct);
return order;
}
public Task<List<Order>> GetOrdersAsync(OrderStatus? status = null, CancellationToken ct = default)
{
var query = _db.Orders
.AsNoTracking()
.Include(o => o.Customer)
.Include(o => o.OrderDetails)
.ThenInclude(d => d.Product)
.AsQueryable();
if (status.HasValue)
{
query = query.Where(o => o.Status == status.Value);
}
return query.OrderByDescending(o => o.Id).ToListAsync(ct);
}
public Task<Order?> GetOrderAsync(int id, CancellationToken ct = default) =>
_db.Orders
.AsNoTracking()
.Include(o => o.Customer)
.Include(o => o.OrderDetails)
.ThenInclude(d => d.Product)
.FirstOrDefaultAsync(o => o.Id == id, ct);
/// <summary>Short, sortable, collision-resistant enough for a single-node demo.</summary>
private static string GenerateOrderNumber() =>
$"ORD-{DateTime.UtcNow:yyyyMMdd}-{Guid.NewGuid().ToString("N")[..6].ToUpperInvariant()}";
}
Step 21: Create Services\ProcessedOrderService.cs
The read side for PostgreSQL, serving both the Processed Orders screen and the Accepted and Rejected screen.
It catches database errors and returns a view model carrying a DataError string
rather than letting the exception become a 500. That way a PostgreSQL outage renders as an
explanatory banner on an otherwise working page, which is consistent with how Redis failures are
handled.
using Microsoft.EntityFrameworkCore;
using MVC_HealthCheck.Data;
using MVC_HealthCheck.Models;
using MVC_HealthCheck.ViewModels;
namespace MVC_HealthCheck.Services;
/// <summary>Reads the PostgreSQL outcome table for the Processed and Accepted/Rejected screens.</summary>
public sealed class ProcessedOrderService
{
private readonly ProcessedOrdersDbContext _db;
private readonly ILogger<ProcessedOrderService> _logger;
public ProcessedOrderService(ProcessedOrdersDbContext db, ILogger<ProcessedOrderService> logger)
{
_db = db;
_logger = logger;
}
/// <param name="decision">null for everything, otherwise "Accepted" or "Rejected".</param>
public async Task<ProcessedOrdersViewModel> GetAsync(string? decision, CancellationToken ct = default)
{
try
{
var all = await _db.ProcessedOrders
.AsNoTracking()
.OrderByDescending(p => p.ProcessedOnUtc)
.ToListAsync(ct);
var filtered = string.IsNullOrWhiteSpace(decision)
? all
: all.Where(p => string.Equals(p.Decision, decision, StringComparison.OrdinalIgnoreCase)).ToList();
return new ProcessedOrdersViewModel
{
ProcessedOrders = filtered,
Decision = decision,
AcceptedCount = all.Count(p => p.IsAccepted),
RejectedCount = all.Count(p => !p.IsAccepted),
AcceptedValue = all.Where(p => p.IsAccepted).Sum(p => p.TotalAmount),
RejectedValue = all.Where(p => !p.IsAccepted).Sum(p => p.TotalAmount)
};
}
catch (Exception ex)
{
// PostgreSQL being down is already reported by /health; the screen should say so
// rather than return a 500.
_logger.LogError(ex, "Could not read processed orders from PostgreSQL.");
return new ProcessedOrdersViewModel
{
Decision = decision,
DataError = $"PostgreSQL is not reachable: {ex.Message}"
};
}
}
public async Task<ProcessedOrder?> GetByOrderIdAsync(int orderId, CancellationToken ct = default)
{
try
{
return await _db.ProcessedOrders.AsNoTracking().FirstOrDefaultAsync(p => p.OrderId == orderId, ct);
}
catch (Exception ex)
{
_logger.LogError(ex, "Could not read processed order for order {OrderId}.", orderId);
return null;
}
}
}
Step 22: Create ViewModels\OrderViewModels.cs
The view models for all four screens, kept in one file because they are small and closely related.
CreateOrderViewModel implements IValidatableObject to express a rule
that attributes cannot: the order needs at least one product line. Attribute validation works
property by property, so a cross property or collection level rule needs
Validate.
using System.ComponentModel.DataAnnotations;
using Microsoft.AspNetCore.Mvc.Rendering;
using MVC_HealthCheck.Models;
using MVC_HealthCheck.Services;
namespace MVC_HealthCheck.ViewModels;
/// <summary>One product line on the create-order form.</summary>
public class OrderLineInput
{
[Display(Name = "Product")]
public int ProductId { get; set; }
[Display(Name = "Quantity")]
[Range(1, 10_000, ErrorMessage = "Quantity must be between 1 and 10,000.")]
public int Quantity { get; set; } = 1;
}
/// <summary>Backs the Create Order screen.</summary>
public class CreateOrderViewModel : IValidatableObject
{
[Display(Name = "Customer")]
[Range(1, int.MaxValue, ErrorMessage = "Please choose a customer.")]
public int CustomerId { get; set; }
[Display(Name = "Advance Paid")]
[Range(0, 100_000_000, ErrorMessage = "Advance must be zero or more.")]
[DataType(DataType.Currency)]
public decimal AdvanceAmount { get; set; }
public List<OrderLineInput> Items { get; set; } = [new OrderLineInput()];
// Populated by the controller for the dropdowns.
public IEnumerable<SelectListItem> CustomerOptions { get; set; } = [];
public IEnumerable<Product> Products { get; set; } = [];
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
if (Items is null || Items.All(i => i.ProductId <= 0))
{
yield return new ValidationResult(
"Add at least one product line.", [nameof(Items)]);
}
}
}
/// <summary>Backs the Order Status screen (SQL Server side of the pipeline).</summary>
public class OrderStatusViewModel
{
public IReadOnlyList<Order> Orders { get; init; } = [];
public OrderStatus? Filter { get; init; }
public OrderCacheCounters Counters { get; init; } = new();
public bool CacheAvailable { get; init; }
}
/// <summary>Backs both the Processed Orders screen and the Accepted/Rejected screen (PostgreSQL side).</summary>
public class ProcessedOrdersViewModel
{
public IReadOnlyList<ProcessedOrder> ProcessedOrders { get; init; } = [];
/// <summary>null = all, "Accepted", or "Rejected".</summary>
public string? Decision { get; init; }
public int AcceptedCount { get; init; }
public int RejectedCount { get; init; }
public decimal AcceptedValue { get; init; }
public decimal RejectedValue { get; init; }
/// <summary>Set when PostgreSQL could not be reached, so the view can explain the empty table.</summary>
public string? DataError { get; init; }
}
/// <summary>Backs the dashboard, which is driven mostly by the Redis activity lists.</summary>
public class DashboardViewModel
{
public OrderCacheCounters Counters { get; init; } = new();
public IReadOnlyList<OrderCacheEntry> RecentReceived { get; init; } = [];
public IReadOnlyList<OrderCacheEntry> RecentProcessed { get; init; } = [];
public bool CacheAvailable { get; init; }
public int PendingInPipeline { get; init; }
}
Step 23: Create HealthChecks\HealthCheckResponseWriter.cs
By default, MapHealthChecks writes a plain text body containing a single word:
Healthy or Unhealthy. That is enough for a load balancer, which only
reads the status code, but it is useless to a human trying to work out which dependency broke.
This writer replaces that body with indented JSON naming every check, its status, how long it
took and any error message. It is wired up through the ResponseWriter property of
HealthCheckOptions.
One security note on this class. It writes entry.Value.Exception?.Message and not
the full exception. A stack trace on an endpoint that is often reachable from outside the cluster
leaks file paths, library versions and internal structure. The message alone is enough to
diagnose, and in a system exposed to the internet you would go further and keep the detailed
variant behind authentication.
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Diagnostics.HealthChecks;
namespace MVC_HealthCheck.HealthChecks;
/// <summary>
/// Writes a readable JSON document for the /health endpoint instead of the default
/// plain-text "Healthy" body, so an outage names the dependency that caused it.
/// </summary>
public static class HealthCheckResponseWriter
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
WriteIndented = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
public static Task WriteAsync(HttpContext context, HealthReport report)
{
context.Response.ContentType = "application/json; charset=utf-8";
var payload = new
{
status = report.Status.ToString(),
totalDurationMs = Math.Round(report.TotalDuration.TotalMilliseconds, 2),
checkedAtUtc = DateTime.UtcNow,
entries = report.Entries.Select(entry => new
{
name = entry.Key,
status = entry.Value.Status.ToString(),
description = entry.Value.Description,
durationMs = Math.Round(entry.Value.Duration.TotalMilliseconds, 2),
tags = entry.Value.Tags,
// Only the message: a full stack trace on a public endpoint leaks internals.
error = entry.Value.Exception?.Message,
data = entry.Value.Data.Count == 0 ? null : entry.Value.Data
})
};
return context.Response.WriteAsync(JsonSerializer.Serialize(payload, JsonOptions));
}
}
Step 24: Create Controllers\OrdersController.cs
The controller for all four required screens, plus a details view.
The Decisions action is the one worth a second look. It does not pass the query
string straight to the service. It maps it through a switch expression that accepts
only "accepted" or "rejected" and turns anything else into null, meaning no filter.
Normalising untrusted input to a known set before it reaches the data layer is cheap and removes
a whole class of problem.
Create is split into a GET that renders an empty form and a POST marked
[ValidateAntiForgeryToken]. On a validation failure it repopulates the dropdown
lookups before redisplaying the view, which is easy to forget and produces an empty product list
on the second attempt.
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Rendering;
using MVC_HealthCheck.Models;
using MVC_HealthCheck.Services;
using MVC_HealthCheck.ViewModels;
namespace MVC_HealthCheck.Controllers;
public class OrdersController : Controller
{
private readonly OrderService _orders;
private readonly ProcessedOrderService _processed;
private readonly IOrderCache _cache;
private readonly ILogger<OrdersController> _logger;
public OrdersController(
OrderService orders,
ProcessedOrderService processed,
IOrderCache cache,
ILogger<OrdersController> logger)
{
_orders = orders;
_processed = processed;
_cache = cache;
_logger = logger;
}
// ---------------------------------------------------------------- Create Orders ----------
[HttpGet]
public async Task<IActionResult> Create(CancellationToken ct)
{
var model = new CreateOrderViewModel();
await PopulateLookupsAsync(model, ct);
return View(model);
}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Create(CreateOrderViewModel model, CancellationToken ct)
{
if (!ModelState.IsValid)
{
await PopulateLookupsAsync(model, ct);
return View(model);
}
try
{
var order = await _orders.CreateOrderAsync(model, ct);
TempData["Flash"] = order.Status == OrderStatus.Failed
? $"Order {order.OrderNumber} was saved but could not be queued. See its status for details."
: $"Order {order.OrderNumber} was created and queued for processing.";
TempData["FlashKind"] = order.Status == OrderStatus.Failed ? "warning" : "success";
return RedirectToAction(nameof(Status));
}
catch (Exception ex)
{
_logger.LogError(ex, "Order creation failed.");
ModelState.AddModelError(string.Empty, ex.Message);
await PopulateLookupsAsync(model, ct);
return View(model);
}
}
// ------------------------------------------------------------- Checking Order Status -----
[HttpGet]
public async Task<IActionResult> Status(OrderStatus? status, CancellationToken ct)
{
var model = new OrderStatusViewModel
{
Orders = await _orders.GetOrdersAsync(status, ct),
Filter = status,
Counters = await _cache.GetCountersAsync(ct),
CacheAvailable = _cache.IsAvailable
};
return View(model);
}
[HttpGet]
public async Task<IActionResult> Details(int id, CancellationToken ct)
{
var order = await _orders.GetOrderAsync(id, ct);
if (order is null)
{
return NotFound();
}
ViewBag.Processed = await _processed.GetByOrderIdAsync(id, ct);
return View(order);
}
// ------------------------------------------------------------ Viewing Processed Orders ---
[HttpGet]
public async Task<IActionResult> Processed(CancellationToken ct)
{
var model = await _processed.GetAsync(decision: null, ct);
return View(model);
}
// ------------------------------------------------------- Viewing Accepted and Rejected ---
[HttpGet]
public async Task<IActionResult> Decisions(string? decision, CancellationToken ct)
{
// Guard the filter so an arbitrary query string cannot reach the database.
var normalised = decision?.Trim().ToLowerInvariant() switch
{
"accepted" => "Accepted",
"rejected" => "Rejected",
_ => null
};
var model = await _processed.GetAsync(normalised, ct);
return View(model);
}
private async Task PopulateLookupsAsync(CreateOrderViewModel model, CancellationToken ct)
{
var customers = await _orders.GetCustomersAsync(ct);
model.CustomerOptions = customers.Select(c => new SelectListItem
{
Value = c.Id.ToString(),
Text = $"{c.Name} ({c.City})",
Selected = c.Id == model.CustomerId
});
model.Products = await _orders.GetProductsAsync(ct);
if (model.Items.Count == 0)
{
model.Items.Add(new OrderLineInput());
}
}
}
Step 25: Create Controllers\HomeController.cs
The dashboard controller. The interesting line is the injection of
HealthCheckService.
That is the same service behind the /health endpoint, and it can be called
directly from application code. The dashboard runs
CheckHealthAsync in process and renders the resulting
HealthReport, so the home page shows dependency health without making an HTTP call
back to itself.
using System.Diagnostics;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using MVC_HealthCheck.Models;
using MVC_HealthCheck.Services;
using MVC_HealthCheck.ViewModels;
namespace MVC_HealthCheck.Controllers;
public class HomeController : Controller
{
private readonly IOrderCache _cache;
private readonly OrderService _orders;
private readonly HealthCheckService _healthChecks;
public HomeController(IOrderCache cache, OrderService orders, HealthCheckService healthChecks)
{
_cache = cache;
_orders = orders;
_healthChecks = healthChecks;
}
public async Task<IActionResult> Index(CancellationToken ct)
{
// Run the registered health checks in-process so the dashboard can render the same
// results that /health returns, without an extra HTTP hop.
ViewBag.Health = await _healthChecks.CheckHealthAsync(ct);
var received = await _cache.GetReceivedAsync(10, ct);
var processed = await _cache.GetProcessedAsync(10, ct);
var pending = (await _orders.GetOrdersAsync(OrderStatus.Queued, ct)).Count
+ (await _orders.GetOrdersAsync(OrderStatus.Processing, ct)).Count;
return View(new DashboardViewModel
{
Counters = await _cache.GetCountersAsync(ct),
RecentReceived = received,
RecentProcessed = processed,
CacheAvailable = _cache.IsAvailable,
PendingInPipeline = pending
});
}
public IActionResult Privacy() => View();
[ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)]
public IActionResult Error() =>
View(new ErrorViewModel { RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier });
}
Step 26: Create Views\_ViewImports.cshtml
Adding the Microsoft.Extensions.Diagnostics.HealthChecks namespace here is what
lets the dashboard view refer to HealthReport and HealthStatus without
a fully qualified name.
@using MVC_HealthCheck
@using MVC_HealthCheck.Models
@using MVC_HealthCheck.Services
@using MVC_HealthCheck.ViewModels
@using Microsoft.Extensions.Diagnostics.HealthChecks
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
Step 27: Create Views\Shared\_Layout.cshtml
The shared layout, carrying the navigation for the four screens and direct links to the raw
/health JSON and the Health UI. It also renders the TempData flash message set by
the controller after a successful post.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>@ViewData["Title"] - Order Processing</title>
<script type="importmap"></script>
<link rel="stylesheet" href="~/lib/bootstrap/dist/css/bootstrap.min.css" />
<link rel="stylesheet" href="~/css/site.css" asp-append-version="true" />
<link rel="stylesheet" href="~/MVC_HealthCheck.styles.css" asp-append-version="true" />
</head>
<body>
<header>
<nav class="navbar navbar-expand-lg navbar-dark bg-dark border-bottom box-shadow mb-4">
<div class="container-fluid">
<a class="navbar-brand fw-semibold" asp-controller="Home" asp-action="Index">
Order Processing
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target=".navbar-collapse"
aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="navbar-collapse collapse d-lg-inline-flex justify-content-between">
<ul class="navbar-nav flex-grow-1">
<li class="nav-item">
<a class="nav-link" asp-controller="Home" asp-action="Index">Dashboard</a>
</li>
<li class="nav-item">
<a class="nav-link" asp-controller="Orders" asp-action="Create">Create Order</a>
</li>
<li class="nav-item">
<a class="nav-link" asp-controller="Orders" asp-action="Status">Order Status</a>
</li>
<li class="nav-item">
<a class="nav-link" asp-controller="Orders" asp-action="Processed">Processed Orders</a>
</li>
<li class="nav-item">
<a class="nav-link" asp-controller="Orders" asp-action="Decisions">Accepted / Rejected</a>
</li>
</ul>
<ul class="navbar-nav">
<li class="nav-item">
<a class="nav-link" href="/health" target="_blank" rel="noopener">/health JSON</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/health-ui" target="_blank" rel="noopener">Health UI</a>
</li>
</ul>
</div>
</div>
</nav>
</header>
<div class="container-fluid px-4">
<main role="main" class="pb-3">
@if (TempData["Flash"] is string flash && !string.IsNullOrWhiteSpace(flash))
{
var kind = TempData["FlashKind"] as string ?? "success";
<div class="alert alert-@kind alert-dismissible fade show" role="alert">
@flash
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
}
@RenderBody()
</main>
</div>
<footer class="border-top footer text-muted mt-4">
<div class="container-fluid px-4">
SQL Server → RabbitMQ → Background Worker → PostgreSQL, with Redis for live activity.
</div>
</footer>
<script src="~/lib/jquery/dist/jquery.min.js"></script>
<script src="~/lib/bootstrap/dist/js/bootstrap.bundle.min.js"></script>
<script src="~/js/site.js" asp-append-version="true"></script>
@await RenderSectionAsync("Scripts", required: false)
</body>
</html>
Step 28: Create Views\Home\Index.cshtml
The dashboard. The top card iterates HealthReport.Entries and renders one tile
per dependency, mapping HealthStatus to a Bootstrap colour through a local
function.
Below that are the Redis counters and the two activity lists. When
Model.CacheAvailable is false the view explains that the lists are empty because
Redis is unreachable, which is the user facing half of the degradation strategy.
@model DashboardViewModel
@{
ViewData["Title"] = "Dashboard";
var report = ViewBag.Health as HealthReport;
string BadgeFor(HealthStatus s) => s switch
{
HealthStatus.Healthy => "success",
HealthStatus.Degraded => "warning",
_ => "danger"
};
}
<div class="d-flex justify-content-between align-items-center mb-3">
<h1 class="h3 mb-0">Order Processing Dashboard</h1>
<div>
<a class="btn btn-sm btn-outline-secondary" asp-controller="Home" asp-action="Index">Refresh</a>
<a class="btn btn-sm btn-primary" asp-controller="Orders" asp-action="Create">Create Order</a>
</div>
</div>
<!-- ------------------------------------------------------------------ dependency health -->
<div class="card mb-4">
<div class="card-header d-flex justify-content-between align-items-center">
<span class="fw-semibold">Dependency Health</span>
@if (report is not null)
{
<span class="badge bg-@BadgeFor(report.Status)">
Overall: @report.Status (@report.TotalDuration.TotalMilliseconds.ToString("0") ms)
</span>
}
</div>
<div class="card-body">
@if (report is null)
{
<p class="text-muted mb-0">Health report unavailable.</p>
}
else
{
<div class="row g-3">
@foreach (var entry in report.Entries.OrderBy(e => e.Key))
{
<div class="col-12 col-md-6 col-xl-3">
<div class="border rounded p-3 h-100 border-@BadgeFor(entry.Value.Status)">
<div class="d-flex justify-content-between align-items-start">
<span class="fw-semibold text-uppercase small">@entry.Key</span>
<span class="badge bg-@BadgeFor(entry.Value.Status)">@entry.Value.Status</span>
</div>
<div class="small text-muted mt-2">
@entry.Value.Duration.TotalMilliseconds.ToString("0") ms
</div>
@if (entry.Value.Exception is not null)
{
<div class="small text-danger mt-2 text-break">@entry.Value.Exception.Message</div>
}
</div>
</div>
}
</div>
}
</div>
<div class="card-footer small text-muted">
Probed by AspNetCore.Diagnostics.HealthChecks. Raw JSON at
<a href="/health" target="_blank" rel="noopener">/health</a>,
readiness at <a href="/health/ready" target="_blank" rel="noopener">/health/ready</a>,
polling UI at <a href="/health-ui" target="_blank" rel="noopener">/health-ui</a>.
</div>
</div>
<!-- ---------------------------------------------------------------------- redis counters -->
@if (!Model.CacheAvailable)
{
<div class="alert alert-warning">
Redis is not reachable, so the counters and activity lists below are empty.
Orders are still captured in SQL Server.
</div>
}
<div class="row g-3 mb-4">
<div class="col-6 col-lg-3">
<div class="card text-bg-primary h-100">
<div class="card-body">
<div class="small text-uppercase">Received</div>
<div class="display-6">@Model.Counters.Received</div>
</div>
</div>
</div>
<div class="col-6 col-lg-3">
<div class="card text-bg-secondary h-100">
<div class="card-body">
<div class="small text-uppercase">Processed</div>
<div class="display-6">@Model.Counters.Processed</div>
</div>
</div>
</div>
<div class="col-6 col-lg-3">
<div class="card text-bg-success h-100">
<div class="card-body">
<div class="small text-uppercase">Accepted</div>
<div class="display-6">@Model.Counters.Accepted</div>
</div>
</div>
</div>
<div class="col-6 col-lg-3">
<div class="card text-bg-danger h-100">
<div class="card-body">
<div class="small text-uppercase">Rejected</div>
<div class="display-6">@Model.Counters.Rejected</div>
</div>
</div>
</div>
</div>
@if (Model.PendingInPipeline > 0)
{
<div class="alert alert-info">
@Model.PendingInPipeline order(s) are queued or being processed right now. Refresh in a moment.
</div>
}
<!-- --------------------------------------------------------------- redis activity lists -->
<div class="row g-4">
<div class="col-12 col-xl-6">
<div class="card h-100">
<div class="card-header fw-semibold">Received Orders <span class="text-muted small">(Redis)</span></div>
<div class="table-responsive">
<table class="table table-sm table-striped mb-0">
<thead>
<tr>
<th>Order</th>
<th>Customer</th>
<th class="text-end">Total</th>
<th class="text-end">Advance</th>
<th class="text-end">%</th>
<th>At (UTC)</th>
</tr>
</thead>
<tbody>
@if (Model.RecentReceived.Count == 0)
{
<tr><td colspan="6" class="text-center text-muted py-3">No orders received yet.</td></tr>
}
@foreach (var e in Model.RecentReceived)
{
<tr>
<td><a asp-controller="Orders" asp-action="Details" asp-route-id="@e.OrderId">@e.OrderNumber</a></td>
<td>@e.CustomerName</td>
<td class="text-end">@e.TotalAmount.ToString("N2")</td>
<td class="text-end">@e.AdvanceAmount.ToString("N2")</td>
<td class="text-end">@e.AdvancePercentage.ToString("0.##")</td>
<td class="small">@e.TimestampUtc.ToString("HH:mm:ss")</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
<div class="col-12 col-xl-6">
<div class="card h-100">
<div class="card-header fw-semibold">Processed Orders <span class="text-muted small">(Redis)</span></div>
<div class="table-responsive">
<table class="table table-sm table-striped mb-0">
<thead>
<tr>
<th>Order</th>
<th>Customer</th>
<th class="text-end">Total</th>
<th class="text-end">%</th>
<th>Decision</th>
<th>At (UTC)</th>
</tr>
</thead>
<tbody>
@if (Model.RecentProcessed.Count == 0)
{
<tr><td colspan="6" class="text-center text-muted py-3">Nothing processed yet.</td></tr>
}
@foreach (var e in Model.RecentProcessed)
{
<tr>
<td><a asp-controller="Orders" asp-action="Details" asp-route-id="@e.OrderId">@e.OrderNumber</a></td>
<td>@e.CustomerName</td>
<td class="text-end">@e.TotalAmount.ToString("N2")</td>
<td class="text-end">@e.AdvancePercentage.ToString("0.##")</td>
<td>
<span class="badge bg-@(e.Status == "Accepted" ? "success" : "danger")">@e.Status</span>
</td>
<td class="small">@e.TimestampUtc.ToString("HH:mm:ss")</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
</div>
Step 29: Create Views\Orders\Create.cshtml
The Create Order screen, and the most involved view in the application.
Order lines are rendered in an indexed loop producing names like
Items[0].ProductId, which is the format the MVC model binder expects for a
collection. The JavaScript at the bottom adds and removes rows, and its reindex
function renumbers every row after each change. That renumbering is not cosmetic: the model
binder stops at the first gap in the sequence, so deleting row 1 without reindexing would make
the binder silently ignore every line after it.
The advance meter recalculates on every input event and tells the user, before they submit, whether the order will be accepted or rejected. It is a preview of the rule the background worker will apply, not the rule itself. The server recomputes the decision independently.
@model CreateOrderViewModel
@{
ViewData["Title"] = "Create Order";
}
<h1 class="h3 mb-3">Create Order</h1>
<div asp-validation-summary="All" class="text-danger mb-3"></div>
<form asp-action="Create" method="post" id="orderForm">
@Html.AntiForgeryToken()
<div class="row g-4">
<div class="col-12 col-lg-8">
<div class="card mb-3">
<div class="card-header fw-semibold">Customer</div>
<div class="card-body">
<label asp-for="CustomerId" class="form-label"></label>
<select asp-for="CustomerId" asp-items="Model.CustomerOptions" class="form-select">
<option value="">-- choose a customer --</option>
</select>
<span asp-validation-for="CustomerId" class="text-danger small"></span>
</div>
</div>
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<span class="fw-semibold">Products</span>
<button type="button" class="btn btn-sm btn-outline-primary" id="addLine">Add line</button>
</div>
<div class="table-responsive">
<table class="table mb-0 align-middle" id="linesTable">
<thead>
<tr>
<th style="width:50%">Product</th>
<th style="width:15%">Qty</th>
<th style="width:15%" class="text-end">Unit Price</th>
<th style="width:15%" class="text-end">Line Total</th>
<th style="width:5%"></th>
</tr>
</thead>
<tbody>
@for (var i = 0; i < Model.Items.Count; i++)
{
<tr class="order-line">
<td>
<select name="Items[@i].ProductId" class="form-select product-select">
<option value="0" data-price="0">-- choose a product --</option>
@foreach (var p in Model.Products)
{
<option value="@p.Id" data-price="@p.UnitPrice.ToString(System.Globalization.CultureInfo.InvariantCulture)"
selected="@(Model.Items[i].ProductId == p.Id)">
@p.Name (@p.Sku)
</option>
}
</select>
</td>
<td>
<input name="Items[@i].Quantity" type="number" min="1" step="1"
value="@Model.Items[i].Quantity" class="form-control qty-input" />
</td>
<td class="text-end unit-price">0.00</td>
<td class="text-end line-total">0.00</td>
<td class="text-end">
<button type="button" class="btn btn-sm btn-outline-danger remove-line">×</button>
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
<div class="col-12 col-lg-4">
<div class="card">
<div class="card-header fw-semibold">Payment</div>
<div class="card-body">
<dl class="row mb-3">
<dt class="col-6">Order Total</dt>
<dd class="col-6 text-end fs-5" id="orderTotal">0.00</dd>
</dl>
<label asp-for="AdvanceAmount" class="form-label"></label>
<input asp-for="AdvanceAmount" type="number" step="0.01" min="0" class="form-control" id="advanceInput" />
<span asp-validation-for="AdvanceAmount" class="text-danger small"></span>
<div class="mt-3">
<div class="d-flex justify-content-between small">
<span>Advance as % of total</span>
<span id="advancePctLabel">0%</span>
</div>
<div class="progress mt-1" style="height:1.25rem">
<div class="progress-bar" id="advanceBar" role="progressbar" style="width:0%">0%</div>
</div>
<div class="form-text" id="advanceHint">
The background worker accepts an order when the advance is at least 50% of the total.
</div>
</div>
<div class="d-grid gap-2 mt-4">
<button type="submit" class="btn btn-primary">Place Order</button>
<a asp-action="Status" class="btn btn-outline-secondary">Cancel</a>
</div>
</div>
</div>
<div class="alert alert-light border mt-3 small">
The order is written to <strong>SQL Server</strong>, pushed to <strong>RabbitMQ</strong>,
picked up by the background worker, and the decision is stored in
<strong>PostgreSQL</strong>. Live counts come from <strong>Redis</strong>.
</div>
</div>
</div>
</form>
@section Scripts {
<partial name="_ValidationScriptsPartial" />
<script>
(function () {
const table = document.querySelector('#linesTable tbody');
const totalEl = document.getElementById('orderTotal');
const advanceInput = document.getElementById('advanceInput');
const advanceBar = document.getElementById('advanceBar');
const advancePctLabel = document.getElementById('advancePctLabel');
const advanceHint = document.getElementById('advanceHint');
// Indexes must stay contiguous or the MVC model binder stops at the first gap.
function reindex() {
table.querySelectorAll('tr.order-line').forEach(function (row, i) {
const select = row.querySelector('.product-select');
const qty = row.querySelector('.qty-input');
select.name = 'Items[' + i + '].ProductId';
qty.name = 'Items[' + i + '].Quantity';
});
}
function recalc() {
let total = 0;
table.querySelectorAll('tr.order-line').forEach(function (row) {
const select = row.querySelector('.product-select');
const qty = parseInt(row.querySelector('.qty-input').value, 10) || 0;
const price = parseFloat(select.options[select.selectedIndex].dataset.price) || 0;
const line = price * qty;
row.querySelector('.unit-price').textContent = price.toFixed(2);
row.querySelector('.line-total').textContent = line.toFixed(2);
total += line;
});
totalEl.textContent = total.toFixed(2);
const advance = parseFloat(advanceInput.value) || 0;
const pct = total > 0 ? (advance / total) * 100 : 0;
const capped = Math.min(pct, 100);
advancePctLabel.textContent = pct.toFixed(2) + '%';
advanceBar.style.width = capped + '%';
advanceBar.textContent = pct.toFixed(0) + '%';
const willAccept = pct >= 50;
advanceBar.className = 'progress-bar ' + (willAccept ? 'bg-success' : 'bg-danger');
advanceHint.textContent = total <= 0
? 'Add a product line to see the advance percentage.'
: (willAccept
? 'This order will be ACCEPTED (advance is at least 50% of the total).'
: 'This order will be REJECTED (advance is below 50% of the total).');
advanceHint.className = 'form-text ' + (total <= 0 ? '' : (willAccept ? 'text-success' : 'text-danger'));
}
table.addEventListener('change', recalc);
table.addEventListener('input', recalc);
advanceInput.addEventListener('input', recalc);
document.getElementById('addLine').addEventListener('click', function () {
const first = table.querySelector('tr.order-line');
const clone = first.cloneNode(true);
clone.querySelector('.product-select').selectedIndex = 0;
clone.querySelector('.qty-input').value = 1;
table.appendChild(clone);
reindex();
recalc();
});
table.addEventListener('click', function (e) {
if (!e.target.classList.contains('remove-line')) return;
const rows = table.querySelectorAll('tr.order-line');
if (rows.length === 1) return; // always keep one line
e.target.closest('tr').remove();
reindex();
recalc();
});
recalc();
})();
</script>
}
Step 30: Create Views\Orders\Status.cshtml
The Checking Order Status screen, reading from SQL Server. It offers a filter button per
OrderStatus value and shows the Redis counters above the table, so you can watch an
order move through Received, Queued, Processing and then its final state.
@model OrderStatusViewModel
@{
ViewData["Title"] = "Order Status";
string BadgeFor(OrderStatus s) => s switch
{
OrderStatus.Received => "secondary",
OrderStatus.Queued => "info",
OrderStatus.Processing => "primary",
OrderStatus.Accepted => "success",
OrderStatus.Rejected => "danger",
_ => "dark"
};
var statuses = Enum.GetValues<OrderStatus>();
}
<div class="d-flex justify-content-between align-items-center mb-3">
<h1 class="h3 mb-0">Order Status <span class="text-muted fs-6">(SQL Server)</span></h1>
<div>
<a class="btn btn-sm btn-outline-secondary" asp-action="Status" asp-route-status="@Model.Filter">Refresh</a>
<a class="btn btn-sm btn-primary" asp-action="Create">Create Order</a>
</div>
</div>
<div class="mb-3">
<a class="btn btn-sm @(Model.Filter is null ? "btn-dark" : "btn-outline-dark")" asp-action="Status">
All (@Model.Orders.Count)
</a>
@foreach (var s in statuses)
{
<a class="btn btn-sm @(Model.Filter == s ? "btn-" + BadgeFor(s) : "btn-outline-" + BadgeFor(s))"
asp-action="Status" asp-route-status="@s">@s</a>
}
</div>
@if (!Model.CacheAvailable)
{
<div class="alert alert-warning py-2 small">Redis is not reachable, so the counters below read zero.</div>
}
<div class="row g-3 mb-4">
<div class="col-6 col-lg-3"><div class="border rounded p-2"><div class="small text-muted">Received</div><div class="fs-4">@Model.Counters.Received</div></div></div>
<div class="col-6 col-lg-3"><div class="border rounded p-2"><div class="small text-muted">Processed</div><div class="fs-4">@Model.Counters.Processed</div></div></div>
<div class="col-6 col-lg-3"><div class="border rounded p-2"><div class="small text-muted">Accepted</div><div class="fs-4 text-success">@Model.Counters.Accepted</div></div></div>
<div class="col-6 col-lg-3"><div class="border rounded p-2"><div class="small text-muted">Rejected</div><div class="fs-4 text-danger">@Model.Counters.Rejected</div></div></div>
</div>
<div class="card">
<div class="table-responsive">
<table class="table table-striped table-hover mb-0 align-middle">
<thead>
<tr>
<th>Order #</th>
<th>Customer</th>
<th class="text-center">Items</th>
<th class="text-end">Total</th>
<th class="text-end">Advance</th>
<th class="text-end">Advance %</th>
<th>Status</th>
<th>Placed (UTC)</th>
<th>Message</th>
<th></th>
</tr>
</thead>
<tbody>
@if (Model.Orders.Count == 0)
{
<tr><td colspan="10" class="text-center text-muted py-4">No orders match this filter.</td></tr>
}
@foreach (var order in Model.Orders)
{
<tr>
<td class="font-monospace">@order.OrderNumber</td>
<td>@(order.Customer?.Name ?? "-")</td>
<td class="text-center">@order.OrderDetails.Count</td>
<td class="text-end">@order.TotalAmount.ToString("N2")</td>
<td class="text-end">@order.AdvanceAmount.ToString("N2")</td>
<td class="text-end @(order.AdvancePercentage >= 50 ? "text-success" : "text-danger")">
@order.AdvancePercentage.ToString("0.##")%
</td>
<td><span class="badge bg-@BadgeFor(order.Status)">@order.Status</span></td>
<td class="small">@order.OrderDate.ToString("yyyy-MM-dd HH:mm:ss")</td>
<td class="small text-muted">@order.StatusMessage</td>
<td class="text-end">
<a class="btn btn-sm btn-outline-primary" asp-action="Details" asp-route-id="@order.Id">View</a>
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
<p class="small text-muted mt-3">
An order moves Received → Queued → Processing → Accepted or Rejected.
Refresh after a second or two to see the background worker finish.
</p>
Step 31: Create Views\Orders\Details.cshtml
A single order, pulling the lines and the advance from SQL Server and the decision from PostgreSQL. This is the one screen that shows both databases side by side, which makes the split easy to explain.
@model Order
@{
ViewData["Title"] = $"Order {Model.OrderNumber}";
var processed = ViewBag.Processed as ProcessedOrder;
string BadgeFor(OrderStatus s) => s switch
{
OrderStatus.Received => "secondary",
OrderStatus.Queued => "info",
OrderStatus.Processing => "primary",
OrderStatus.Accepted => "success",
OrderStatus.Rejected => "danger",
_ => "dark"
};
}
<div class="d-flex justify-content-between align-items-center mb-3">
<h1 class="h3 mb-0">
Order <span class="font-monospace">@Model.OrderNumber</span>
<span class="badge bg-@BadgeFor(Model.Status) align-middle">@Model.Status</span>
</h1>
<a class="btn btn-sm btn-outline-secondary" asp-action="Status">Back to status</a>
</div>
<div class="row g-4">
<div class="col-12 col-lg-7">
<div class="card">
<div class="card-header fw-semibold">Order Lines <span class="text-muted small">(SQL Server)</span></div>
<div class="table-responsive">
<table class="table mb-0">
<thead>
<tr>
<th>Product</th>
<th>SKU</th>
<th class="text-center">Qty</th>
<th class="text-end">Unit Price</th>
<th class="text-end">Line Total</th>
</tr>
</thead>
<tbody>
@foreach (var d in Model.OrderDetails)
{
<tr>
<td>@(d.Product?.Name ?? "-")</td>
<td class="font-monospace small">@(d.Product?.Sku ?? "-")</td>
<td class="text-center">@d.Quantity</td>
<td class="text-end">@d.UnitPrice.ToString("N2")</td>
<td class="text-end">@d.LineTotal.ToString("N2")</td>
</tr>
}
</tbody>
<tfoot class="table-light">
<tr>
<th colspan="4" class="text-end">Order Total</th>
<th class="text-end">@Model.TotalAmount.ToString("N2")</th>
</tr>
</tfoot>
</table>
</div>
</div>
</div>
<div class="col-12 col-lg-5">
<div class="card mb-3">
<div class="card-header fw-semibold">Customer</div>
<div class="card-body">
<dl class="row mb-0">
<dt class="col-4">Name</dt><dd class="col-8">@(Model.Customer?.Name ?? "-")</dd>
<dt class="col-4">Email</dt><dd class="col-8">@(Model.Customer?.Email ?? "-")</dd>
<dt class="col-4">Phone</dt><dd class="col-8">@(Model.Customer?.Phone ?? "-")</dd>
<dt class="col-4">City</dt><dd class="col-8">@(Model.Customer?.City ?? "-")</dd>
</dl>
</div>
</div>
<div class="card mb-3">
<div class="card-header fw-semibold">Advance</div>
<div class="card-body">
<dl class="row mb-2">
<dt class="col-6">Total</dt><dd class="col-6 text-end">@Model.TotalAmount.ToString("N2")</dd>
<dt class="col-6">Advance Paid</dt><dd class="col-6 text-end">@Model.AdvanceAmount.ToString("N2")</dd>
<dt class="col-6">Advance %</dt>
<dd class="col-6 text-end @(Model.AdvancePercentage >= 50 ? "text-success" : "text-danger")">
@Model.AdvancePercentage.ToString("0.##")%
</dd>
</dl>
<div class="progress" style="height:1.25rem">
<div class="progress-bar @(Model.AdvancePercentage >= 50 ? "bg-success" : "bg-danger")"
role="progressbar"
style="width:@(Math.Min(Model.AdvancePercentage, 100m).ToString("0.##", System.Globalization.CultureInfo.InvariantCulture))%">
@Model.AdvancePercentage.ToString("0")%
</div>
</div>
@if (!string.IsNullOrWhiteSpace(Model.StatusMessage))
{
<p class="small text-muted mt-3 mb-0">@Model.StatusMessage</p>
}
</div>
</div>
<div class="card">
<div class="card-header fw-semibold">Processing Outcome <span class="text-muted small">(PostgreSQL)</span></div>
<div class="card-body">
@if (processed is null)
{
<p class="text-muted mb-0">
Not processed yet, or PostgreSQL is unreachable. Refresh in a moment.
</p>
}
else
{
<dl class="row mb-0">
<dt class="col-5">Decision</dt>
<dd class="col-7">
<span class="badge bg-@(processed.IsAccepted ? "success" : "danger")">@processed.Decision</span>
</dd>
<dt class="col-5">Reason</dt><dd class="col-7 small">@processed.Reason</dd>
<dt class="col-5">Processed (UTC)</dt>
<dd class="col-7 small">@processed.ProcessedOnUtc.ToString("yyyy-MM-dd HH:mm:ss")</dd>
<dt class="col-5">Duration</dt><dd class="col-7 small">@processed.ProcessingDurationMs ms</dd>
</dl>
}
</div>
</div>
</div>
</div>
Step 32: Create Views\Orders\Processed.cshtml
The Viewing Processed Orders screen, reading the processed_orders table from
PostgreSQL. It shows the decision, the reason text written by the worker and how long processing
took, plus summary tiles including the acceptance rate.
@model ProcessedOrdersViewModel
@{
ViewData["Title"] = "Processed Orders";
}
<div class="d-flex justify-content-between align-items-center mb-3">
<h1 class="h3 mb-0">Processed Orders <span class="text-muted fs-6">(PostgreSQL)</span></h1>
<a class="btn btn-sm btn-outline-secondary" asp-action="Processed">Refresh</a>
</div>
@if (Model.DataError is not null)
{
<div class="alert alert-danger">
<strong>PostgreSQL could not be read.</strong>
<div class="small mt-1">@Model.DataError</div>
<div class="small mt-1">Check <a href="/health" target="_blank" rel="noopener">/health</a> for the current state.</div>
</div>
}
<div class="row g-3 mb-4">
<div class="col-6 col-lg-3">
<div class="card text-bg-success h-100"><div class="card-body">
<div class="small text-uppercase">Accepted</div>
<div class="fs-3">@Model.AcceptedCount</div>
<div class="small">@Model.AcceptedValue.ToString("N2") value</div>
</div></div>
</div>
<div class="col-6 col-lg-3">
<div class="card text-bg-danger h-100"><div class="card-body">
<div class="small text-uppercase">Rejected</div>
<div class="fs-3">@Model.RejectedCount</div>
<div class="small">@Model.RejectedValue.ToString("N2") value</div>
</div></div>
</div>
<div class="col-6 col-lg-3">
<div class="card h-100"><div class="card-body">
<div class="small text-uppercase text-muted">Total Processed</div>
<div class="fs-3">@(Model.AcceptedCount + Model.RejectedCount)</div>
</div></div>
</div>
<div class="col-6 col-lg-3">
<div class="card h-100"><div class="card-body">
<div class="small text-uppercase text-muted">Acceptance Rate</div>
<div class="fs-3">
@{
var totalDecided = Model.AcceptedCount + Model.RejectedCount;
var rate = totalDecided == 0 ? 0m : (decimal)Model.AcceptedCount / totalDecided * 100m;
}
@rate.ToString("0.#")%
</div>
</div></div>
</div>
</div>
<div class="card">
<div class="card-header fw-semibold">processed_orders</div>
<div class="table-responsive">
<table class="table table-striped table-hover mb-0 align-middle">
<thead>
<tr>
<th>Order #</th>
<th>Customer</th>
<th class="text-center">Items</th>
<th class="text-end">Total</th>
<th class="text-end">Advance</th>
<th class="text-end">Advance %</th>
<th>Decision</th>
<th>Reason</th>
<th>Processed (UTC)</th>
<th class="text-end">Took</th>
</tr>
</thead>
<tbody>
@if (Model.ProcessedOrders.Count == 0)
{
<tr>
<td colspan="10" class="text-center text-muted py-4">
Nothing has been processed yet. Create an order and give the worker a moment.
</td>
</tr>
}
@foreach (var p in Model.ProcessedOrders)
{
<tr>
<td class="font-monospace">
<a asp-action="Details" asp-route-id="@p.OrderId">@p.OrderNumber</a>
</td>
<td>@p.CustomerName</td>
<td class="text-center">@p.ItemCount</td>
<td class="text-end">@p.TotalAmount.ToString("N2")</td>
<td class="text-end">@p.AdvanceAmount.ToString("N2")</td>
<td class="text-end @(p.IsAccepted ? "text-success" : "text-danger")">
@p.AdvancePercentage.ToString("0.##")%
</td>
<td><span class="badge bg-@(p.IsAccepted ? "success" : "danger")">@p.Decision</span></td>
<td class="small text-muted">@p.Reason</td>
<td class="small">@p.ProcessedOnUtc.ToString("yyyy-MM-dd HH:mm:ss")</td>
<td class="text-end small">@p.ProcessingDurationMs ms</td>
</tr>
}
</tbody>
</table>
</div>
</div>
Step 33: Create Views\Orders\Decisions.cshtml
The Viewing Accepted and Rejected Orders screen. With no filter it shows both panels side by
side; with a filter it shows one. The repeated table markup is factored into a local
RenderPanel function so both panels stay identical.
@model ProcessedOrdersViewModel
@{
ViewData["Title"] = "Accepted and Rejected Orders";
var accepted = Model.ProcessedOrders.Where(p => p.IsAccepted).ToList();
var rejected = Model.ProcessedOrders.Where(p => !p.IsAccepted).ToList();
}
<div class="d-flex justify-content-between align-items-center mb-3">
<h1 class="h3 mb-0">Accepted and Rejected Orders</h1>
<a class="btn btn-sm btn-outline-secondary" asp-action="Decisions" asp-route-decision="@Model.Decision">Refresh</a>
</div>
@if (Model.DataError is not null)
{
<div class="alert alert-danger">
<strong>PostgreSQL could not be read.</strong>
<div class="small mt-1">@Model.DataError</div>
</div>
}
<div class="mb-3">
<a class="btn btn-sm @(Model.Decision is null ? "btn-dark" : "btn-outline-dark")"
asp-action="Decisions">All (@(Model.AcceptedCount + Model.RejectedCount))</a>
<a class="btn btn-sm @(Model.Decision == "Accepted" ? "btn-success" : "btn-outline-success")"
asp-action="Decisions" asp-route-decision="Accepted">Accepted (@Model.AcceptedCount)</a>
<a class="btn btn-sm @(Model.Decision == "Rejected" ? "btn-danger" : "btn-outline-danger")"
asp-action="Decisions" asp-route-decision="Rejected">Rejected (@Model.RejectedCount)</a>
</div>
<div class="alert alert-light border small">
Rule applied by the background service: an order is <strong>Accepted</strong> when the advance
is at least <strong>50%</strong> of the order total, otherwise it is <strong>Rejected</strong>.
</div>
@{
// Local helper so both panels render identically.
async Task RenderPanel(string title, string colour, IReadOnlyList<ProcessedOrder> rows, decimal value)
{
<div class="card h-100">
<div class="card-header d-flex justify-content-between align-items-center">
<span class="fw-semibold text-@colour">@title</span>
<span class="badge bg-@colour">@rows.Count · @value.ToString("N2")</span>
</div>
<div class="table-responsive">
<table class="table table-sm table-striped mb-0 align-middle">
<thead>
<tr>
<th>Order #</th>
<th>Customer</th>
<th class="text-end">Total</th>
<th class="text-end">Advance</th>
<th class="text-end">%</th>
<th>Processed (UTC)</th>
</tr>
</thead>
<tbody>
@if (rows.Count == 0)
{
<tr><td colspan="6" class="text-center text-muted py-3">None.</td></tr>
}
@foreach (var p in rows)
{
<tr>
<td class="font-monospace">
<a asp-action="Details" asp-route-id="@p.OrderId">@p.OrderNumber</a>
</td>
<td>@p.CustomerName</td>
<td class="text-end">@p.TotalAmount.ToString("N2")</td>
<td class="text-end">@p.AdvanceAmount.ToString("N2")</td>
<td class="text-end text-@colour">@p.AdvancePercentage.ToString("0.##")</td>
<td class="small">@p.ProcessedOnUtc.ToString("yyyy-MM-dd HH:mm:ss")</td>
</tr>
}
</tbody>
</table>
</div>
</div>
await Task.CompletedTask;
}
}
<div class="row g-4">
@if (Model.Decision is null or "Accepted")
{
<div class="col-12 @(Model.Decision is null ? "col-xl-6" : "")">
@{ await RenderPanel("Accepted", "success", accepted, accepted.Sum(p => p.TotalAmount)); }
</div>
}
@if (Model.Decision is null or "Rejected")
{
<div class="col-12 @(Model.Decision is null ? "col-xl-6" : "")">
@{ await RenderPanel("Rejected", "danger", rejected, rejected.Sum(p => p.TotalAmount)); }
</div>
}
</div>
Step 34: Update appsettings.json
Every connection string and every tunable lives here. This is the complete file:
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"Microsoft.EntityFrameworkCore.Database.Command": "Warning",
"MVC_HealthCheck": "Information"
}
},
"AllowedHosts": "*",
"ConnectionStrings": {
"SqlServer": "Server=.;Database=OrderProcessingDb;Trusted_Connection=True;TrustServerCertificate=True;MultipleActiveResultSets=True",
"PostgreSql": "Host=localhost;Port=5432;Database=orderprocessing;Username=postgres;Password=YOUR_POSTGRES_PASSWORD",
"Redis": "localhost:6379"
},
"RabbitMq": {
"HostName": "localhost",
"Port": 5672,
"UserName": "guest",
"Password": "guest",
"VirtualHost": "/",
"QueueName": "order-processing-queue"
},
"OrderProcessing": {
"MinimumAdvancePercentage": 50,
"SimulatedWorkMilliseconds": 750,
"CacheListLength": 50
},
"HealthChecksUI": {
"EvaluationTimeInSeconds": 15,
"MinimumSecondsBetweenFailureNotifications": 60
}
}
Taking the changes one section at a time.
Logging
Two entries were added to the template's defaults.
Microsoft.EntityFrameworkCore.Database.Command is set to Warning,
which suppresses the SQL statement that EF Core logs at Information for every single
query. Without it, the worker's output is unreadable.
MVC_HealthCheck is set to Information so the application's own
logging, including the line the worker writes for each decision, still comes through.
ConnectionStrings
Three named entries, read in Program.cs through
GetConnectionString.
SqlServer uses Server=. for the local default instance and
Trusted_Connection=True for Windows authentication, so no credentials appear in the
file. TrustServerCertificate=True is needed because recent versions of
Microsoft.Data.SqlClient encrypt by default and a local developer instance normally
has a self signed certificate. It is a development convenience and should not follow you to
production, where the right answer is a certificate the client actually trusts.
PostgreSql is a standard Npgsql connection string. Unlike SQL Server, a default
PostgreSQL installation uses password authentication (scram-sha-256), so a password
is unavoidable here. Do not commit it. Move it to user secrets for development
(dotnet user-secrets set "ConnectionStrings:PostgreSql" "...") or to an environment
variable, both of which override this file without changing any code.
Redis is just host:port, the format
StackExchange.Redis parses. It accepts a longer configuration string if you need a
password or multiple endpoints.
RabbitMq
A new section bound to RabbitMqOptions. It matches the credentials and ports from
the docker run command above. QueueName is here rather than hard coded
because the publisher and the consumer both read it, and they must agree.
OrderProcessing
A new section bound to OrderProcessingOptions, holding the business rule itself.
MinimumAdvancePercentage is 50, which is the threshold in the brief. Putting it in
configuration means the rule can be changed without a rebuild, and it means the test for the rule
can set it to something else.
HealthChecksUI
Settings for the dashboard. Note that the values set in code through
AddHealthChecksUI(settings => ...) take effect regardless; this section is the
configuration based equivalent, and the two can be mixed.
Step 35: Wire everything in Program.cs
The composition root. Everything built so far is registered here, the health checks are declared, and the endpoints are mapped.
using HealthChecks.UI.Client;
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using MVC_HealthCheck.Data;
using MVC_HealthCheck.HealthChecks;
using MVC_HealthCheck.Messaging;
using MVC_HealthCheck.Options;
using MVC_HealthCheck.Services;
using StackExchange.Redis;
var builder = WebApplication.CreateBuilder(args);
// ---------------------------------------------------------------------------- configuration --
builder.Services.Configure<RabbitMqOptions>(
builder.Configuration.GetSection(RabbitMqOptions.SectionName));
builder.Services.Configure<OrderProcessingOptions>(
builder.Configuration.GetSection(OrderProcessingOptions.SectionName));
var sqlServerConnection = builder.Configuration.GetConnectionString("SqlServer")
?? throw new InvalidOperationException("ConnectionStrings:SqlServer is not configured.");
var postgresConnection = builder.Configuration.GetConnectionString("PostgreSql")
?? throw new InvalidOperationException("ConnectionStrings:PostgreSql is not configured.");
var redisConnection = builder.Configuration.GetConnectionString("Redis")
?? throw new InvalidOperationException("ConnectionStrings:Redis is not configured.");
// ---------------------------------------------------------------------------------- storage --
// SQL Server holds Customers, Products, Orders and OrderDetails.
builder.Services.AddDbContext<OrdersDbContext>(options =>
options.UseSqlServer(sqlServerConnection, sql => sql.EnableRetryOnFailure()));
// PostgreSQL holds the processed_orders outcome table.
builder.Services.AddDbContext<ProcessedOrdersDbContext>(options =>
options.UseNpgsql(postgresConnection, npg => npg.EnableRetryOnFailure()));
// A single multiplexer for the whole app. AbortOnConnectFail=false means a Redis outage at
// startup does not stop the app from booting; it shows up on /health instead.
builder.Services.AddSingleton<IConnectionMultiplexer>(_ =>
{
var config = ConfigurationOptions.Parse(redisConnection);
config.AbortOnConnectFail = false;
config.ConnectRetry = 3;
config.ClientName = "mvc-healthcheck-order-processing";
return ConnectionMultiplexer.Connect(config);
});
builder.Services.AddSingleton<IOrderCache, RedisOrderCache>();
// ---------------------------------------------------------------------------------- messaging --
builder.Services.AddSingleton<RabbitMqConnectionProvider>();
builder.Services.AddSingleton<IOrderPublisher, RabbitMqOrderPublisher>();
builder.Services.AddHostedService<OrderProcessingWorker>();
// ---------------------------------------------------------------------------------- app services --
builder.Services.AddScoped<OrderService>();
builder.Services.AddScoped<ProcessedOrderService>();
// ------------------------------------------------------------------------------- health checks --
// AspNetCore.Diagnostics.HealthChecks (Xabaril) probes for every external dependency.
builder.Services.AddHealthChecks()
.AddSqlServer(
connectionString: sqlServerConnection,
healthQuery: "SELECT 1;",
name: "sql-server",
failureStatus: HealthStatus.Unhealthy,
tags: ["db", "sql", "sqlserver", "ready"])
.AddNpgSql(
connectionString: postgresConnection,
healthQuery: "SELECT 1;",
name: "postgresql",
failureStatus: HealthStatus.Unhealthy,
tags: ["db", "sql", "postgres", "ready"])
.AddRedis(
connectionMultiplexerFactory: sp => sp.GetRequiredService<IConnectionMultiplexer>(),
name: "redis",
failureStatus: HealthStatus.Degraded,
tags: ["cache", "redis", "ready"])
// The async overload: the factory runs on every probe, so the provider gets a chance to
// re-establish a dropped connection instead of reporting a stale failure forever.
.AddRabbitMQ(
factory: sp => sp.GetRequiredService<RabbitMqConnectionProvider>().GetConnectionAsync(),
name: "rabbitmq",
failureStatus: HealthStatus.Unhealthy,
tags: ["broker", "rabbitmq", "ready"]);
// The polling UI that ships with the same library.
builder.Services
.AddHealthChecksUI(settings =>
{
settings.SetEvaluationTimeInSeconds(15);
settings.MaximumHistoryEntriesPerEndpoint(60);
settings.SetApiMaxActiveRequests(1);
// Must point at the UI-formatted payload, not the custom /health JSON.
settings.AddHealthCheckEndpoint("Order Processing", "/health/ui-data");
})
.AddInMemoryStorage();
// ---------------------------------------------------------------------------------------- mvc --
builder.Services.AddControllersWithViews();
var app = builder.Build();
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler("/Home/Error");
app.UseHsts();
}
app.UseStaticFiles();
app.UseRouting();
app.UseAuthorization();
app.MapStaticAssets();
// ------------------------------------------------------------------------- health endpoints --
// Everything, with the detailed JSON body.
app.MapHealthChecks("/health", new HealthCheckOptions
{
ResponseWriter = HealthCheckResponseWriter.WriteAsync
});
// Readiness: all external dependencies must answer before traffic is routed here.
app.MapHealthChecks("/health/ready", new HealthCheckOptions
{
Predicate = registration => registration.Tags.Contains("ready"),
ResponseWriter = HealthCheckResponseWriter.WriteAsync
});
// Liveness: is the process up at all? No dependency is probed.
app.MapHealthChecks("/health/live", new HealthCheckOptions
{
Predicate = _ => false
});
// The shape the HealthChecks UI polls for.
app.MapHealthChecks("/health/ui-data", new HealthCheckOptions
{
Predicate = _ => true,
ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse
});
app.MapHealthChecksUI(options => options.UIPath = "/health-ui");
// ---------------------------------------------------------------------------------- routing --
app.MapControllerRoute(
name: "default",
pattern: "{controller=Home}/{action=Index}/{id?}")
.WithStaticAssets();
// Create both schemas and seed the catalogue. Failures are logged, not thrown, so a database
// outage surfaces on /health rather than preventing startup.
await DatabaseInitializer.InitializeAsync(app.Services);
app.Run();
Reading it in order: configuration binding, then the three storage registrations, then
messaging, then application services, then health checks, then MVC. After
builder.Build() the pipeline is configured, the health endpoints are mapped, the
default MVC route is registered, and the databases are initialised just before
app.Run().
Two registration details are worth pulling out. The Redis multiplexer is a singleton created
with AbortOnConnectFail = false. That single flag is why the application starts when
Redis is down: the default behaviour is to throw from Connect, which would take the
whole process with it before the health endpoint ever existed.
RabbitMqConnectionProvider is a singleton for the opposite reason, because a broker
connection is expensive and must be shared rather than created per request.
5. AddHealthChecks() in detail
This is the part of Program.cs that does the actual work:
builder.Services.AddHealthChecks()
.AddSqlServer(
connectionString: sqlServerConnection,
healthQuery: "SELECT 1;",
name: "sql-server",
failureStatus: HealthStatus.Unhealthy,
tags: ["db", "sql", "sqlserver", "ready"])
.AddNpgSql(
connectionString: postgresConnection,
healthQuery: "SELECT 1;",
name: "postgresql",
failureStatus: HealthStatus.Unhealthy,
tags: ["db", "sql", "postgres", "ready"])
.AddRedis(
connectionMultiplexerFactory: sp => sp.GetRequiredService<IConnectionMultiplexer>(),
name: "redis",
failureStatus: HealthStatus.Degraded,
tags: ["cache", "redis", "ready"])
.AddRabbitMQ(
factory: sp => sp.GetRequiredService<RabbitMqConnectionProvider>().GetConnectionAsync(),
name: "rabbitmq",
failureStatus: HealthStatus.Unhealthy,
tags: ["broker", "rabbitmq", "ready"]);
AddHealthChecks() registers the health check infrastructure (most importantly
HealthCheckService) and returns an IHealthChecksBuilder. Every
Add* call after it adds one HealthCheckRegistration and returns the
builder again, which is why they chain. Calling AddHealthChecks() twice is harmless;
you get the same builder back and the registrations accumulate.
The parameters, and why each value was chosen
name is the key this check appears under in
HealthReport.Entries, and therefore the name a human reads at two in the morning.
Each package has a default ("sqlserver", "rabbitmq" and so on), but
naming them explicitly matters as soon as you have two of anything, for example a primary and a
reporting database. Names must be unique.
healthQuery is the SQL the probe executes.
SELECT 1; is the right choice: it proves the connection opened, authentication
succeeded and the server is answering queries, and it costs essentially nothing. Resist the urge
to probe a real table. If you want to prove a specific table is reachable, a bounded
SELECT TOP 1 against a small table is the most you should consider.
failureStatus is the status reported when the probe throws, and
this is the parameter that carries the most design intent. SQL Server, PostgreSQL and RabbitMQ
are set to Unhealthy, because without any of them the application cannot accept or
process an order. Redis is set to Degraded, because Redis in this application holds
only activity lists and counters. Orders are still captured, still queued, still processed and
still stored when Redis is down; the dashboard just goes quiet. Since Degraded maps
to a 200 by default, a Redis outage will not cause a load balancer to pull the instance out of
rotation, which is exactly right. That one argument encodes the judgement that Redis is a
convenience and PostgreSQL is not.
tags are free form labels used to filter checks per endpoint.
Every check here carries ready, which is what the readiness endpoint selects on. The
others (db, cache, broker) are not used by this
application's endpoints but cost nothing and make it trivial to add, say, a database only
endpoint later.
timeout is not used above but is worth knowing. Each
Add* method accepts an optional TimeSpan after which the check is
abandoned and reported as failed. Without it, a probe against a host that is dropping packets
rather than refusing connections can hang for the full connection timeout, and your health
endpoint becomes slow at exactly the moment it is being polled hardest.
Why Redis and RabbitMQ use factories instead of connection strings
SQL Server and PostgreSQL take a connection string, because opening a short lived connection per probe is normal for those clients and they pool underneath.
Redis does not work that way. ConnectionMultiplexer is expensive and designed to
be created once and shared for the lifetime of the application. Passing a connection string to
AddRedis would have the health check build its own multiplexer alongside the one the
application already uses, so the probe would be testing a connection that no application code
actually depends on. Passing
connectionMultiplexerFactory: sp => sp.GetRequiredService<IConnectionMultiplexer>()
means the probe tests the real one.
RabbitMQ in version 9 of the package takes the same approach, and only the factory form
exists: there is no connection string overload. Two overloads are available, one taking
Func<IServiceProvider, IConnection> and one taking
Func<IServiceProvider, Task<IConnection>>. This application uses the
asynchronous one, because RabbitMQ.Client 7 creates connections asynchronously and
the alternative is blocking on a task inside the probe.
The factory runs on every probe, not once at startup. That is the detail that makes recovery
work. Because each call goes through RabbitMqConnectionProvider.GetConnectionAsync,
which checks IsOpen and rebuilds a dead connection, restarting the broker produces a
few failed probes and then a clean recovery. Had the connection been resolved once and cached,
the check would have reported a failure that could never clear without an application
restart.
Mapping the endpoints
app.MapHealthChecks("/health", new HealthCheckOptions
{
ResponseWriter = HealthCheckResponseWriter.WriteAsync
});
app.MapHealthChecks("/health/ready", new HealthCheckOptions
{
Predicate = registration => registration.Tags.Contains("ready"),
ResponseWriter = HealthCheckResponseWriter.WriteAsync
});
app.MapHealthChecks("/health/live", new HealthCheckOptions
{
Predicate = _ => false
});
app.MapHealthChecks("/health/ui-data", new HealthCheckOptions
{
Predicate = _ => true,
ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse
});
app.MapHealthChecksUI(options => options.UIPath = "/health-ui");
One set of registered checks, four endpoints over it, differing only by
Predicate and ResponseWriter.
/health runs everything (the default predicate selects all checks) and writes the
detailed JSON. /health/ready filters to checks tagged ready, which is
the readiness probe: it fails when a dependency the application needs is unavailable.
/health/live is the liveness probe, and Predicate = _ => false
deserves an explanation because it looks like a mistake. It selects no checks at all. The
endpoint therefore returns 200 with an empty report whenever the process is able to serve a
request. That is precisely what liveness should mean. If liveness probed the database, a
temporary database outage would cause Kubernetes to kill and restart every pod, repeatedly,
which converts a recoverable dependency problem into a full outage. Liveness answers "should I
restart this process", and the answer to that is almost never yes because another system is
down.
/health/ui-data exists because the dashboard cannot read our custom JSON. It
runs the same checks but writes UIResponseWriter.WriteHealthCheckUIResponse, the
format the UI expects. The UI is pointed at it in AddHealthChecksUI:
builder.Services
.AddHealthChecksUI(settings =>
{
settings.SetEvaluationTimeInSeconds(15);
settings.MaximumHistoryEntriesPerEndpoint(60);
settings.SetApiMaxActiveRequests(1);
settings.AddHealthCheckEndpoint("Order Processing", "/health/ui-data");
})
.AddInMemoryStorage();
The UI polls that endpoint every 15 seconds and keeps 60 results per endpoint, giving roughly
fifteen minutes of history. AddInMemoryStorage() keeps that history in memory, so it
is lost on restart. For anything beyond local development use one of the persistent storage
packages, such as the SQL Server or PostgreSQL storage providers.
If you point the UI at /health by mistake, the dashboard renders but shows the
endpoint as unhealthy with a deserialisation error, because our custom JSON is not the shape it
parses. That is an easy half hour to lose.
Status codes
By default Healthy and Degraded both map to 200, and
Unhealthy maps to 503. Override it per endpoint through
ResultStatusCodes when you need different behaviour:
app.MapHealthChecks("/health/strict", new HealthCheckOptions
{
ResultStatusCodes =
{
[HealthStatus.Healthy] = StatusCodes.Status200OK,
[HealthStatus.Degraded] = StatusCodes.Status503ServiceUnavailable,
[HealthStatus.Unhealthy] = StatusCodes.Status503ServiceUnavailable
}
});
6. The complete execution, end to end
Here is what actually happens when somebody places an order, from the button click to the row in PostgreSQL.
Figure 1: Shows the Execution of ASP.NET Core 10 MVC Healthcheck
Capture, inside the HTTP request
1. The browser posts to /Orders/Create with a customer id, a set
of product ids and quantities, and an advance amount. Note what is not posted: no prices and no
total.
2. OrdersController.Create validates the model and calls
OrderService.CreateOrderAsync. The service groups duplicate product lines, loads
those products from the catalogue, and computes each line total and the order total from the
database prices. This is the step that makes the total trustworthy, and the decision depends
entirely on the total.
3. The order and its lines are inserted into SQL Server with status
Received. At this point the order is durable. Everything after this can fail without
losing it.
4. The order is pushed onto the Redis orders:list:received list
and the received counter is incremented. If Redis is unreachable this silently does nothing.
5. The message is published to RabbitMQ on the default exchange with the
queue name as the routing key, marked persistent, on a durable queue. On success the order moves
to Queued. On failure the order moves to Failed with the broker error
recorded in its status message, and the user is told. The HTTP request ends here and the user is
redirected to the status screen, typically well before processing finishes.
Processing, in the background service
6. OrderProcessingWorker has been consuming the queue since
startup with autoAck: false and a prefetch of 1. It receives the delivery,
deserialises the message, opens a DI scope, and sets the order to Processing in SQL
Server.
The rule. The worker computes
AdvanceAmount / TotalAmount * 100, rounded to two decimal places, guarding against a
zero total. If that percentage is greater than or equal to
MinimumAdvancePercentage, the order is accepted; otherwise it is rejected. The
comparison is inclusive, so an advance of exactly 50% is accepted.
7. A row is written to processed_orders in PostgreSQL carrying
the decision, the computed percentage, a human readable reason and how long processing took. If a
row for this order already exists, because the message was redelivered, it is updated instead of
duplicated.
8. The order in SQL Server is updated to Accepted or
Rejected, with the reason text and the processing timestamp.
9. The result is pushed onto the Redis orders:list:processed
list and the processed counter plus the accepted or rejected counter are incremented.
10. The message is acknowledged with BasicAckAsync and leaves
the queue. If anything in steps 6 to 9 threw, the handler instead marks the order
Failed and calls BasicNackAsync with requeue: false, so
the message is discarded rather than looping.
Reading it back
11. Three different screens read from three different places. Order Status reads SQL Server, so it shows every order including ones still in flight and ones that failed. Processed Orders and Accepted/Rejected read PostgreSQL, so they show only orders that reached a decision. The dashboard reads Redis, so it shows recent activity and running counters.
Reading the same order from all three is the clearest way to see why the split exists. SQL Server answers "what did the customer order and where is it now". PostgreSQL answers "what did we decide and why". Redis answers "what has been happening lately".
What the health checks are doing meanwhile
Independently of all of the above, every request to a health endpoint runs the selected probes concurrently and aggregates them. A worked example of the output, with PostgreSQL deliberately misconfigured, shows why the custom writer earns its place:
{
"status": "Unhealthy",
"totalDurationMs": 619.22,
"checkedAtUtc": "2026-10-06T05:24:07.5901759Z",
"entries": [
{ "name": "sql-server", "status": "Healthy", "durationMs": 1.62, "tags": [ "db", "sql", "sqlserver", "ready" ] },
{ "name": "postgresql", "status": "Unhealthy", "durationMs": 610.79, "tags": [ "db", "sql", "postgres", "ready" ],
"description": "28P01: password authentication failed for user \"postgres\"",
"error": "28P01: password authentication failed for user \"postgres\"" },
{ "name": "redis", "status": "Healthy", "durationMs": 3.22, "tags": [ "cache", "redis", "ready" ] },
{ "name": "rabbitmq", "status": "Healthy", "durationMs": 6.63, "tags": [ "broker", "rabbitmq", "ready" ] }
]
}
That response names the broken dependency, gives the exact driver error, and shows that the
failing probe took 610 ms while the healthy ones took single digit milliseconds. The default
plain text body would have said Unhealthy. The endpoint returns 503 here because one
entry is unhealthy; with all four healthy it returns 200.
It is worth watching the application degrade on purpose, because that is the only way to know
the checks work. Stop Redis with docker stop hc-redis and the report goes to
Degraded and still returns 200, the dashboard counters go to zero, and orders
continue to be captured and processed normally. Stop RabbitMQ and the report goes to
Unhealthy with a 503, new orders are saved to SQL Server but marked
Failed because they cannot be queued. Start either one again and the checks recover
on their own within a probe or two.
Once the application is Executes, the result can be seen as explained in the video.
7. Conclusion
Adding health checks to an ASP.NET Core application is a few lines of code. Adding health checks that tell you something useful takes a little more thought, and most of that thought goes into three decisions.
The first is what each dependency is worth. The failureStatus argument is where
that judgement lives. Marking Redis Degraded while SQL Server, PostgreSQL and
RabbitMQ are Unhealthy is a statement that this application can serve its purpose
without a cache but not without its databases or its broker. Get this wrong in the cautious
direction and a cache blip pulls healthy instances out of rotation.
The second is separating liveness from readiness. They answer different questions, and conflating them is actively harmful: a liveness probe that checks the database will restart your pods during a database incident and turn a brief outage into a long one.
The third is what the endpoint says when it fails. The default body is a single word. Twenty lines of response writer turn it into a document that names the failing dependency and quotes the driver error, which is the difference between diagnosing an incident and guessing at it.
The order processing pipeline here exists to make those decisions concrete. Four dependencies that fail in different ways, with different consequences, is a far better teacher than a single database and a green tick. Build it, then start stopping containers and watching what the endpoint says. The checks you trust are the ones you have watched fail.
