Skip to main content

ASP.NET Core Deployment

ASP.NET Core is the most common hosting model for Excalibur applications, combining web API capabilities with background processing.

Before You Start

  • .NET 8.0+ (or .NET 9/10 for latest features)
  • Install the required packages:
    dotnet add package Excalibur.Dispatch
    dotnet add package Excalibur.Hosting.Web # for ASP.NET Core integration
  • Familiarity with getting started and dependency injection

Minimal API Setup

var builder = WebApplication.CreateBuilder(args);

var connectionString = builder.Configuration.GetConnectionString("EventStore")!;

// Add Excalibur services
builder.Services.AddExcalibur(excalibur =>
{
excalibur.AddEventSourcing(es =>
{
es.UseSqlServer(opts => opts.ConnectionString = connectionString);
es.AddRepository<OrderAggregate, OrderId>();
es.UseIntervalSnapshots(100);
});

excalibur.AddOutbox(outbox =>
{
outbox.UseSqlServer(opts => opts.ConnectionString = connectionString);
outbox.EnableBackgroundProcessing();
});
});

// Add controllers or minimal APIs
builder.Services.AddControllers();

var app = builder.Build();

app.MapControllers();
app.Run();

Controller Example

[ApiController]
[Route("api/[controller]")]
public class OrdersController : ControllerBase
{
private readonly IDispatcher _dispatcher;
private readonly IEventSourcedRepository<OrderAggregate, OrderId> _repository;

public OrdersController(
IDispatcher dispatcher,
IEventSourcedRepository<OrderAggregate, OrderId> repository)
{
_dispatcher = dispatcher;
_repository = repository;
}

[HttpPost]
public async Task<ActionResult<OrderId>> CreateOrder(
CreateOrderCommand command,
CancellationToken ct)
{
var result = await _dispatcher.DispatchAsync(command, ct);

return result.Match<ActionResult<OrderId>>(
success: id => CreatedAtAction(nameof(GetOrder), new { id }, id),
failure: error => BadRequest(error));
}

[HttpGet("{id}")]
public async Task<ActionResult<OrderDto>> GetOrder(
OrderId id,
CancellationToken ct)
{
var order = await _repository.GetByIdAsync(id, ct);

if (order is null)
return NotFound();

return Ok(OrderDto.FromAggregate(order));
}
}

Health Checks

builder.Services.AddHealthChecks()
.AddSqlServer(
builder.Configuration.GetConnectionString("EventStore"),
name: "database",
tags: new[] { "ready" })
.AddCheck<OutboxHealthCheck>("outbox", tags: new[] { "ready" });

var app = builder.Build();

// Liveness - is the app running?
app.MapHealthChecks("/health/live", new HealthCheckOptions
{
Predicate = _ => false // No checks
});

// Readiness - is the app ready to serve traffic?
app.MapHealthChecks("/health/ready", new HealthCheckOptions
{
Predicate = check => check.Tags.Contains("ready")
});

Configuration

appsettings.json

{
"ConnectionStrings": {
"EventStore": "Server=localhost;Database=EventStore;Trusted_Connection=True;TrustServerCertificate=True"
},
"Excalibur": {
"Outbox": {
"BatchSize": 100,
"PollingInterval": "00:00:05",
"MaxRetryCount": 5
},
"Snapshots": {
"Interval": 100
}
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Excalibur": "Debug"
}
}
}

Environment-Specific Configuration

appsettings.json                 # Base configuration
appsettings.Development.json # Local development
appsettings.Production.json # Production overrides

Observability

Structured Logging

builder.Host.UseSerilog((context, config) =>
{
config.ReadFrom.Configuration(context.Configuration)
.Enrich.FromLogContext()
.Enrich.WithProperty("Application", "OrderService");
});

OpenTelemetry

builder.Services.AddOpenTelemetry()
.ConfigureResource(resource => resource
.AddService("OrderService"))
.WithTracing(tracing =>
{
tracing.AddAspNetCoreInstrumentation()
.AddSqlClientInstrumentation()
.AddEventSourcingInstrumentation()
.AddOtlpExporter();
})
.WithMetrics(metrics =>
{
metrics.AddAspNetCoreInstrumentation()
.AddMeter("Excalibur.Outbox.*")
.AddOtlpExporter();
});

Error Handling

Global Exception Handler

app.UseExceptionHandler(error =>
{
error.Run(async context =>
{
var exception = context.Features.Get<IExceptionHandlerFeature>()?.Error;
var logger = context.RequestServices.GetRequiredService<ILogger<Program>>();

logger.LogError(exception, "Unhandled exception");

context.Response.StatusCode = exception switch
{
ConcurrencyException => StatusCodes.Status409Conflict,
ValidationException => StatusCodes.Status400BadRequest,
NotFoundException => StatusCodes.Status404NotFound,
_ => StatusCodes.Status500InternalServerError
};

await context.Response.WriteAsJsonAsync(new
{
error = exception?.Message ?? "An error occurred"
});
});
});

Background Processing

Hosted Services

The outbox processor runs as a hosted service:

outbox.EnableBackgroundProcessing();  // Adds OutboxBackgroundService

Custom Background Services

public class ProjectionBackgroundService : BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory;

public ProjectionBackgroundService(IServiceScopeFactory scopeFactory)
{
_scopeFactory = scopeFactory;
}

protected override async Task ExecuteAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
using var scope = _scopeFactory.CreateScope();
var projector = scope.ServiceProvider.GetRequiredService<OrderProjector>();

await projector.ProcessAsync(ct);
await Task.Delay(TimeSpan.FromSeconds(1), ct);
}
}
}

Docker Deployment

Dockerfile

FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base
WORKDIR /app
EXPOSE 80
EXPOSE 443

FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
COPY ["OrderService.csproj", "./"]
RUN dotnet restore
COPY . .
RUN dotnet build -c Release -o /app/build

FROM build AS publish
RUN dotnet publish -c Release -o /app/publish

FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "OrderService.dll"]

docker-compose.yml

version: '3.8'
services:
orderservice:
build: .
ports:
- "5000:80"
environment:
- ASPNETCORE_ENVIRONMENT=Production
- ConnectionStrings__EventStore=Server=db;Database=EventStore;...
depends_on:
- db

db:
image: mcr.microsoft.com/mssql/server:2022-latest
environment:
- ACCEPT_EULA=Y
- SA_PASSWORD=YourStrong@Passw0rd
ports:
- "1433:1433"
volumes:
- sqldata:/var/opt/mssql

volumes:
sqldata:

Production Considerations

Graceful Shutdown

var app = builder.Build();

app.Lifetime.ApplicationStopping.Register(() =>
{
// Allow outbox processor to complete current batch
logger.LogInformation("Application stopping, waiting for background tasks...");
});

Connection Resiliency

Excalibur uses Dapper and ADO.NET for all SQL operations. Configure retry policies with Polly:

builder.Services.AddExcaliburOutbox(outbox =>
{
outbox.UseSqlServer(sql =>
{
sql.ConnectionString(connectionString);
});
});

// Add resilience via Polly
builder.Services.AddResiliencePipeline("sql-retry", pipeline =>
{
pipeline.AddRetry(new RetryStrategyOptions
{
MaxRetryAttempts = 5,
Delay = TimeSpan.FromSeconds(1),
BackoffType = DelayBackoffType.Exponential,
ShouldHandle = new PredicateBuilder().Handle<SqlException>()
});
});

Rate Limiting

builder.Services.AddRateLimiter(options =>
{
options.AddFixedWindowLimiter("api", limiter =>
{
limiter.Window = TimeSpan.FromMinutes(1);
limiter.PermitLimit = 100;
});
});

app.UseRateLimiter();

Best Practices

PracticeReason
Use health checksLoad balancer integration
Configure graceful shutdownComplete in-flight work
Enable structured loggingBetter observability
Use managed identityNo secrets in code
Configure retry policiesHandle transient failures
Set timeoutsPrevent resource exhaustion

Minimal API with Dispatch Bridge

For Minimal API projects, the Excalibur.Dispatch.Hosting.AspNetCore package provides a higher-level bridge that maps HTTP endpoints directly to Dispatch messages with zero boilerplate. Instead of manually resolving IDispatcher and converting results, you declare a mapping and the bridge handles request binding, dispatching, and HTTP response conversion.

See Minimal API Hosting Bridge for the full reference.

Project Organization

For feature-rich APIs, consider organizing code using vertical slice architecture -- group files by feature (Patients, Appointments) instead of by technical layer (Controllers, Services, Repositories). This pairs naturally with Dispatch's one-message-per-operation model.

See Vertical Slice Architecture for guidance and a working healthcare sample.

Content Negotiation

Dispatch provides custom ASP.NET Core formatters that bridge ISerializerRegistry with the MVC content negotiation pipeline. Any registered ISerializer with a ContentType automatically becomes a supported media type for both input and output.

// 1. Register serializers
builder.Services.AddDispatch(dispatch =>
{
dispatch.AddHandlersFromAssembly(typeof(Program).Assembly);
});
// MessagePack for content negotiation (application/x-msgpack)
builder.Services.AddMessagePackSerializer();

// 2. Add content negotiation formatters
builder.Services.AddControllers()
.AddDispatchContentNegotiation();

This adds:

  • DispatchInputFormatter — Deserializes request bodies using the matching ISerializer based on the Content-Type header
  • DispatchOutputFormatter — Serializes response bodies using the matching ISerializer based on the Accept header

Clients can then request different formats:

# JSON (default)
curl -H "Accept: application/json" https://api.example.com/orders/123

# MessagePack
curl -H "Accept: application/x-msgpack" https://api.example.com/orders/123
note

Content negotiation requires the Dispatch serialization infrastructure to be configured first via AddDispatchSerialization(). The formatters are registered at position 0 in the MVC formatter list, so they take priority over default formatters for matching content types.

See Also