.NET 9 Clean Architecture REST API Boilerplate
src/
├── core/
│ ├── Boilerplate.Domain/ # Entities, Repositories interfaces
│ └── Boilerplate.Application/ # CQRS, Validation, Mapping
├── host/
│ └── Boilerplate.Api/ # REST API Controllers
├── infrastructure/
│ └── Boilerplate.Infrastructure/ # EF Core, PostgreSQL
├── consumer/
│ └── Boilerplate.Consumer/ # RabbitMQ Consumer (Background Service)
└── shared/
├── Boilerplate.Common/ # Constants, Models, Base Classes
├── Boilerplate.Logging/ # Serilog, Exceptions, Middleware
├── Boilerplate.Caching/ # Redis, Memory Cache, Idempotency
├── Boilerplate.EventBus/ # MassTransit, RabbitMQ
└── Boilerplate.Monitoring/ # OpenTelemetry, Metrics, Tracing
// Common/Models/PagedBaseRequest.cs
public class PagedBaseRequest
{
public int Offset { get; set; }
public int Limit { get; set; }
public string[]? Orderby { get; set; }
}// Features/Products/Queries/GetAll/GetAllProductsQuery.cs
public class GetAllProductsQuery : PagedBaseRequest, IRequest<PagedResponse<IReadOnlyList<GetAllProductsResponse>>>
{
}[HttpGet]
public async Task<IActionResult> GetAll(
[FromQuery] GetAllProductsQuery query,
CancellationToken cancellationToken = default)
{
var result = await _mediator.Send(query, cancellationToken);
return Ok(result);
}public async Task<PagedResponse<IReadOnlyList<GetAllProductsResponse>>> Handle(
GetAllProductsQuery request,
CancellationToken cancellationToken)
{
var allProducts = await _productRepository.GetAllAsync(cancellationToken);
var totalCount = allProducts.Count;
var pagedProducts = allProducts
.Skip(request.Offset)
.Take(request.Limit > 0 ? request.Limit : 10)
.ToList();
var items = pagedProducts.Adapt<IReadOnlyList<GetAllProductsResponse>>();
return new PagedResponse<IReadOnlyList<GetAllProductsResponse>>
{
Index = request.Offset,
PageSize = request.Limit > 0 ? request.Limit : 10,
Total = totalCount,
Items = items
};
}# Basic pagination
GET /api/products?offset=0&limit=10
# With sorting (single field)
GET /api/products?offset=0&limit=10&orderby=name
# With sorting (multiple fields)
GET /api/products?offset=0&limit=10&orderby=name&orderby=price
# Second page
GET /api/products?offset=10&limit=10{
"index": 0,
"pageSize": 10,
"total": 50,
"items": [
{
"id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"name": "Product 1",
"description": "Description",
"price": 99.99,
"stock": 100,
"isActive": true,
"createdAt": "2024-01-01T00:00:00Z"
}
],
"first": null,
"next": null,
"prev": null,
"last": null
}This project can be used as a dotnet new template.
# Install the template (from project root)
dotnet new install .
# Remove the template
dotnet new uninstall .dotnet new bp -n Wallet -o WalletThis command:
- Creates the
Wallet/directory - Replaces all
Boilerplatenames withWallet - Sets database names to
wallet_dbandwallet_eventbus_db
# E-commerce project
dotnet new bp -n ECommerce -o ECommerce
# Order service
dotnet new bp -n OrderService -o OrderService
# Payment service
dotnet new bp -n Payment -o Payment# Restore packages
dotnet restore
# Build
dotnet build
# Run
cd src/host/Boilerplate.Api
dotnet runPrerequisites:
- Install the EF CLI once:
dotnet tool install --global dotnet-ef - Ensure
appsettings.json(or environment) contains a valid PostgreSQL connection string.
Scaffold syntax (run from repo root) to reverse engineer the database into Infrastructure:
dotnet ef dbcontext scaffold "Host=localhost;Port=5432;Database=boilerplate_db;Username=postgres;Password=postgres" \
Npgsql.EntityFrameworkCore.PostgreSQL \
--project src/infrastructure/Boilerplate.Infrastructure \
--startup-project src/host/Boilerplate.Api \
--context ApplicationDbContext \
--context-dir Persistence \
--context-namespace Boilerplate.Infrastructure.Persistence \
--output-dir ../../core/Boilerplate.Domain/Entities/Generated \
--namespace Boilerplate.Domain.Entities.Generated \
--schema public \
--no-pluralize \
--forceThis keeps the DbContext under src/infrastructure/Boilerplate.Infrastructure/Persistence and places generated entity types under src/core/Boilerplate.Domain/Entities as the single source of truth for domain entities. Adjust the connection string, schemas, and output paths as needed.
Update appsettings.json with your PostgreSQL connection string:
{
"ConnectionStrings": {
"DefaultConnection": "Host=localhost;Port=5432;Database=boilerplate_db;Username=postgres;Password=postgres"
}
}The project includes a dedicated Boilerplate.Monitoring library for comprehensive observability.
- Tracing: ASP.NET Core, HTTP Client, Entity Framework Core instrumentation
- Metrics: Runtime, Process, ASP.NET Core, HTTP Client metrics
- Exporters: Console, OTLP (Jaeger/Zipkin/Tempo), Prometheus
{
"OpenTelemetry": {
"ServiceName": "Boilerplate.Api",
"ServiceVersion": "1.0.0",
"ServiceNamespace": "Boilerplate",
"Tracing": {
"Enabled": true,
"AspNetCore": { "Enabled": true },
"HttpClient": { "Enabled": true },
"EntityFramework": { "Enabled": true }
},
"Metrics": {
"Enabled": true,
"RuntimeInstrumentation": true,
"ProcessInstrumentation": true
},
"Exporters": {
"Console": { "Enabled": false },
"Otlp": {
"Enabled": false,
"Endpoint": "http://localhost:4317",
"Protocol": "grpc"
},
"Prometheus": {
"Enabled": true,
"ScrapeEndpointPath": "/metrics"
}
}
}
}using Boilerplate.Monitoring.Extensions;
// Add services
builder.Services.AddOpenTelemetryMonitoring(builder.Configuration);
// Configure middleware
app.UseOpenTelemetryMonitoring(builder.Configuration);using Boilerplate.Monitoring.Diagnostics;
// Counter
DiagnosticsConfig.RequestCounter.Add(1, new KeyValuePair<string, object?>("endpoint", "/api/products"));
// Error Counter
DiagnosticsConfig.ErrorCounter.Add(1, new KeyValuePair<string, object?>("type", "NotFound"));
// Histogram (duration)
DiagnosticsConfig.RequestDuration.Record(0.5, new KeyValuePair<string, object?>("endpoint", "/api/products"));using Boilerplate.Monitoring.Diagnostics;
// Start a custom activity
using var activity = DiagnosticsConfig.StartActivity("ProcessOrder");
DiagnosticsConfig.AddTag(activity, "orderId", orderId);
try
{
// Your logic here
}
catch (Exception ex)
{
DiagnosticsConfig.RecordException(activity, ex);
throw;
}| Endpoint | Description |
|---|---|
/metrics |
Prometheus metrics scrape endpoint |
/scalar/v1 |
API Documentation (Scalar) |
/health |
Health check - all services |
/health/ready |
Readiness probe |
/health/live |
Liveness probe |
To send traces and metrics to Jaeger, Zipkin, or other OTLP-compatible backends:
{
"OpenTelemetry": {
"Exporters": {
"Otlp": {
"Enabled": true,
"Endpoint": "http://localhost:4317",
"Protocol": "grpc"
}
}
}
}The project includes built-in idempotency support using Redis to prevent duplicate request processing.
{
"Cache": {
"Enabled": true,
"Provider": "Redis",
"Redis": {
"ConnectionString": "localhost:6379",
"InstanceName": "boilerplate:"
}
}
}using Boilerplate.Caching.Extensions;
// Add caching and idempotency services
builder.Services.AddCaching(builder.Configuration);
builder.Services.AddIdempotency();
// Add idempotency filter to controllers
builder.Services.AddControllers(options =>
{
options.Filters.Add<IdempotencyFilter>();
});using Boilerplate.Caching.Idempotency;
[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
[HttpPost]
[Idempotent(ExpirationSeconds = 86400)] // 24 hours
public async Task<ActionResult<BaseResponse<ProductDto>>> Create(CreateProductCommand command)
{
var result = await _mediator.Send(command);
return Ok(result);
}
[HttpPost("transfer")]
[Idempotent(HeaderName = "X-Request-Id", Required = true)]
public async Task<ActionResult<BaseResponse<TransferDto>>> Transfer(TransferCommand command)
{
var result = await _mediator.Send(command);
return Ok(result);
}
}| Option | Default | Description |
|---|---|---|
HeaderName |
X-Idempotency-Key |
Header name for the idempotency key |
ExpirationSeconds |
86400 (24h) |
How long to cache the response |
Required |
true |
Whether the header is required |
# Create product with idempotency key
curl -X POST http://localhost:5000/api/products \
-H "Content-Type: application/json" \
-H "X-Idempotency-Key: unique-request-id-123" \
-d '{"name": "Product 1", "price": 99.99}'
# Retry same request - returns cached response
curl -X POST http://localhost:5000/api/products \
-H "Content-Type: application/json" \
-H "X-Idempotency-Key: unique-request-id-123" \
-d '{"name": "Product 1", "price": 99.99}'| Header | Description |
|---|---|
X-Idempotency-Cached: true |
Indicates response was served from cache |
- Client sends request with
X-Idempotency-Keyheader - Filter checks Redis for existing response with that key
- If found, returns cached response immediately
- If not found, acquires distributed lock and processes request
- On success, caches response in Redis with configured TTL
- Subsequent requests with same key return cached response
The API includes health check endpoints that monitor the status of dependent services.
AspNetCore.HealthChecks.NpgSql- PostgreSQL checkAspNetCore.HealthChecks.Redis- Redis checkAspNetCore.HealthChecks.Rabbitmq- RabbitMQ checkAspNetCore.HealthChecks.UI.Client- JSON response format
| Endpoint | Description | Usage |
|---|---|---|
/health |
Checks all services (PostgreSQL, Redis, RabbitMQ) | Overall health |
/health/ready |
Checks DB and messaging services | Kubernetes readiness probe |
/health/live |
Checks only that the application is running | Kubernetes liveness probe |
{
"status": "Healthy",
"totalDuration": "00:00:00.1234567",
"entries": {
"postgresql": {
"status": "Healthy",
"duration": "00:00:00.0500000",
"tags": ["db", "sql", "postgresql"]
},
"redis": {
"status": "Healthy",
"duration": "00:00:00.0300000",
"tags": ["cache", "redis"]
},
"rabbitmq": {
"status": "Healthy",
"duration": "00:00:00.0400000",
"tags": ["messaging", "rabbitmq"]
}
}
}apiVersion: apps/v1
kind: Deployment
metadata:
name: boilerplate-api
spec:
template:
spec:
containers:
- name: api
image: boilerplate-api:latest
ports:
- containerPort: 8080
livenessProbe:
httpGet:
path: /health/live
port: 8080
initialDelaySeconds: 5
periodSeconds: 10
failureThreshold: 3
readinessProbe:
httpGet:
path: /health/ready
port: 8080
initialDelaySeconds: 10
periodSeconds: 5
failureThreshold: 3
startupProbe:
httpGet:
path: /health/live
port: 8080
initialDelaySeconds: 0
periodSeconds: 5
failureThreshold: 30services:
api:
image: boilerplate-api:latest
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/health/live"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s// Health Checks
builder.Services.AddHealthChecks()
.AddNpgSql(
builder.Configuration.GetConnectionString("DefaultConnection")!,
name: "postgresql",
tags: ["db", "sql", "postgresql"])
.AddRedis(
builder.Configuration["Cache:Redis:ConnectionString"] ?? "localhost:6379",
name: "redis",
tags: ["cache", "redis"])
.AddRabbitMQ(
name: "rabbitmq",
tags: ["messaging", "rabbitmq"]);
// Endpoint mapping
app.MapHealthChecks("/health", new HealthCheckOptions
{
ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse
});
app.MapHealthChecks("/health/ready", new HealthCheckOptions
{
Predicate = check => check.Tags.Contains("db") || check.Tags.Contains("messaging"),
ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse
});
app.MapHealthChecks("/health/live", new HealthCheckOptions
{
Predicate = _ => false
});The project includes a comprehensive generic repository implementation with advanced query capabilities.
- Eager Loading: Load related entities with Include support
- Pagination: Built-in pagination with total count
- Flexible Querying: Predicate, ordering, and includes support
- Bulk Operations: AddRange, UpdateRange, DeleteRange
- Count & Existence: CountAsync, AnyAsync methods
- Advanced Scenarios: AsQueryable for complex queries
public class ProductService
{
private readonly IRepository<Product> _productRepository;
public ProductService(IRepository<Product> productRepository)
{
_productRepository = productRepository;
}
// Get by ID
public async Task<Product?> GetProductAsync(Guid id)
{
return await _productRepository.GetByIdAsync(id);
}
// Get with includes (eager loading)
public async Task<Product?> GetProductWithDetailsAsync(Guid id)
{
return await _productRepository.GetByIdAsync(
id,
p => p.Category,
p => p.Reviews
);
}
// Get all active products
public async Task<IReadOnlyList<Product>> GetActiveProductsAsync()
{
return await _productRepository.GetAsync(p => p.IsActive);
}
// Get with filtering, ordering, and includes
public async Task<IReadOnlyList<Product>> GetProductsAsync()
{
return await _productRepository.GetAsync(
predicate: p => p.IsActive && p.Stock > 0,
orderBy: q => q.OrderByDescending(p => p.CreatedAt),
p => p.Category
);
}
}public async Task<PagedResult<Product>> GetProductsPagedAsync(int page, int pageSize)
{
var (items, totalCount) = await _productRepository.GetPagedAsync(
pageNumber: page,
pageSize: pageSize,
predicate: p => p.IsActive,
orderBy: q => q.OrderByDescending(p => p.CreatedAt),
p => p.Category
);
return new PagedResult<Product>
{
Items = items,
TotalCount = totalCount,
PageNumber = page,
PageSize = pageSize
};
}// Count all products
var totalProducts = await _productRepository.CountAsync();
// Count active products
var activeCount = await _productRepository.CountAsync(p => p.IsActive);
// Check if product exists
var exists = await _productRepository.AnyAsync(p => p.Name == "iPhone 15");// Bulk insert
var newProducts = new List<Product> { product1, product2, product3 };
await _productRepository.AddRangeAsync(newProducts);
// Bulk update
products.ForEach(p => p.IsActive = false);
await _productRepository.UpdateRangeAsync(products);
// Bulk delete
await _productRepository.DeleteRangeAsync(productsToDelete);// Use AsQueryable for complex scenarios
var query = _productRepository.AsQueryable()
.Where(p => p.IsActive)
.GroupBy(p => p.CategoryId)
.Select(g => new CategoryStats
{
CategoryId = g.Key,
ProductCount = g.Count(),
TotalValue = g.Sum(p => p.Price * p.Stock)
});
var stats = await query.ToListAsync();// Interface
public interface IUserRepository : IRepository<User>
{
Task<User?> GetByEmailAsync(string email, CancellationToken cancellationToken = default);
}
// Implementation
public class UserRepository : Repository<User>, IUserRepository
{
public UserRepository(ApplicationDbContext context) : base(context)
{
}
public async Task<User?> GetByEmailAsync(string email, CancellationToken cancellationToken = default)
{
return await _context.Set<User>()
.FirstOrDefaultAsync(u => u.Email == email && !u.IsDeleted, cancellationToken);
}
}GetByIdAsync(id)- Get entity by IDGetByIdAsync(id, includes)- Get entity by ID with includesFirstOrDefaultAsync(predicate)- Get first entity matching predicateFirstOrDefaultAsync(predicate, includes)- Get first with includesGetAllAsync()- Get all entitiesGetAllAsync(includes)- Get all with includesGetAsync(predicate)- Get entities matching predicateGetAsync(predicate, orderBy, includes)- Get with full optionsGetPagedAsync(pageNumber, pageSize, predicate, orderBy, includes)- Paginated query
CountAsync(predicate)- Count entitiesAnyAsync(predicate)- Check if entity exists
AddAsync(entity)- Add single entityAddRangeAsync(entities)- Add multiple entitiesUpdateAsync(entity)- Update single entityUpdateRangeAsync(entities)- Update multiple entitiesDeleteAsync(entity)- Delete by entityDeleteAsync(id)- Delete by IDDeleteRangeAsync(entities)- Delete multiple entities
AsQueryable()- Get IQueryable for complex queries
- .NET 9
- MediatR (CQRS)
- FluentValidation
- Mapster
- Entity Framework Core
- PostgreSQL (Npgsql)
- Scalar (API Documentation)
- Serilog (Logging)
- OpenTelemetry (Tracing & Metrics)
- Redis (Caching & Idempotency)
- MassTransit + RabbitMQ (Event Bus)
- HashiCorp Consul (Configuration Management)
- ASP.NET Core Health Checks
This project uses HashiCorp Consul for distributed configuration management.
# Start all services (including Consul)
docker-compose up -d
# Load initial configuration into Consul
./scripts/consul-init.sh
# Open the Consul UI
open http://localhost:8500/uiConfigurations in Consul are stored with the following structure:
boilerplate/development/
├── ConnectionStrings/
│ └── DefaultConnection
├── Cache/
│ └── Redis/
│ ├── ConnectionString
│ └── InstanceName
├── RabbitMq/
│ ├── Host
│ ├── Port
│ ├── Username
│ └── Password
└── OpenTelemetry/
└── Exporters/
└── Otlp/
├── Enabled
├── Endpoint
└── Protocol
# Via CLI
consul kv put boilerplate/development/ConnectionStrings/DefaultConnection "Host=localhost;Database=mydb;..."
# Via HTTP API
curl -X PUT -d 'value' http://localhost:8500/v1/kv/boilerplate/development/key
# Via Consul UI
# http://localhost:8500/ui -> Key/Value -> Createusing Boilerplate.Configuration.Consul;
var builder = WebApplication.CreateBuilder(args);
// Add Consul as a configuration source
builder.Configuration.AddConsul(
consulAddress: "http://localhost:8500",
keyPrefix: "boilerplate/development/",
reloadOnChange: true
);For detailed usage: Consul Configuration README
- 8500: HTTP API & Web UI
- 8600: DNS Server (UDP)