This repository is a template for a .NET microservice. It contains a single Web API project
(DotNet.ServiceName.Api) with a layered structure (Application, Common), API versioning,
API Key authentication, Swagger/OpenAPI documentation, structured logging, health checks with
a dashboard, CORS, rate limiting, request timeouts, OpenTelemetry traces and metrics, and a
Docker setup for local development.
src/
DotNet.ServiceName.Api/ # Web API host: controllers, Razor home page, middleware, Swagger/Scalar, auth
DotNet.ServiceName.Application/ # Business logic, DTOs/facets, service registrations
DotNet.ServiceName.Common/ # Shared configuration options and extension helpers
tests/
DotNet.ServiceName.Application.Tests/ # xUnit unit tests (services, mappings, DI)
DotNet.ServiceName.Api.Tests/ # xUnit integration tests (WebApplicationFactory)
Rename DotNet.ServiceName to your service name across the solution, project folders,
namespaces, and the Constants.ApiName value when using the template.
Application developed and used next technologies (on the backend) and components:
- .NET 10 (LTS) - see
global.jsonfor the pinned SDK version - API Key authentication (custom handler) with Swagger UI / Scalar integration
- Serilog for logging
- OpenTelemetry for traces and metrics (OTLP export)
- Swashbuckle for Swagger (OpenAPI)
- Scalar for an alternative interactive API reference UI (Scalar.AspNetCore)
- Asp.Versioning for API versioning (URL segment based)
- Facet for compile-time generated DTOs and mapping (no runtime reflection), with Facet.Extensions helpers (
ToFacet) and a Facet.Dashboard page (/facets) to inspect all facets - HealthCheck UI for ASP.NET Core - DotNetDiag HealthChecks for ASP.NET Core Diagnostics Package
- xUnit +
WebApplicationFactoryfor unit and integration tests - Central Package Management via
Directory.Packages.props
Service/web application use Serilog to write and generate structure logs with details how application working. It's possible to configure logs to send to the different services like Splunk to monitor in one single place or use other tools to read the logs. Depending on hosting type and where the service wil be placed. Request log entries carry the TraceId, so they can be correlated with the corresponding OpenTelemetry trace.
Secured endpoints require an API key sent in an HTTP header (X-API-Key by default).
The scheme is implemented in ApiKeyAuthenticationHandler and applied via
[Authorize(AuthenticationSchemes = ApiKeyAuthenticationHandler.SchemeName)] on
controllers. Health check endpoints stay anonymous.
Configuration lives in the ApiKeyOptions section of appsettings.json:
"ApiKeyOptions": {
"HeaderName": "X-API-Key",
"ApiKey": "local-dev-api-key"
}Key points:
- The key is validated with a constant-time comparison (
CryptographicOperations.FixedTimeEquals). - Missing/invalid key returns
401 Unauthorized; a valid key is required for all secured endpoints. SwaggerAuth(trueinappsettings.json) exposes the security scheme in Swagger UI (Authorize button) and Scalar (persistent authentication) so you can call secured endpoints from both UIs without leaving them.- Options are validated on startup (DataAnnotations; FluentValidation variant is available via
AddConfigurationWithFluentValidation). - For real environments, override the key via environment variables
(
ApiKeyOptions__ApiKey) or user secrets instead of committing it to source control.
Traces and metrics are exported via OpenTelemetry (OTLP) - see the Telemetry section. Point the exporter at a collector such as Jaeger, Grafana Tempo, or an observability platform to store and visualize them.
The service exposes the following health check endpoints:
| Endpoint | Description |
|---|---|
/healthcheck |
Simple readiness probe (checks tagged ready) |
/health |
Full health report with details |
/health/ready |
Readiness endpoint |
/health/live |
Liveness endpoint |
/healthcheck-dashboard |
Health Checks UI dashboard (when enabled in configuration) |
The dashboard is a packaged single page application, so it is themed through a custom style sheet
that is loaded after its own one. The path is configured relative to wwwroot:
"HealthCheckOptions": {
"HeaderText": "Simple Service - Health Checks Status",
"CustomStylesheet": "css/healthcheck-dashboard.css"
}wwwroot/css/healthcheck-dashboard.css
overrides the CSS custom properties the dashboard declares (colors, fonts, surfaces) and follows the
system theme with a dark palette. Every text and background pair it introduces is checked for at
least WCAG AA contrast (4.5:1) in both palettes - keep it that way when changing a color. The logo comes from wwwroot/images/healthcheck-logo.svg
through the --logoImageUrl property - the default logo of the dashboard is a remote image, so
replacing it also removes the external request. Use a root relative url('/images/...') in the
style sheet, because it is served from /ui/resources/css. Clear CustomStylesheet to fall back to
the dashboard defaults; a configured file that is missing in wwwroot is logged as a warning and
also falls back to the defaults.
The root of the service (/) is a Razor Page (src/DotNet.ServiceName.Api/Pages/Index.cshtml) whose
links are built from the configuration of the running environment, so a production deployment does
not advertise UIs it does not expose. Its content is configured in HomePageOptions:
"HomePageOptions": {
"Enabled": true,
"Title": "Simple Service API",
"Description": "Template service with API Key authorization, health checks and OpenTelemetry",
"ShowEnvironment": true,
"ShowDocumentation": true,
"ShowHealthChecks": true,
"Links": []
}| Setting | Effect |
|---|---|
Enabled |
Serve the page on /. When false, Razor Pages are not registered at all and / returns 404 |
Title, Description |
Heading and lead text of the page |
ShowEnvironment |
Show the name of the current environment as a badge |
ShowDocumentation |
Show Swagger, Scalar and Facet Dashboard links - but only when SwaggerEnabled is true for the environment. When they are expected and turned off, the page says that the documentation is not available |
ShowHealthChecks |
Show the health status link, plus the dashboard link when the Health Checks UI is enabled |
Links |
Extra links (title, description, URL, icon, enabled, open in new tab) for anything the configuration above does not cover |
DefaultTheme |
Theme the page starts with: System (default), Light or Dark |
The page is a slim top bar (service name, environment badge, theme switch) above the link cards - the name is the only heading of the document, so the page stays a single h1.
The theme switch has three positions (System, Light, Dark): the choice is stored in the browser
and applied by wwwroot/js/theme.js before the
first paint, so the page never flashes in the wrong theme. The switch is a radio group, so it works
with the keyboard and screen readers, and while System is selected the page follows the operating
system when its theme changes. DefaultTheme only sets where the switch starts.
Turn the whole page off with Enabled, and tune the content per environment - the shipped
appsettings.Production.json is the sample
of a production deployment: SwaggerEnabled: false (so no Swagger, Scalar or Facet links), no
environment badge and a link to an internal runbook. Styles live in
wwwroot/css/home.css and the icons in
wwwroot/icons.
Non-development environments get a set of security headers applied by
NetEscapades.AspNetCore.SecurityHeaders:
HSTS (365 days, subdomains included), CSP, X-Frame-Options, X-Content-Type-Options,
Referrer-Policy, Permissions-Policy, and Cross-Origin policies. See
ApplicationBuilderExtensions.ConfigureSecurityHeaders for the configured policy.
Cross-origin requests are allowed for the origins listed in configuration. The policy answers
preflight (OPTIONS) requests before authentication, so browser clients can send the API key
header without an extra round trip:
"CorsPolicyOptions": {
"Enabled": true,
"AllowedOrigins": [ "http://localhost:4200", "http://localhost:3000" ],
"AllowCredentials": false
}HTTP methods and allowed headers default to the common REST verbs and * - see
CorsPolicyOptions for the full list of settings.
A global fixed-window limiter protects the API from request floods, partitioned by the client
IP address (after forwarded headers are processed). Requests over the limit get 429 Too Many Requests with a ProblemDetails body and a Retry-After header. Health check endpoints are
exempt so monitoring probes are never throttled:
"RateLimitingOptions": {
"Enabled": true,
"PermitLimit": 100,
"WindowSeconds": 60,
"QueueLimit": 0
}Every request is bounded by a default timeout - slow or stuck handlers are aborted with
503 Service Unavailable instead of holding connections open:
"HttpTimeoutOptions": {
"DefaultTimeoutSeconds": 30
}Per-endpoint overrides can be added later with the [RequestTimeout] attribute.
Traces and metrics are collected with OpenTelemetry and exported
over OTLP. When OtlpEndpoint is empty, the standard OTEL_EXPORTER_OTLP_* environment
variables are honored (default endpoint: http://localhost:4317):
"TelemetryOptions": {
"Enabled": true,
"ServiceName": "dotnet-servicename",
"ConsoleExporter": false
}Instrumented out of the box: incoming ASP.NET Core requests, outgoing HttpClient calls, and
runtime metrics (GC, threads, memory). Set ConsoleExporter to true to print telemetry
locally without a collector. Every Serilog request entry also carries the TraceId, so logs
can be correlated with the corresponding trace.
Forwarded headers are processed, but only a loopback proxy is trusted by default so clients
cannot spoof X-Forwarded-*. When running behind a reverse proxy or load balancer, list its
IP address in configuration:
"ForwardedHeaders": {
"KnownProxies": ["10.0.0.5"]
}Two xUnit projects cover the solution (40 tests in total):
DotNet.ServiceName.Application.Tests- unit tests for services, DTO mapping and DI registration (NSubstitute for mocks).DotNet.ServiceName.Api.Tests- integration tests that boot the whole API in-process withWebApplicationFactory: API Key authentication (401/403/200), ProblemDetails responses, CORS policy (preflight, allowed and disallowed origins), rate limiting (429 with ProblemDetails, health endpoints exempt), health check endpoints, OpenAPI document, and telemetry wiring.
# run everything
dotnet test DotNet.ServiceName.sln
# run a single project or filter by name
dotnet test tests/DotNet.ServiceName.Api.Tests
dotnet test --filter "FullyQualifiedName~RateLimiting"- You have Docker installed - ideally latest version of the tool.
- You have .NET 10 installed (SDK and runtime). The required version is pinned in
global.json; rundotnet --versioninside the repository to verify it resolves. - Visual Studio 2022 (17.14+) or JetBrains Rider (2024.1+) or Visual Studio Code as IDE - one of them, better for you, all them is appropriate.
# restore + build + test
dotnet build DotNet.ServiceName.sln -c Release
dotnet test DotNet.ServiceName.sln -c Release
# format / code style check (CI-friendly)
dotnet format DotNet.ServiceName.sln --verify-no-changes
# build and run in Docker (container listens on port 8080 internally)
docker compose up --build
# then open http://localhost:5050/swagger/index.html or http://localhost:5050/scalar
# call a secured endpoint (default local key)
curl -H "X-API-Key: local-dev-api-key" http://localhost:5050/api/v1/values