Skip to content

mparlak/Boilerplate

Repository files navigation

Boilerplate API

.NET 9 Clean Architecture REST API Boilerplate

Project Structure

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

PagedBaseRequest Usage

Model Definition

// Common/Models/PagedBaseRequest.cs
public class PagedBaseRequest
{
    public int Offset { get; set; }
    public int Limit { get; set; }
    public string[]? Orderby { get; set; }
}

Query Implementation

// Features/Products/Queries/GetAll/GetAllProductsQuery.cs
public class GetAllProductsQuery : PagedBaseRequest, IRequest<PagedResponse<IReadOnlyList<GetAllProductsResponse>>>
{
}

Controller Usage

[HttpGet]
public async Task<IActionResult> GetAll(
    [FromQuery] GetAllProductsQuery query,
    CancellationToken cancellationToken = default)
{
    var result = await _mediator.Send(query, cancellationToken);
    return Ok(result);
}

Handler Implementation

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
    };
}

API Request Examples

# 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

Response Format

{
  "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
}

Template Usage

This project can be used as a dotnet new template.

Template Setup

# Install the template (from project root)
dotnet new install .

# Remove the template
dotnet new uninstall .

Create a New Project

dotnet new bp -n Wallet -o Wallet

This command:

  • Creates the Wallet/ directory
  • Replaces all Boilerplate names with Wallet
  • Sets database names to wallet_db and wallet_eventbus_db

Example Usages

# 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

Running the Application

# Restore packages
dotnet restore

# Build
dotnet build

# Run
cd src/host/Boilerplate.Api
dotnet run

Entity Framework Reverse Engineering

Prerequisites:

  • 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 \
    --force

This 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.

Configuration

Update appsettings.json with your PostgreSQL connection string:

{
  "ConnectionStrings": {
    "DefaultConnection": "Host=localhost;Port=5432;Database=boilerplate_db;Username=postgres;Password=postgres"
  }
}

OpenTelemetry Monitoring

The project includes a dedicated Boilerplate.Monitoring library for comprehensive observability.

Features

  • 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

Configuration

{
  "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"
      }
    }
  }
}

Setup in Program.cs

using Boilerplate.Monitoring.Extensions;

// Add services
builder.Services.AddOpenTelemetryMonitoring(builder.Configuration);

// Configure middleware
app.UseOpenTelemetryMonitoring(builder.Configuration);

Custom Metrics Usage

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"));

Custom Tracing Usage

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;
}

Endpoints

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

OTLP Integration

To send traces and metrics to Jaeger, Zipkin, or other OTLP-compatible backends:

{
  "OpenTelemetry": {
    "Exporters": {
      "Otlp": {
        "Enabled": true,
        "Endpoint": "http://localhost:4317",
        "Protocol": "grpc"
      }
    }
  }
}

Idempotency

The project includes built-in idempotency support using Redis to prevent duplicate request processing.

Configuration

{
  "Cache": {
    "Enabled": true,
    "Provider": "Redis",
    "Redis": {
      "ConnectionString": "localhost:6379",
      "InstanceName": "boilerplate:"
    }
  }
}

Setup in Program.cs

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>();
});

Controller Usage

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);
    }
}

Attribute Options

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

API Request Examples

# 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}'

Response Headers

Header Description
X-Idempotency-Cached: true Indicates response was served from cache

How It Works

  1. Client sends request with X-Idempotency-Key header
  2. Filter checks Redis for existing response with that key
  3. If found, returns cached response immediately
  4. If not found, acquires distributed lock and processes request
  5. On success, caches response in Redis with configured TTL
  6. Subsequent requests with same key return cached response

Health Checks

The API includes health check endpoints that monitor the status of dependent services.

Packages

  • AspNetCore.HealthChecks.NpgSql - PostgreSQL check
  • AspNetCore.HealthChecks.Redis - Redis check
  • AspNetCore.HealthChecks.Rabbitmq - RabbitMQ check
  • AspNetCore.HealthChecks.UI.Client - JSON response format

Endpoints

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

Example Response (/health)

{
  "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"]
    }
  }
}

Kubernetes Deployment

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: 30

Docker Compose Health Check

services:
  api:
    image: boilerplate-api:latest
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8080/health/live"]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 40s

Program.cs Configuration

// 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
});

Generic Repository Pattern

The project includes a comprehensive generic repository implementation with advanced query capabilities.

Features

  • 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

Basic Usage

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
        );
    }
}

Pagination

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 & Existence

// 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 Operations

// 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);

Advanced Queries

// 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();

Custom Repository

// 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);
    }
}

Available Methods

Query Methods

  • GetByIdAsync(id) - Get entity by ID
  • GetByIdAsync(id, includes) - Get entity by ID with includes
  • FirstOrDefaultAsync(predicate) - Get first entity matching predicate
  • FirstOrDefaultAsync(predicate, includes) - Get first with includes
  • GetAllAsync() - Get all entities
  • GetAllAsync(includes) - Get all with includes
  • GetAsync(predicate) - Get entities matching predicate
  • GetAsync(predicate, orderBy, includes) - Get with full options
  • GetPagedAsync(pageNumber, pageSize, predicate, orderBy, includes) - Paginated query

Count & Existence

  • CountAsync(predicate) - Count entities
  • AnyAsync(predicate) - Check if entity exists

Command Methods

  • AddAsync(entity) - Add single entity
  • AddRangeAsync(entities) - Add multiple entities
  • UpdateAsync(entity) - Update single entity
  • UpdateRangeAsync(entities) - Update multiple entities
  • DeleteAsync(entity) - Delete by entity
  • DeleteAsync(id) - Delete by ID
  • DeleteRangeAsync(entities) - Delete multiple entities

Advanced

  • AsQueryable() - Get IQueryable for complex queries

Technologies

  • .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

HashiCorp Consul Configuration

This project uses HashiCorp Consul for distributed configuration management.

Docker Compose Usage

# 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/ui

Consul Configuration Structure

Configurations 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

Adding Configuration Manually

# 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 -> Create

Programmatic Usage

using 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

Consul Ports

  • 8500: HTTP API & Web UI
  • 8600: DNS Server (UDP)

About

Clean Architecture Template for .NET 9.0 Built with Onion/Hexagonal Architecture

Topics

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages