This guide explains how to set up a local development environment using:
- Kubernetes IN Docker (kind) through Docker Desktop
- .NET Core API with DDD architecture and CQRS pattern
- React.js frontend application
- PostgreSQL database with Flyway migrations
- Complete development workflow with port forwarding
This project demonstrates a local development setup with a .NET Core API, React frontend, and PostgreSQL database running in a Kubernetes cluster. The setup allows developers to:
- Run the database exclusively in Kubernetes
- Run the API either in Kubernetes or locally (both pointing to the same database)
- Run the React frontend locally for development
- Docker Desktop with Kubernetes enabled
- kubectl CLI
- VS Code with appropriate extensions
- Node.js & npm (for React development)
- .NET SDK (for .NET development)
- DBeaver or another PostgreSQL client (optional)
First, ensure Docker Desktop is running with Kubernetes enabled.
# Deploy PostgreSQL service
kubectl apply -f manifests/postgres-deployment.yaml
kubectl apply -f manifests/postgres-service.yaml
# Wait for PostgreSQL to be ready
kubectl wait --for=condition=ready pod -l app.kubernetes.io/component=postgres --timeout=60s# Deploy Flyway for migrations
kubectl apply -f manifests/flyway-deployment.yaml
# Wait for Flyway to be ready
kubectl wait --for=condition=ready pod -l app.kubernetes.io/component=flyway --timeout=60sThe migrations in the migrations/ folder will be automatically applied.
To access the PostgreSQL database from your local machine:
# Port-forward PostgreSQL
kubectl port-forward service/postgres 5432:5432For API access (if deployed to Kubernetes):
# Port-forward API
kubectl port-forward service/api 5000:80This project supports two development workflows for the API:
-
Set up your development settings:
- Copy the example settings file:
cp AJP.API/appsettings.Development.example.json AJP.API/appsettings.Development.json - The development settings file uses
localhostfor database connections (requires port forwarding) - This file is ignored by git to allow for personal development settings
- Copy the example settings file:
-
Ensure port forwarding is active for PostgreSQL:
kubectl port-forward service/postgres 5432:5432- Run the API locally:
cd AJP.API
dotnet runThe API will use the connection string from appsettings.Development.json which points to localhost:5432 (forwarded to the Kubernetes PostgreSQL).
- Build and deploy API changes to Kubernetes:
# Build the API Docker image
docker build -t ajp-api:latest -f Dockerfile.api .
# Apply the deployment
kubectl apply -f api-deployment-updated.yamlWhen deployed to Kubernetes, the API automatically uses the Production configuration with connection string Host=postgres;Port=5432;... to connect directly to the PostgreSQL service within the cluster.
- Set up port forwarding to access the API:
kubectl port-forward service/api 5000:80- Verify the API is running:
curl http://localhost:5000/health
curl http://localhost:5000/productsFor frontend development, simply run:
cd AJP.Frontend/ClientApp
npm startThe React development server will start on port 3000. The frontend is configured to use the environment variable REACT_APP_API_URL to determine which API endpoint to use.
You can easily switch the React app between pointing to the local API or the Kubernetes API using the provided script:
# Switch to local API
./switch-api.sh local
# Switch to Kubernetes API
./switch-api.sh k8s
# Or use the interactive menu
./switch-api.shThis script will:
- Update the
.env.localfile in the React app with the appropriate API URL - Ensure the necessary services are running (e.g., applying the NodePort service)
- Provide an option to restart the frontend to apply changes
After switching, you need to restart the frontend for the changes to take effect.
To access the Kubernetes API from your local machine, you can use the API port-forwarding script:
# Start API port forwarding (creates the service if needed)
./api-port-forward.sh start
# Check status
./api-port-forward.sh status
# Stop port forwarding
./api-port-forward.sh stopYou can also use VS Code Tasks to manage API port-forwarding by:
- Press
Ctrl+Shift+P(orCmd+Shift+Pon macOS) - Type "Tasks: Run Task"
- Select one of the following:
api-port-forward-start- Start the port-forward in the backgroundapi-port-forward-status- Check if port-forwarding is runningapi-port-forward-stop- Stop the port-forward
To connect to the PostgreSQL database using DBeaver:
- Start database port-forwarding:
# Method 1: Use the script (recommended, runs in background)
./db-port-forward.sh start
# Method 2: Use kubectl directly (runs in current terminal)
kubectl port-forward service/postgres 5432:5432-
Connect using these details:
- Host: localhost
- Port: 5432
- Database: ajp_db
- Username: postgres
- Password: postgres
-
Check port-forward status or stop it when done:
# Check if port-forwarding is running
./db-port-forward.sh status
# Stop port-forwarding when finished
./db-port-forward.sh stopYou can also use VS Code Tasks to manage database port-forwarding by:
- Press
Ctrl+Shift+P(orCmd+Shift+Pon macOS) - Type "Tasks: Run Task"
- Select one of the following:
db-port-forward-start- Start the port-forward in the backgrounddb-port-forward-status- Check if port-forwarding is runningdb-port-forward-stop- Stop the port-forward
The project uses Flyway for database migrations.
-
Create a new SQL file in the
migrations/folder with a versioned filename:- Example:
V3__add_new_table.sql - Naming convention:
V{version}__{description}.sql
- Example:
-
Add your SQL statements to the file.
-
Migrations will be automatically applied when the Flyway pod starts, or you can manually apply them with:
kubectl exec -it $(kubectl get pod -l app.kubernetes.io/component=flyway -o jsonpath="{.items[0].metadata.name}") -- flyway migratekubectl exec -it $(kubectl get pod -l app.kubernetes.io/component=flyway -o jsonpath="{.items[0].metadata.name}") -- flyway info- PostgreSQL: Runs in Kubernetes only
- API: Can run either locally or in Kubernetes
- When in Kubernetes: Uses connection string
Host=postgres;Port=5432;...from Production configuration - When running locally: Uses connection string
Host=localhost;Port=5432;...from Development configuration (via port forwarding)
- When in Kubernetes: Uses connection string
- Frontend: Runs locally in development mode, connects to API via
localhost:5000
This architecture allows for flexible development where:
- Database always runs in Kubernetes for consistency
- API can run locally for fast development or in Kubernetes for end-to-end testing
- Frontend runs locally for fast development with hot reloading
The project uses environment-specific configuration files:
- appsettings.json: Base configuration (default settings)
- appsettings.Development.json: Local development settings (git-ignored)
- appsettings.Development.example.json: Example development settings (template for developers)
- appsettings.Production.json: Production settings (used when deployed to Kubernetes)
When you first set up the project, copy the example file:
cp AJP.API/appsettings.Development.example.json AJP.API/appsettings.Development.json# View all pods
kubectl get pods
# View services
kubectl get services
# View logs for API
kubectl logs -l app.kubernetes.io/name=ajp-api
# View logs for PostgreSQL
kubectl logs -l app.kubernetes.io/component=postgres
# View logs for Flyway
kubectl logs -l app.kubernetes.io/component=flyway
# Restart API deployment
kubectl rollout restart deployment ajp-api
# Test API health from within the cluster
kubectl run curl-test --image=curlimages/curl -i --tty --rm -- curl http://api/health# Get all products
curl -s http://localhost:5000/products | jq
# Get a specific product
curl -s http://localhost:5000/products/1 | jq
# Create a new product
curl -s -X POST http://localhost:5000/products \
-H "Content-Type: application/json" \
-d '{"name":"Test Product","description":"A test product","price":19.99}' | jq
# Update a product
curl -s -X PUT http://localhost:5000/products/1 \
-H "Content-Type: application/json" \
-d '{"id":1,"name":"Updated Product","description":"Updated description","price":29.99}' | jq
# Delete a product
curl -s -X DELETE http://localhost:5000/products/1- Issue: DBeaver connection fails or API cannot connect to database
- Solution:
- Ensure port forwarding is active:
kubectl port-forward service/postgres 5432:5432 - Check PostgreSQL pod is running:
kubectl get pods -l app.kubernetes.io/component=postgres - Verify PostgreSQL logs:
kubectl logs -l app.kubernetes.io/component=postgres
- Ensure port forwarding is active:
- Issue: Frontend cannot retrieve data from API
- Solution:
- If API is running in Kubernetes:
- Ensure port forwarding is active:
kubectl port-forward service/api 5000:80 - Or use the NodePort service:
kubectl apply -f api-nodeport-service.yaml - Check API pod is running:
kubectl get pods -l app.kubernetes.io/name=ajp-api - Check API logs:
kubectl logs -l app.kubernetes.io/name=ajp-api
- Ensure port forwarding is active:
- If API is running locally:
- Ensure API is running:
cd AJP.API && dotnet run - Verify PostgreSQL port forwarding is active
- Ensure API is running:
- If API is running in Kubernetes:
- Issue: Browser shows CORS errors when accessing API from frontend
- Solution:
- Make sure you're using the correct port (5000 for local API, 31481 for NodePort)
- Ensure the API is running with the updated CORS configuration
- If running in Kubernetes, redeploy the API after updating CORS settings
- Check that the Origin header in your request matches the allowed origins
- Issue: Schema changes not showing up in the database
- Solution:
- Check Flyway pod logs:
kubectl logs -l app.kubernetes.io/component=flyway - Manually apply migrations:
kubectl exec -it $(kubectl get pod -l app.kubernetes.io/component=flyway -o jsonpath="{.items[0].metadata.name}") -- flyway migrate - Verify migration files are in the correct format:
V{version}__{description}.sql
- Check Flyway pod logs:
- Issue: Frontend displays error or cannot load data
- Solution:
- Ensure API is accessible at http://localhost:5000
- Check browser console for CORS errors
- Verify frontend is configured with the correct API URL
AJP.KubeExample.sln # Solution file
api-deployment.yaml # Kubernetes deployment for API
Dockerfile.api # Dockerfile for API
Dockerfile.flyway # Dockerfile for Flyway
AJP.API/ # API project
appsettings.json # API configuration
Program.cs # API entry point
AJP.Application/ # Application layer (CQRS, validation)
AJP.Domain/ # Domain layer (entities, interfaces)
AJP.Infrastructure/ # Infrastructure layer
AJP.Infrastructure.Persistence/ # Database access
AJP.Frontend/ # React frontend
ClientApp/ # React application
manifests/ # Kubernetes manifests
postgres-deployment.yaml # PostgreSQL deployment
postgres-service.yaml # PostgreSQL service
flyway-deployment.yaml # Flyway deployment
migrations/ # SQL migration files
V1__Initial_Schema.sql # Initial database schema
V2__Add_Users.sql # Additional migrations
Before setting up Kubernetes, let's create a properly structured .NET solution following Domain-Driven Design principles:
First, let's configure code style and package management:
create file: AJP.KubeExample/.editorconfig
root = true
[*]
indent_style = space
indent_size = 4
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.cs]
dotnet_sort_system_directives_first = true
dotnet_separate_import_directive_groups = false
dotnet_naming_rule.interface_should_be_begins_with_i.severity = suggestion
dotnet_naming_rule.interface_should_be_begins_with_i.symbols = interface
dotnet_naming_rule.interface_should_be_begins_with_i.style = begins_with_i
dotnet_naming_rule.types_should_be_pascal_case.severity = suggestion
dotnet_naming_rule.types_should_be_pascal_case.symbols = types
dotnet_naming_rule.types_should_be_pascal_case.style = pascal_case
dotnet_naming_rule.non_field_members_should_be_pascal_case.severity = suggestion
dotnet_naming_rule.non_field_members_should_be_pascal_case.symbols = non_field_members
dotnet_naming_rule.non_field_members_should_be_pascal_case.style = pascal_case
dotnet_naming_rule.private_or_internal_field_should_be_camel_case.severity = suggestion
dotnet_naming_rule.private_or_internal_field_should_be_camel_case.symbols = private_or_internal_field
dotnet_naming_rule.private_or_internal_field_should_be_camel_case.style = camel_case_with_underscore
dotnet_naming_symbols.interface.applicable_kinds = interface
dotnet_naming_symbols.interface.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
dotnet_naming_symbols.types.applicable_kinds = class, struct, interface, enum
dotnet_naming_symbols.types.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
dotnet_naming_symbols.non_field_members.applicable_kinds = property, event, method
dotnet_naming_symbols.non_field_members.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
dotnet_naming_symbols.private_or_internal_field.applicable_kinds = field
dotnet_naming_symbols.private_or_internal_field.applicable_accessibilities = internal, private, private_protected
dotnet_naming_style.begins_with_i.required_prefix = I
dotnet_naming_style.begins_with_i.capitalization = pascal_case
dotnet_naming_style.pascal_case.capitalization = pascal_case
dotnet_naming_style.camel_case_with_underscore.required_prefix = _
dotnet_naming_style.camel_case_with_underscore.capitalization = camel_case
dotnet_style_object_initializer = true:suggestion
dotnet_style_collection_initializer = true:suggestion
dotnet_style_explicit_tuple_names = true:suggestion
dotnet_style_coalesce_expression = true:suggestion
dotnet_style_null_propagation = true:suggestion
dotnet_style_prefer_is_null_check_over_reference_equality_method = true:suggestion
dotnet_style_prefer_auto_properties = true:suggestion
csharp_new_line_before_open_brace = all
csharp_new_line_before_else = true
csharp_new_line_before_catch = true
csharp_new_line_before_finally = true
csharp_indent_case_contents = true
csharp_indent_switch_labels = true
csharp_indent_labels = flush_leftcreate file: AJP.KubeExample/Directory.Build.props
<Project>
<PropertyGroup>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<Authors>AJP.KubeExample Team</Authors>
<Version>1.0.0</Version>
<AssemblyVersion>1.0.0.0</AssemblyVersion>
<FileVersion>1.0.0.0</FileVersion>
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);CS1591</NoWarn>
<Deterministic>true</Deterministic>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="SonarAnalyzer.CSharp" PrivateAssets="all" />
</ItemGroup>
<ItemGroup Condition="'$(MSBuildProjectExtension)' != '.dcproj'">
<PackageReference Include="StyleCop.Analyzers" PrivateAssets="all" />
</ItemGroup>
</Project>create file: AJP.KubeExample/Directory.Packages.props
<Project>
<PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>
<ItemGroup>
<PackageVersion Include="Microsoft.AspNetCore.Cors" Version="2.2.0" />
<PackageVersion Include="Microsoft.Extensions.Configuration" Version="7.0.0" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="7.0.0" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="7.0.0" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="7.0.0" />
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="7.0.0" />
<PackageVersion Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="7.0.0" />
<PackageVersion Include="Microsoft.OpenApi" Version="1.6.9" />
<PackageVersion Include="Swashbuckle.AspNetCore" Version="6.5.0" />
<PackageVersion Include="MediatR" Version="12.1.1" />
<PackageVersion Include="MediatR.Extensions.Microsoft.DependencyInjection" Version="11.1.0" />
<PackageVersion Include="FluentValidation" Version="11.7.1" />
<PackageVersion Include="FluentValidation.DependencyInjection" Version="11.7.1" />
<PackageVersion Include="AutoMapper" Version="12.0.1" />
<PackageVersion Include="AutoMapper.Extensions.Microsoft.DependencyInjection" Version="12.0.1" />
<PackageVersion Include="Dapper" Version="2.0.151" />
<PackageVersion Include="Npgsql" Version="7.0.6" />
<PackageVersion Include="prometheus-net.AspNetCore" Version="8.0.1" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.7.2" />
<PackageVersion Include="xunit" Version="2.5.0" />
<PackageVersion Include="xunit.runner.visualstudio" Version="2.5.0" />
<PackageVersion Include="Moq" Version="4.20.69" />
<PackageVersion Include="FluentAssertions" Version="6.12.0" />
<PackageVersion Include="SonarAnalyzer.CSharp" Version="9.12.0.78982" />
<PackageVersion Include="StyleCop.Analyzers" Version="1.1.118" />
</ItemGroup>
<ItemGroup Condition="'$(MSBuildProjectExtension)' == '.dcproj'">
<PackageVersion Remove="@(PackageVersion)" />
</ItemGroup>
</Project>create (run) command to create the solution
dotnet new sln -n AJP.KubeExampleNow let's create the projects with our standardized settings:
# Create projects for DDD architecture
dotnet new webapi -n AJP.API
dotnet new classlib -n AJP.Domain
dotnet new classlib -n AJP.Application
dotnet new classlib -n AJP.Infrastructure
dotnet new classlib -n AJP.Infrastructure.Persistence
dotnet new xunit -n AJP.UnitTests
# Create the React frontend project
dotnet new react -n AJP.Frontend
# Add projects to the solution
dotnet sln add AJP.API/AJP.API.csproj
dotnet sln add AJP.Domain/AJP.Domain.csproj
dotnet sln add AJP.Application/AJP.Application.csproj
dotnet sln add AJP.Infrastructure/AJP.Infrastructure.csproj
dotnet sln add AJP.Infrastructure.Persistence/AJP.Infrastructure.Persistence.csproj
dotnet sln add AJP.UnitTests/AJP.UnitTests.csproj
dotnet sln add AJP.Frontend/AJP.Frontend.csproj# API dependencies
cd AJP.API
dotnet add reference ../AJP.Application/AJP.Application.csproj
dotnet add reference ../AJP.Infrastructure/AJP.Infrastructure.csproj
dotnet add reference ../AJP.Infrastructure.Persistence/AJP.Infrastructure.Persistence.csproj
# Application layer dependencies
cd ../AJP.Application
dotnet add reference ../AJP.Domain/AJP.Domain.csproj
# Infrastructure dependencies
cd ../AJP.Infrastructure
dotnet add reference ../AJP.Domain/AJP.Domain.csproj
dotnet add reference ../AJP.Application/AJP.Application.csproj
# Persistence dependencies
cd ../AJP.Infrastructure.Persistence
dotnet add reference ../AJP.Domain/AJP.Domain.csproj
dotnet add reference ../AJP.Application/AJP.Application.csproj# API packages
cd ../AJP.API
dotnet add package Microsoft.AspNetCore.Cors
dotnet add package Microsoft.Extensions.DependencyInjection
dotnet add package Microsoft.Extensions.Configuration
dotnet add package Swashbuckle.AspNetCore
# Application packages
cd ../AJP.Application
dotnet add package MediatR
dotnet add package MediatR.Extensions.Microsoft.DependencyInjection
dotnet add package FluentValidation
dotnet add package AutoMapper
dotnet add package AutoMapper.Extensions.Microsoft.DependencyInjection
# Infrastructure packages
cd ../AJP.Infrastructure
dotnet add package Microsoft.Extensions.DependencyInjection.Abstractions
dotnet add package Microsoft.Extensions.Configuration.Abstractions
# Persistence packages (Dapper + PostgreSQL)
cd ../AJP.Infrastructure.Persistence
dotnet add package Dapper
dotnet add package Npgsql
dotnet add package Microsoft.Extensions.Configuration
dotnet add package Microsoft.Extensions.Options.ConfigurationExtensionsCreate the core domain entities, value objects, and domain services:
cd ../AJP.Domain
mkdir -p Entities
mkdir -p ValueObjects
mkdir -p Exceptions
mkdir -p Events
mkdir -p InterfacesCreate a sample entity in AJP.Domain/Entities/Product.cs:
namespace AJP.Domain.Entities;
public class Product
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public decimal Price { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
}Create the CQRS structure in the Application layer using MediatR:
cd ../AJP.Application
mkdir -p Common/Behaviors
mkdir -p Common/Interfaces
mkdir -p Common/Models
mkdir -p Common/Validation
mkdir -p Products/Commands/CreateProduct
mkdir -p Products/Commands/UpdateProduct
mkdir -p Products/Commands/DeleteProduct
mkdir -p Products/Queries/GetProductById
mkdir -p Products/Queries/GetAllProductsFirst, install the MediatR and FluentValidation packages:
dotnet add package MediatR
dotnet add package FluentValidation
dotnet add package FluentValidation.DependencyInjectionUnlike the custom CQRS implementation, MediatR provides the interfaces we need out of the box:
// No need to create custom interfaces
// MediatR provides IRequest<TResponse> for commands and queries
// MediatR provides IRequestHandler<TRequest, TResponse> for handlersCreate a repository interface in AJP.Application/Common/Interfaces/IProductRepository.cs:
using AJP.Domain.Entities;
namespace AJP.Application.Common.Interfaces;
public interface IProductRepository
{
Task<IEnumerable<Product>> GetAllAsync();
Task<Product?> GetByIdAsync(int id);
Task<int> CreateAsync(Product product);
Task<bool> UpdateAsync(Product product);
Task<bool> DeleteAsync(int id);
}Create a base response class in AJP.Application/Common/Models/Result.cs:
namespace AJP.Application.Common.Models;
// Record is perfect for immutable value objects
public record Result<T>
{
public bool IsSuccess { get; }
public T? Value { get; }
public string Error { get; }
private Result(bool isSuccess, T? value, string error) =>
(IsSuccess, Value, Error) = (isSuccess, value, error);
public static Result<T> Success(T value) => new(true, value, string.Empty);
public static Result<T> Failure(string error) => new(false, default, error);
}Create validation infrastructure in AJP.Application/Common/Validation/ValidationException.cs:
using FluentValidation.Results;
namespace AJP.Application.Common.Validation;
public class ValidationException : Exception
{
public ValidationException()
: base("One or more validation failures have occurred.")
{
Errors = new Dictionary<string, string[]>();
}
public ValidationException(IEnumerable<ValidationFailure> failures)
: this()
{
Errors = failures
.GroupBy(e => e.PropertyName, e => e.ErrorMessage)
.ToDictionary(failureGroup => failureGroup.Key, failureGroup => failureGroup.ToArray());
}
public IDictionary<string, string[]> Errors { get; }
}Now, let's create a validation behavior for MediatR in AJP.Application/Common/Behaviors/ValidationBehavior.cs:
using FluentValidation;
using MediatR;
namespace AJP.Application.Common.Behaviors;
// Using primary constructor
public class ValidationBehavior<TRequest, TResponse>(IEnumerable<IValidator<TRequest>> validators)
: IPipelineBehavior<TRequest, TResponse>
where TRequest : IRequest<TResponse>
{
public async Task<TResponse> Handle(TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken cancellationToken)
{
if (validators.Any())
{
var context = new ValidationContext<TRequest>(request);
var validationResults = await Task.WhenAll(
validators.Select(v =>
v.ValidateAsync(context, cancellationToken)));
var failures = validationResults
.SelectMany(r => r.Errors)
.Where(f => f != null)
.ToList();
if (failures.Count != 0)
throw new Validation.ValidationException(failures);
}
return await next();
}
}Now implement the queries and commands using MediatR:
- Create the GetAllProducts query in
AJP.Application/Products/Queries/GetAllProducts/GetAllProductsQuery.cs:
using AJP.Domain.Entities;
using MediatR;
namespace AJP.Application.Products.Queries.GetAllProducts;
// Define the query as an IRequest with the expected return type
public record GetAllProductsQuery : IRequest<IEnumerable<Product>>;
// Define the handler for the query with primary constructor
public class GetAllProductsQueryHandler(IProductRepository productRepository)
: IRequestHandler<GetAllProductsQuery, IEnumerable<Product>>
{
// MediatR uses Handle instead of HandleAsync
public async Task<IEnumerable<Product>> Handle(GetAllProductsQuery query, CancellationToken cancellationToken)
{
return await productRepository.GetAllAsync();
}
}- Create the GetProductById query in
AJP.Application/Products/Queries/GetProductById/GetProductByIdQuery.cs:
using AJP.Application.Common.Interfaces;
using AJP.Application.Common.Models;
using AJP.Domain.Entities;
using MediatR;
namespace AJP.Application.Products.Queries.GetProductById;
// Define the query with primary constructor for the product ID
public record GetProductByIdQuery(int Id) : IRequest<Result<Product>>;
// Define the handler for the query with primary constructor
public class GetProductByIdQueryHandler(IProductRepository productRepository)
: IRequestHandler<GetProductByIdQuery, Result<Product>>
{
public async Task<Result<Product>> Handle(GetProductByIdQuery query, CancellationToken cancellationToken)
{
var product = await productRepository.GetByIdAsync(query.Id);
if (product == null)
return Result<Product>.Failure($"Product with ID {query.Id} not found.");
return Result<Product>.Success(product);
}
}- Create the CreateProduct command in
AJP.Application/Products/Commands/CreateProduct/CreateProductCommand.cs:
using AJP.Domain.Entities;
using FluentValidation;
using MediatR;
namespace AJP.Application.Products.Commands.CreateProduct;
// Define the command with primary constructor and the expected return type
public record CreateProductCommand(
string Name,
string Description,
decimal Price) : IRequest<int>;
// Define the validator for the command
public class CreateProductCommandValidator : AbstractValidator<CreateProductCommand>
{
public CreateProductCommandValidator()
{
RuleFor(v => v.Name)
.NotEmpty().WithMessage("Name is required.")
.MaximumLength(100).WithMessage("Name must not exceed 100 characters.");
RuleFor(v => v.Price)
.GreaterThan(0).WithMessage("Price must be greater than 0.");
}
}
// Define the handler for the command with primary constructor
public class CreateProductCommandHandler(IProductRepository productRepository)
: IRequestHandler<CreateProductCommand, int>
{
public async Task<int> Handle(CreateProductCommand command, CancellationToken cancellationToken)
{
var product = new Product
{
Name = command.Name,
Description = command.Description,
Price = command.Price,
CreatedAt = DateTime.UtcNow
};
return await productRepository.CreateAsync(product);
}
}- Create the UpdateProduct command in
AJP.Application/Products/Commands/UpdateProduct/UpdateProductCommand.cs:
using AJP.Application.Common.Interfaces;
using AJP.Application.Common.Models;
using AJP.Domain.Entities;
using FluentValidation;
using MediatR;
namespace AJP.Application.Products.Commands.UpdateProduct;
// Using record with primary constructor
public record UpdateProductCommand(
int Id,
string Name,
string Description,
decimal Price) : IRequest<Result<bool>>;
public class UpdateProductCommandValidator : AbstractValidator<UpdateProductCommand>
{
public UpdateProductCommandValidator()
{
RuleFor(v => v.Id)
.GreaterThan(0).WithMessage("Id must be greater than 0.");
RuleFor(v => v.Name)
.NotEmpty().WithMessage("Name is required.")
.MaximumLength(100).WithMessage("Name must not exceed 100 characters.");
RuleFor(v => v.Price)
.GreaterThan(0).WithMessage("Price must be greater than 0.");
}
}
// Using primary constructor
public class UpdateProductCommandHandler(IProductRepository productRepository)
: IRequestHandler<UpdateProductCommand, Result<bool>>
{
public async Task<Result<bool>> Handle(UpdateProductCommand command, CancellationToken cancellationToken)
{
var product = new Product
{
Id = command.Id,
Name = command.Name,
Description = command.Description,
Price = command.Price,
UpdatedAt = DateTime.UtcNow
};
var success = await productRepository.UpdateAsync(product);
if (!success)
return Result<bool>.Failure($"Product with ID {command.Id} not found.");
return Result<bool>.Success(true);
}
}- Create the DeleteProduct command in
AJP.Application/Products/Commands/DeleteProduct/DeleteProductCommand.cs:
using AJP.Application.Common.Interfaces;
using AJP.Application.Common.Models;
using FluentValidation;
using MediatR;
namespace AJP.Application.Products.Commands.DeleteProduct;
// Using record with primary constructor
public record DeleteProductCommand(int Id) : IRequest<Result<bool>>;
public class DeleteProductCommandValidator : AbstractValidator<DeleteProductCommand>
{
public DeleteProductCommandValidator()
{
RuleFor(v => v.Id)
.GreaterThan(0).WithMessage("Id must be greater than 0.");
}
}
// Using primary constructor
public class DeleteProductCommandHandler(IProductRepository productRepository)
: IRequestHandler<DeleteProductCommand, Result<bool>>
{
public async Task<Result<bool>> Handle(DeleteProductCommand command, CancellationToken cancellationToken)
{
var success = await productRepository.DeleteAsync(command.Id);
if (!success)
return Result<bool>.Failure($"Product with ID {command.Id} not found.");
return Result<bool>.Success(true);
}
}- Finally, register MediatR and the services in
AJP.Application/DependencyInjection.cs:
using System.Reflection;
using AJP.Application.Common.Behaviors;
using FluentValidation;
using MediatR;
using Microsoft.Extensions.DependencyInjection;
namespace AJP.Application;
public static class DependencyInjection
{
public static IServiceCollection AddApplication(this IServiceCollection services)
{
// Register MediatR
services.AddMediatR(cfg =>
{
cfg.RegisterServicesFromAssembly(Assembly.GetExecutingAssembly());
// Add validation behavior to the pipeline
cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));
});
// Register validators
services.AddValidatorsFromAssembly(Assembly.GetExecutingAssembly());
return services;
}
}Now we need to implement the repository interface in the Infrastructure.Persistence layer to work with our Channels-based CQRS implementation:
cd ../AJP.Infrastructure.PersistenceUpdate the ProductRepository.cs file to implement the interface from the Application layer:
using System.Data;
using AJP.Application.Common.Interfaces;
using AJP.Domain.Entities;
using Dapper;
using Microsoft.Extensions.Logging;
namespace AJP.Infrastructure.Persistence.Repositories;
public class ProductRepository : IProductRepository
{
private readonly IDbConnection _dbConnection;
private readonly ILogger<ProductRepository> _logger;
public ProductRepository(IDbConnection dbConnection, ILogger<ProductRepository> logger)
{
_dbConnection = dbConnection;
_logger = logger;
}
public async Task<IEnumerable<Product>> GetAllAsync()
{
try
{
const string sql = "SELECT * FROM Products ORDER BY Id";
return await _dbConnection.QueryAsync<Product>(sql);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error retrieving products");
return Enumerable.Empty<Product>();
}
}
public async Task<Product?> GetByIdAsync(int id)
{
try
{
const string sql = "SELECT * FROM Products WHERE Id = @Id";
return await _dbConnection.QueryFirstOrDefaultAsync<Product>(sql, new { Id = id });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error retrieving product with ID {ProductId}", id);
return null;
}
}
public async Task<int> CreateAsync(Product product)
{
try
{
const string sql = @"
INSERT INTO Products (Name, Description, Price, CreatedAt)
VALUES (@Name, @Description, @Price, @CreatedAt)
RETURNING Id";
return await _dbConnection.ExecuteScalarAsync<int>(sql, product);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error creating product {ProductName}", product.Name);
return -1;
}
}
public async Task<bool> UpdateAsync(Product product)
{
try
{
const string sql = @"
UPDATE Products
SET Name = @Name,
Description = @Description,
Price = @Price,
UpdatedAt = @UpdatedAt
WHERE Id = @Id";
var rowsAffected = await _dbConnection.ExecuteAsync(sql, product);
return rowsAffected > 0;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error updating product with ID {ProductId}", product.Id);
return false;
}
}
public async Task<bool> DeleteAsync(int id)
{
try
{
const string sql = "DELETE FROM Products WHERE Id = @Id";
var rowsAffected = await _dbConnection.ExecuteAsync(sql, new { Id = id });
return rowsAffected > 0;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error deleting product with ID {ProductId}", id);
return false;
}
}
}Now we'll update the API layer to use our MediatR-based CQRS implementation:
cd ../AJP.APIUpdate Program.cs to register the Application and Infrastructure services:
using AJP.Application;
using AJP.Application.Products.Commands.CreateProduct;
using AJP.Application.Products.Commands.DeleteProduct;
using AJP.Application.Products.Commands.UpdateProduct;
using AJP.Application.Products.Queries.GetAllProducts;
using AJP.Application.Products.Queries.GetProductById;
using AJP.Infrastructure;
using AJP.Infrastructure.Persistence;
using MediatR;
using FluentValidation;
var builder = WebApplication.CreateBuilder(args);
// Add services to the container
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
// Register application layer
builder.Services.AddApplication();
// Register infrastructure layer
builder.Services.AddInfrastructure(builder.Configuration);
builder.Services.AddPersistence(builder.Configuration);
var app = builder.Build();
// Configure the HTTP request pipeline
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseHttpsRedirection();
// Group endpoints by feature
var productsGroup = app.MapGroup("/products")
.WithTags("Products")
.WithOpenApi();
productsGroup.MapGet("/", async (IMediator mediator) =>
{
var products = await mediator.Send(new GetAllProductsQuery());
return Results.Ok(products);
})
.WithName("GetAllProducts");
productsGroup.MapGet("/{id}", async (int id, IMediator mediator) =>
{
var result = await mediator.Send(new GetProductByIdQuery(id));
return result.IsSuccess
? Results.Ok(result.Value)
: Results.NotFound(result.Error);
})
.WithName("GetProductById");
productsGroup.MapPost("/", async (CreateProductCommand command, IMediator mediator) =>
{
try
{
var id = await mediator.Send(command);
return Results.Created($"/products/{id}", id);
}
catch (ValidationException ex)
{
return Results.BadRequest(ex.Errors);
}
})
.WithName("CreateProduct");
productsGroup.MapPut("/{id}", async (int id, UpdateProductCommand command, IMediator mediator) =>
{
if (id != command.Id)
return Results.BadRequest("ID in URL does not match ID in request body");
try
{
var result = await mediator.Send(command);
if (!result.IsSuccess)
return Results.NotFound(result.Error);
return Results.NoContent();
}
catch (ValidationException ex)
{
return Results.BadRequest(ex.Errors);
}
})
.WithName("UpdateProduct");
productsGroup.MapDelete("/{id}", async (int id, IMediator mediator) =>
{
var command = new DeleteProductCommand(id);
var result = await mediator.Send(command);
if (!result.IsSuccess)
return Results.NotFound(result.Error);
return Results.NoContent();
})
.WithName("DeleteProduct");
app.Run();To handle database migrations in a clean, version-controlled way, we'll use Flyway in our Kubernetes environment.
Update your devspace.yaml to include Flyway:
deployments:
# Existing deployments...
flyway:
helm:
values:
containers:
- name: flyway
image: flyway/flyway:9.21
command: ["sleep", "infinity"]
volumeMounts:
- name: migrations
mountPath: /flyway/sql
volumes:
- name: migrations
hostPath:
path: ./AJP.Infrastructure.Persistence/Migrationsmkdir -p AJP.Infrastructure.Persistence/MigrationsCreate your first migration file in AJP.Infrastructure.Persistence/Migrations/V1__Initial_Schema.sql:
CREATE TABLE IF NOT EXISTS Products (
Id SERIAL PRIMARY KEY,
Name VARCHAR(100) NOT NULL,
Description TEXT,
Price DECIMAL(18,2) NOT NULL,
CreatedAt TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
UpdatedAt TIMESTAMP NULL
);
-- Add some test data
INSERT INTO Products (Name, Description, Price, CreatedAt)
VALUES ('Test Product 1', 'Description for test product 1', 19.99, CURRENT_TIMESTAMP),
('Test Product 2', 'Description for test product 2', 29.99, CURRENT_TIMESTAMP)
ON CONFLICT (Id) DO NOTHING;Create a .vscode/tasks.json file to simplify working with migrations:
{
"version": "2.0.0",
"tasks": [
{
"label": "Flyway: Apply Migrations",
"type": "shell",
"command": "kubectl exec -i $(kubectl get pods -l app.kubernetes.io/component=postgres -o jsonpath='{.items[0].metadata.name}') -- flyway -url=jdbc:postgresql://localhost:5432/ajp_db -user=postgres -password=${input:dbPassword} migrate",
"problemMatcher": [],
"presentation": {
"reveal": "always",
"panel": "new"
},
"group": "none"
},
{
"label": "Flyway: Validate Migrations",
"type": "shell",
"command": "kubectl exec -i $(kubectl get pods -l app.kubernetes.io/component=postgres -o jsonpath='{.items[0].metadata.name}') -- flyway -url=jdbc:postgresql://localhost:5432/ajp_db -user=postgres -password=${input:dbPassword} validate",
"problemMatcher": [],
"presentation": {
"reveal": "always",
"panel": "new"
},
"group": "none"
},
{
"label": "Flyway: Undo Last Migration",
"type": "shell",
"command": "kubectl exec -i $(kubectl get pods -l app.kubernetes.io/component=postgres -o jsonpath='{.items[0].metadata.name}') -- flyway -url=jdbc:postgresql://localhost:5432/ajp_db -user=postgres -password=${input:dbPassword} undo",
"problemMatcher": [],
"presentation": {
"reveal": "always",
"panel": "new"
},
"group": "none"
},
{
"label": "Flyway: Show Migration Info",
"type": "shell",
"command": "kubectl exec -i $(kubectl get pods -l app.kubernetes.io/component=postgres -o jsonpath='{.items[0].metadata.name}') -- flyway -url=jdbc:postgresql://localhost:5432/ajp_db -user=postgres -password=${input:dbPassword} info",
"problemMatcher": [],
"presentation": {
"reveal": "always",
"panel": "new"
},
"group": "none"
},
{
"label": "Flyway: Create New Migration",
"type": "shell",
"command": "echo \"-- V$(date +%Y%m%d%H%M%S)__${input:migrationName}.sql\n\n-- Write your migration SQL here\" > ./AJP.Infrastructure.Persistence/Migrations/V$(date +%Y%m%d%H%M%S)__${input:migrationName}.sql && code ./AJP.Infrastructure.Persistence/Migrations/V$(date +%Y%m%d%H%M%S)__${input:migrationName}.sql",
"problemMatcher": [],
"presentation": {
"reveal": "always",
"panel": "new"
},
"group": "none"
}
],
"inputs": [
{
"id": "dbPassword",
"description": "Database password:",
"default": "postgres",
"type": "promptString"
},
{
"id": "migrationName",
"description": "Migration name (use_underscores):",
"default": "new_migration",
"type": "promptString"
}
]
}These VS Code tasks make migration management simple:
-
Apply Migrations: Press
Ctrl+Shift+P, type "Tasks: Run Task", select "Flyway: Apply Migrations"- This applies all pending migrations to the database
-
Validate Migrations: Run the "Flyway: Validate Migrations" task
- Checks if migrations will apply cleanly without actually applying them
-
Undo Last Migration: Run the "Flyway: Undo Last Migration" task
- Reverts the most recent migration using undo scripts
-
Show Migration Info: Run the "Flyway: Show Migration Info" task
- Shows which migrations have been applied and which are pending
-
Create New Migration: Run the "Flyway: Create New Migration" task
- Prompts for a migration name and creates a timestamped SQL file
- Automatically opens the new file in VS Code for editing
- Run the "Flyway: Create New Migration" task
- Enter a descriptive name like "add_user_table"
- Write your migration SQL in the opened file
- Run "Flyway: Apply Migrations" to apply it
To support rollbacks, create undo scripts with the same version number:
- Create a file in
AJP.Infrastructure.Persistence/Migrations/undo/U{version}__{Description}.sql - Run the "Flyway: Undo Last Migration" task
With migrations set up, test your CQRS implementation:
# Get all products
curl -X GET http://localhost:5000/products
# Get a specific product
curl -X GET http://localhost:5000/products/1
# Create a new product
curl -X POST http://localhost:5000/products \
-H "Content-Type: application/json" \
-d '{"name":"New Product", "description":"A new product", "price":39.99}'
# Update a product
curl -X PUT http://localhost:5000/products/1 \
-H "Content-Type: application/json" \
-d '{"id":1, "name":"Updated Product", "description":"An updated product", "price":49.99}'
# Delete a product
curl -X DELETE http://localhost:5000/products/1For larger applications, the CQRS pattern can be extended with additional features:
In complex domains, you might want to separate your command/query models from your domain entities. Mapster provides a faster, simpler alternative to AutoMapper:
cd AJP.Application
dotnet add package Mapster
dotnet add package Mapster.DependencyInjectionCreate DTOs in AJP.Application/Products/Queries/GetAllProducts/ProductDto.cs:
namespace AJP.Application.Products.Queries.GetAllProducts;
// Perfect use case for a record - immutable data transfer object
public record ProductDto(
int Id,
string Name,
string Description,
decimal Price,
DateTime CreatedAt);Configure Mapster in AJP.Application/Common/Mappings/MappingConfig.cs:
using AJP.Application.Products.Queries.GetAllProducts;
using AJP.Domain.Entities;
using Mapster;
namespace AJP.Application.Common.Mappings;
public static class MappingConfig
{
public static void Configure()
{
// Basic mapping configuration
TypeAdapterConfig<Product, ProductDto>.NewConfig()
.Map(dest => dest.Name, src => src.Name)
.Map(dest => dest.Description, src => src.Description)
.Map(dest => dest.Price, src => src.Price);
// You can add more complex mappings or transformations here
// For example:
// .Map(dest => dest.FormattedPrice, src => $"${src.Price:0.00}");
}
}Update the query handler to use Mapster:
using AJP.Application.Common.Interfaces;
using AJP.Domain.Entities;
using Mapster;
using MediatR;
namespace AJP.Application.Products.Queries.GetAllProducts;
public record GetAllProductsQuery : IRequest<IEnumerable<ProductDto>>;
// Using primary constructor
public class GetAllProductsQueryHandler(IProductRepository productRepository)
: IRequestHandler<GetAllProductsQuery, IEnumerable<ProductDto>>
{
public async Task<IEnumerable<ProductDto>> Handle(GetAllProductsQuery request, CancellationToken cancellationToken)
{
var products = await productRepository.GetAllAsync();
return products.Adapt<IEnumerable<ProductDto>>();
}
}Register Mapster in DependencyInjection.cs:
using AJP.Application.Common.Mappings;
using Mapster;
using MapsterMapper;
using System.Reflection;
public static IServiceCollection AddApplication(this IServiceCollection services)
{
// Configure and register Mapster
MappingConfig.Configure();
services.AddSingleton(TypeAdapterConfig.GlobalSettings);
services.AddScoped<IMapper, ServiceMapper>();
// Other registrations
services.AddMediatR(cfg =>
{
cfg.RegisterServicesFromAssembly(Assembly.GetExecutingAssembly());
cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));
});
services.AddValidatorsFromAssembly(Assembly.GetExecutingAssembly());
return services;
}You can add a logging behavior to log all requests and responses:
using MediatR;
using Microsoft.Extensions.Logging;
namespace AJP.Application.Common.Behaviors;
// Using primary constructor
public class LoggingBehavior<TRequest, TResponse>(ILogger<LoggingBehavior<TRequest, TResponse>> logger)
: IPipelineBehavior<TRequest, TResponse>
where TRequest : IRequest<TResponse>
{
public async Task<TResponse> Handle(TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken cancellationToken)
{
var requestName = typeof(TRequest).Name;
logger.LogInformation("Handling {RequestName}", requestName);
logger.LogDebug("Request details: {@Request}", request);
var response = await next();
logger.LogInformation("Handled {RequestName}", requestName);
return response;
}
}Register this behavior in DependencyInjection.cs:
services.AddMediatR(cfg =>
{
cfg.RegisterServicesFromAssembly(Assembly.GetExecutingAssembly());
cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));
cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(LoggingBehavior<,>));
});For read-heavy applications, you can add caching to your queries:
cd AJP.Application
dotnet add package Microsoft.Extensions.Caching.AbstractionsCreate an interface for cacheable queries:
namespace AJP.Application.Common.Interfaces;
public interface ICacheableQuery
{
string CacheKey { get; }
int CacheTime { get; } // Minutes
}Create a cache behavior:
using AJP.Application.Common.Interfaces;
using MediatR;
using Microsoft.Extensions.Caching.Memory;
namespace AJP.Application.Common.Behaviors;
// Using primary constructor
public class CachingBehavior<TRequest, TResponse>(IMemoryCache cache)
: IPipelineBehavior<TRequest, TResponse>
where TRequest : IRequest<TResponse>, ICacheableQuery
{
public async Task<TResponse> Handle(TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken cancellationToken)
{
var cacheKey = $"{typeof(TRequest).Name}_{request.CacheKey}";
if (cache.TryGetValue(cacheKey, out TResponse cachedResponse))
{
return cachedResponse;
}
var response = await next();
cache.Set(cacheKey, response, TimeSpan.FromMinutes(request.CacheTime));
return response;
}
}Update a query to use caching:
using AJP.Application.Common.Interfaces;
using MediatR;
// Record with implementing interface
public record GetAllProductsQuery : IRequest<IEnumerable<ProductDto>>, ICacheableQuery
{
public string CacheKey => "AllProducts";
public int CacheTime => 5; // Minutes
}Register the cache behavior in DependencyInjection.cs:
services.AddMemoryCache();
services.AddMediatR(cfg =>
{
cfg.RegisterServicesFromAssembly(Assembly.GetExecutingAssembly());
cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));
cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(LoggingBehavior<,>));
cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(CachingBehavior<,>));
});Now that we have implemented CQRS in our application, let's see how it works within our DevSpace and Kubernetes environment:
Update your devspace.yaml file to reflect the new CQRS implementation:
# No changes needed for CQRS specifically, but ensure proper environment variables
dev:
api:
# ... existing config ...
env:
- name: ASPNETCORE_ENVIRONMENT
value: Development
- name: ConnectionStrings__DefaultConnection
value: "Host=postgres;Port=5432;Database=ajp_db;Username=postgres;Password=postgres"
- name: Logging__LogLevel__Default
value: "Information"
- name: Logging__LogLevel__Microsoft
value: "Warning"
- name: Logging__LogLevel__Microsoft.Hosting.Lifetime
value: "Information"For a real-world CQRS application, monitoring becomes important. You can add some basic monitoring with Prometheus and Grafana:
# Install Prometheus and Grafana in your cluster
kubectl apply -f https://raw.githubusercontent.com/prometheus-operator/prometheus-operator/main/bundle.yamlAdd metrics to your application:
cd AJP.API
dotnet add package prometheus-net.AspNetCoreUpdate your Program.cs to expose metrics:
// Add to your services
builder.Services.AddHealthChecks();
// Add to your middleware pipeline
app.UseMetricServer();
app.UseHttpMetrics();
app.MapHealthChecks("/health");CQRS provides several advantages in a Kubernetes environment:
-
Scalability: You can scale read and write operations independently
- Deploy query handlers in multiple pods optimized for reads
- Deploy command handlers in fewer pods focused on write consistency
-
Resilience: Separate failure domains for reads and writes
- Read failures don't affect write capabilities
- Write failures don't prevent users from reading data
-
Resource Optimization: Different resource profiles
- Query pods can be optimized for memory and CPU
- Command pods can be optimized for I/O and consistency
-
Monitoring Granularity: Better observability
- Track command vs. query performance separately
- Identify bottlenecks more precisely
Verify your CQRS implementation by testing the endpoints in your Kubernetes environment:
# Start your application with DevSpace
devspace dev
# Get all products (Query)
curl -X GET http://localhost:5000/products
# Create a new product (Command)
curl -X POST http://localhost:5000/products \
-H "Content-Type: application/json" \
-d '{"name":"Kubernetes Product", "description":"Created in Kubernetes", "price":99.99}'
# Verify the product was created (Query)
curl -X GET http://localhost:5000/productsThe project includes several code quality tools and standardization configurations:
The .editorconfig file ensures consistent code formatting across all developer environments and IDEs that support it.
Key benefits:
- Consistent indentation, line endings, and whitespace
- Standard C# naming conventions
- Customized code style settings
- IDE-agnostic formatting rules
The Directory.Build.props file centralizes common build properties and analyzer settings for all projects:
<Project>
<PropertyGroup>
<!-- Enable treating warnings as errors -->
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<!-- Enable latest C# features -->
<LangVersion>latest</LangVersion>
<!-- Enable nullable reference types -->
<Nullable>enable</Nullable>
<!-- Additional shared properties -->
<!-- ... -->
</PropertyGroup>
<!-- Common package references for all projects -->
<ItemGroup>
<PackageReference Include="SonarAnalyzer.CSharp" PrivateAssets="all" />
</ItemGroup>
<!-- Exclude .dcproj files from certain settings -->
<ItemGroup Condition="'$(MSBuildProjectExtension)' != '.dcproj'">
<PackageReference Include="StyleCop.Analyzers" PrivateAssets="all" />
</ItemGroup>
</Project>Key benefits:
- Consistent compiler settings across all projects
- Uniform error handling (TreatWarningsAsErrors)
- Static code analysis for all projects
- Simplified project files by centralizing common settings
The Directory.Packages.props file centralizes all NuGet package versions, ensuring consistency across projects:
<Project>
<PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>
<!-- Common package versions for all projects -->
<ItemGroup>
<!-- Package versions here -->
<PackageVersion Include="MediatR" Version="12.1.1" />
<PackageVersion Include="Dapper" Version="2.0.151" />
<!-- ... -->
</ItemGroup>
<!-- Exclude .dcproj files from central package management -->
<ItemGroup Condition="'$(MSBuildProjectExtension)' == '.dcproj'">
<PackageVersion Remove="@(PackageVersion)" />
</ItemGroup>
</Project>Key benefits:
- Single source of truth for package versions
- Simplified dependency updates
- Prevention of version conflicts
- Exclusion of Docker project files (.dcproj) from central management
SonarAnalyzer is included in all projects through Directory.Build.props and provides:
- Detection of code smells
- Security vulnerability scanning
- Bug detection
- Code maintainability analysis
- Performance issue identification
The analyzer runs during compilation to provide immediate feedback.
When working with this setup:
-
Honor the EditorConfig: Ensure your IDE respects the .editorconfig settings
-
Add new packages to Directory.Packages.props:
<PackageVersion Include="NewPackage" Version="1.0.0" />
-
Reference packages in project files without versions:
<ItemGroup> <PackageReference Include="NewPackage" /> </ItemGroup>
-
Address all warnings: Since TreatWarningsAsErrors is enabled, all warnings will block the build
-
Run SonarQube analysis regularly:
dotnet sonarscanner begin /k:"project-key" /d:sonar.host.url="http://your-sonar-host" dotnet build dotnet sonarscanner end
This code quality setup ensures consistent, maintainable, and high-quality code across the entire project, with minimal configuration needed for individual project files.
Create a service to call the API in AJP.Frontend/src/services/api.js:
import axios from 'axios';
const API_URL = process.env.REACT_APP_API_URL || 'http://localhost:5000';
export const apiClient = axios.create({
baseURL: API_URL,
headers: {
'Content-Type': 'application/json'
}
});
export const productService = {
getAll: async () => {
try {
const response = await apiClient.get('/api/products');
return response.data;
} catch (error) {
console.error('Error fetching products:', error);
throw error;
}
},
getById: async (id) => {
try {
const response = await apiClient.get(`/api/products/${id}`);
return response.data;
} catch (error) {
console.error(`Error fetching product with id ${id}:`, error);
throw error;
}
},
create: async (product) => {
try {
const response = await apiClient.post('/api/products', product);
return response.data;
} catch (error) {
console.error('Error creating product:', error);
throw error;
}
},
update: async (id, product) => {
try {
await apiClient.put(`/api/products/${id}`, product);
} catch (error) {
console.error(`Error updating product with id ${id}:`, error);
throw error;
}
},
delete: async (id) => {
try {
await apiClient.delete(`/api/products/${id}`);
} catch (error) {
console.error(`Error deleting product with id ${id}:`, error);
throw error;
}
}
};Now we'll use DevSpace to deploy our DDD solution with PostgreSQL to Kubernetes.
cd AJP.KubeExample
devspace initThis will start an interactive wizard. When prompted:
- Select "Create a new DevSpace project"
- Choose "Microservices / Multiple Deployments"
- Let DevSpace detect your Docker images or enter your own
Create or edit the devspace.yaml file in your AJP.KubeExample directory:
version: v2beta1
name: ajp-kubeexample
vars:
FRONTEND_IMAGE: ajp-frontend
API_IMAGE: ajp-api
POSTGRES_PASSWORD: postgres
deployments:
frontend:
helm:
values:
containers:
- name: frontend
image: ${FRONTEND_IMAGE}
command: ["npm", "start"]
env:
- name: REACT_APP_API_URL
value: http://localhost:5000
service:
ports:
- port: 3000
api:
helm:
values:
containers:
- name: api
image: ${API_IMAGE}
command: ["dotnet", "watch", "run", "--urls=http://0.0.0.0:5000"]
env:
- name: ConnectionStrings__DefaultConnection
value: "Host=postgres;Port=5432;Database=ajp_db;Username=postgres;Password=${POSTGRES_PASSWORD}"
service:
ports:
- port: 5000
postgres:
helm:
values:
containers:
- name: postgres
image: postgres:14
env:
- name: POSTGRES_PASSWORD
value: ${POSTGRES_PASSWORD}
- name: POSTGRES_DB
value: ajp_db
volumeMounts:
- name: postgres-data
mountPath: /var/lib/postgresql/data
volumes:
- name: postgres-data
emptyDir: {}
service:
ports:
- port: 5432
dev:
frontend:
namespace: default
labelSelector:
app.kubernetes.io/component: frontend
ports:
- port: 3000
open:
- url: http://localhost:3000
sync:
- path: ./AJP.Frontend/:/app
excludePaths:
- node_modules/
- build/
terminal:
enabled: true
command: "npm install"
api:
namespace: default
labelSelector:
app.kubernetes.io/component: api
ports:
- port: 5000
open:
- url: http://localhost:5000/swagger
sync:
- path: ./:/app
excludePaths:
- .git/
- AJP.Frontend/node_modules/
- AJP.Frontend/build/
- "**/.vs/"
- "**/bin/"
- "**/obj/"
terminal:
enabled: true
command: "dotnet restore"
postgres:
namespace: default
labelSelector:
app.kubernetes.io/component: postgres
ports:
- port: 5432Create a directory for the initialization scripts:
mkdir -p postgres-initCreate a SQL script to initialize your database schema in postgres-init/init.sql:
CREATE TABLE IF NOT EXISTS Products (
Id SERIAL PRIMARY KEY,
Name VARCHAR(100) NOT NULL,
Description TEXT,
Price DECIMAL(18, 2) NOT NULL,
CreatedAt TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
UpdatedAt TIMESTAMP NULL
);
-- Insert some sample data
INSERT INTO Products (Name, Description, Price, CreatedAt)
VALUES
('Product 1', 'Description for product 1', 19.99, CURRENT_TIMESTAMP),
('Product 2', 'Description for product 2', 29.99, CURRENT_TIMESTAMP),
('Product 3', 'Description for product 3', 39.99, CURRENT_TIMESTAMP);Update the DevSpace configuration to include database initialization. Add the following to your devspace.yaml:
hooks:
- name: "Setup Database"
events: ["after:deploy:postgres"]
command: |
# Wait for PostgreSQL to be ready
sleep 10
POSTGRES_POD=$(kubectl get pods -l app.kubernetes.io/component=postgres -o jsonpath='{.items[0].metadata.name}')
kubectl cp ./postgres-init/init.sql $POSTGRES_POD:/tmp/init.sql
kubectl exec $POSTGRES_POD -- psql -U postgres -d ajp_db -f /tmp/init.sqlWith this configuration, you can start your development environment with a single command:
devspace devThis will:
- Build and deploy your applications and PostgreSQL
- Initialize the database with your schema and sample data
- Set up file synchronization
- Forward ports
- Open browser windows to your services
- Create interactive terminal sessions
DevSpace's port forwarding makes debugging much easier. Let's set up VS Code to debug all components.
- Kubernetes extension
- C# Dev Kit
- JavaScript Debugger
- PostgreSQL extension (for database inspection)
Create or edit .vscode/launch.json in your AJP.KubeExample directory:
{
"version": "0.2.0",
"configurations": [
{
"name": "Chrome: AJP.Frontend",
"type": "chrome",
"request": "launch",
"url": "http://localhost:3000",
"webRoot": "${workspaceFolder}/AJP.Frontend"
},
{
"name": "AJP.API",
"type": "coreclr",
"request": "attach",
"processId": "${command:pickRemoteProcess}",
"pipeTransport": {
"pipeProgram": "devspace",
"pipeArgs": ["enter", "--label-selector", "app.kubernetes.io/component=api", "--"],
"debuggerPath": "/vsdbg/vsdbg",
"pipeCwd": "${workspaceRoot}"
}
}
]
}For .NET debugging to work, install the VS Code debugger inside the container. With DevSpace, this is simple:
# Open a terminal in the API container
devspace enter --label-selector app.kubernetes.io/component=api
# In the container, install the debugger
curl -sSL https://aka.ms/getvsdbgsh | bash /dev/stdin -v latest -l /vsdbg
# Exit the container
exit- Install the PostgreSQL extension for VS Code
- Add a new connection:
- Host: localhost
- Port: 5432 (DevSpace will port-forward this)
- User: postgres
- Password: postgres (as set in the DevSpace config)
- Database: ajp_db
- Make sure DevSpace is running:
devspace dev - In VS Code, go to the "Run and Debug" view
- Select "Chrome: AJP.Frontend" to debug the React application
- Select "AJP.API" to debug the .NET application
DevSpace keeps the necessary port forwarding active, so you can set breakpoints, inspect variables, and step through code just like in local development.
With DevSpace, VS Code, and our DDD architecture set up, here's your optimal development workflow:
cd AJP.KubeExample
devspace devThis single command:
- Builds all necessary images
- Deploys frontend, API, and PostgreSQL to Kubernetes
- Sets up file synchronization
- Forwards all required ports
- Opens browser tabs for your services
- Initializes the PostgreSQL database with your schema
When implementing new features in your DDD architecture with Minimal API:
-
Start with the Domain Layer:
- Define or update entities, value objects, and domain events in the Domain project
- This layer should be focused on business rules and contain no external dependencies
-
Implement the Application Layer:
- Create command and query handlers for the new feature
- Define DTOs for transferring data between layers
- Implement validation and business logic
-
Update the Infrastructure Layer:
- Implement repository interfaces with Dapper
- Write SQL queries for the new feature
- Handle data access concerns
-
Finally, Define Minimal API Endpoints:
- Create a new endpoint extension class:
// Create MyFeatureEndpoints.cs for feature-specific endpoints public static class MyFeatureEndpoints { public static WebApplication MapMyFeatureEndpoints(this WebApplication app) { app.MapGet("/api/myfeature", GetMyFeature); // Add other endpoints return app; } private static async Task<IResult> GetMyFeature(IMyFeatureService service) { var result = await service.GetDataAsync(); return Results.Ok(result); } }
- Register in Program.cs:
app.MapMyFeatureEndpoints();
- Create a new endpoint extension class:
-
Update the React Frontend:
- Implement UI components to consume the new API
- Handle state management and user interactions
Minimal API provides several benefits for your Kubernetes-deployed application:
-
Reduced memory footprint:
- Smaller assemblies
- Faster startup time
- More efficient in container environments
-
Simplified development workflow:
- Fewer files to manage
- Less ceremony compared to controllers
- Direct dependency injection into endpoint handlers
-
Better organization with extension methods:
- Group related endpoints
- Maintain clean Program.cs
- Easier to understand API surface
-
Improved performance:
- Fewer layers of abstraction
- Optimized request pipeline
- Lower latency for API calls
To interact with the database during development:
# Connect to the database using psql in the container
devspace enter --label-selector app.kubernetes.io/component=postgres
psql -U postgres -d ajp_db
# Or execute SQL directly
devspace enter --label-selector app.kubernetes.io/component=postgres
psql -U postgres -d ajp_db -c "SELECT * FROM Products"For writing efficient Dapper queries:
-
Use parameterized queries to prevent SQL injection
-
Use appropriate Dapper methods:
Query<T>for multiple rowsQuerySingle<T>for a single row (throws if not found)QuerySingleOrDefault<T>for a single row or nullExecutefor commands (returns affected rows)ExecuteScalar<T>for single value returns
-
For complex queries, consider using SQL files:
var sql = await File.ReadAllTextAsync("Sql/GetProducts.sql"); var products = await connection.QueryAsync<Product>(sql, parameters);
When you need to update your database schema:
- Update your domain entities
- Create a migration SQL script in
postgres-init/migrations/ - Apply the migration:
devspace enter --label-selector app.kubernetes.io/component=postgres psql -U postgres -d ajp_db -f /tmp/migrations/your-migration.sql
Or, for easier schema management, consider adding a database migration tool like Flyway to your solution.
-
Set breakpoints in VS Code:
- Domain logic
- Application services
- Data access code
- API controllers
-
Launch the appropriate debug configuration
-
Trigger the code path in your application
-
Use VS Code PostgreSQL extension to examine the database state
# View logs from all services
devspace logs -f
# View logs from just the API
devspace logs -f --label-selector app.kubernetes.io/component=api
# View logs from just the frontend
devspace logs -f --label-selector app.kubernetes.io/component=frontend
# View PostgreSQL logs
devspace logs -f --label-selector app.kubernetes.io/component=postgres# Press Ctrl+C in the terminal where devspace dev is running
# Or from another terminal
devspace purgeIf you want to pause development without removing everything:
# Stop DevSpace without purging deployments
devspace stopWhen you're completely done with development:
# Remove all DevSpace deployments
devspace purge
# Or for a more thorough cleanup
devspace reset pods# Check what's running
kubectl get pods,deployments,services
# Check resource usage
kubectl top pods
kubectl top nodesBefore shutting down your environment, you might want to back up your database:
# Export PostgreSQL data to a dump file
devspace enter --label-selector app.kubernetes.io/component=postgres
pg_dump -U postgres ajp_db > /tmp/ajp_db_backup.sql
exit
# Copy the dump file to your local machine
POSTGRES_POD=$(kubectl get pods -l app.kubernetes.io/component=postgres -o jsonpath='{.items[0].metadata.name}')
kubectl cp $POSTGRES_POD:/tmp/ajp_db_backup.sql ./ajp_db_backup.sqlIf DevSpace encounters problems:
# Reset DevSpace cache
devspace reset
# Enable verbose logging
devspace dev --verboseIf you encounter "ImagePullBackOff" errors with DevSpace:
# Use DevSpace with a local registry
devspace dev --use-docker-hub=falseIf you can't access services:
-
Check DevSpace port forwarding status:
devspace list ports
-
Manually set up port forwarding if needed:
devspace enter --label-selector app.kubernetes.io/component=api
If your API can't connect to PostgreSQL:
-
Check if PostgreSQL pod is running:
kubectl get pods -l app.kubernetes.io/component=postgres
-
Check PostgreSQL logs:
devspace logs -f --label-selector app.kubernetes.io/component=postgres
-
Test the connection from within the API container:
devspace enter --label-selector app.kubernetes.io/component=api nc -zv postgres 5432
-
Verify the connection string in the API container:
devspace enter --label-selector app.kubernetes.io/component=api printenv | grep CONNECTION
For .NET debugging:
- Make sure the debugger is installed in the container
- Check the pod is running and the app started correctly
- Verify the DevSpace port-forwarding is active
For React debugging:
- Make sure the Chrome Debugger extension is installed
- Check React app is running on the expected port
- Verify source maps are enabled in your React app
If you encounter issues with your DDD implementation:
-
Ensure proper separation of concerns:
- Domain layer should not reference other layers
- Application layer should only reference Domain
- Infrastructure should not be referenced by Domain or Application
- API should not contain business logic
-
Check for circular dependencies:
cd AJP.KubeExample dotnet build -
Verify Dapper queries:
# Enable Dapper parameter logging # Add to your repository constructor: SimpleCRUD.SetLogger(s => Console.WriteLine(s));
To ensure high code quality and consistent standards across the project, we'll use three key configuration files: .editorconfig, Directory.Build.props, and Directory.Packages.props. Here's a detailed breakdown of each:
The .editorconfig file provides IDE-agnostic code formatting rules. Here's a comprehensive configuration:
root = true
[*]
indent_style = space
indent_size = 4
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.cs]
# C# specific settings
# Organize usings
dotnet_sort_system_directives_first = true
dotnet_separate_import_directive_groups = false
# Naming conventions
dotnet_naming_rule.interface_should_be_begins_with_i.severity = suggestion
dotnet_naming_rule.interface_should_be_begins_with_i.symbols = interface
dotnet_naming_rule.interface_should_be_begins_with_i.style = begins_with_i
dotnet_naming_rule.types_should_be_pascal_case.severity = suggestion
dotnet_naming_rule.types_should_be_pascal_case.symbols = types
dotnet_naming_rule.types_should_be_pascal_case.style = pascal_case
dotnet_naming_rule.non_field_members_should_be_pascal_case.severity = suggestion
dotnet_naming_rule.non_field_members_should_be_pascal_case.symbols = non_field_members
dotnet_naming_rule.non_field_members_should_be_pascal_case.style = pascal_case
dotnet_naming_rule.private_or_internal_field_should_be_camel_case.severity = suggestion
dotnet_naming_rule.private_or_internal_field_should_be_camel_case.symbols = private_or_internal_field
dotnet_naming_rule.private_or_internal_field_should_be_camel_case.style = camel_case_with_underscore
# Symbol specifications
dotnet_naming_symbols.interface.applicable_kinds = interface
dotnet_naming_symbols.interface.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
dotnet_naming_symbols.interface.required_modifiers =
dotnet_naming_symbols.types.applicable_kinds = class, struct, interface, enum
dotnet_naming_symbols.types.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
dotnet_naming_symbols.types.required_modifiers =
dotnet_naming_symbols.non_field_members.applicable_kinds = property, event, method
dotnet_naming_symbols.non_field_members.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
dotnet_naming_symbols.non_field_members.required_modifiers =
dotnet_naming_symbols.private_or_internal_field.applicable_kinds = field
dotnet_naming_symbols.private_or_internal_field.applicable_accessibilities = internal, private, private_protected
dotnet_naming_symbols.private_or_internal_field.required_modifiers =
# Naming styles
dotnet_naming_style.begins_with_i.required_prefix = I
dotnet_naming_style.begins_with_i.required_suffix =
dotnet_naming_style.begins_with_i.word_separator =
dotnet_naming_style.begins_with_i.capitalization = pascal_case
dotnet_naming_style.pascal_case.required_prefix =
dotnet_naming_style.pascal_case.required_suffix =
dotnet_naming_style.pascal_case.word_separator =
dotnet_naming_style.pascal_case.capitalization = pascal_case
dotnet_naming_style.camel_case_with_underscore.required_prefix = _
dotnet_naming_style.camel_case_with_underscore.required_suffix =
dotnet_naming_style.camel_case_with_underscore.word_separator =
dotnet_naming_style.camel_case_with_underscore.capitalization = camel_case
# Expression-level preferences
dotnet_style_object_initializer = true:suggestion
dotnet_style_collection_initializer = true:suggestion
dotnet_style_explicit_tuple_names = true:suggestion
dotnet_style_coalesce_expression = true:suggestion
dotnet_style_null_propagation = true:suggestion
dotnet_style_prefer_is_null_check_over_reference_equality_method = true:suggestion
dotnet_style_prefer_auto_properties = true:suggestion
# New line preferences
csharp_new_line_before_open_brace = all
csharp_new_line_before_else = true
csharp_new_line_before_catch = true
csharp_new_line_before_finally = true
# Indentation preferences
csharp_indent_case_contents = true
csharp_indent_switch_labels = true
csharp_indent_labels = flush_left
[*.{json,yml,yaml,xml,csproj,props,targets}]
indent_size = 2
[*.md]
max_line_length = off
trim_trailing_whitespace = false
The Directory.Build.props file centralizes build properties for all projects:
<Project>
<PropertyGroup>
<!-- Enable treating warnings as errors -->
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<!-- Enable latest C# features -->
<LangVersion>latest</LangVersion>
<!-- Enable nullable reference types -->
<Nullable>enable</Nullable>
<!-- Enable implicit usings -->
<ImplicitUsings>enable</ImplicitUsings>
<!-- Set a default author for all assemblies -->
<Authors>AJP.KubeExample Team</Authors>
<!-- Set consistent versioning -->
<Version>1.0.0</Version>
<AssemblyVersion>1.0.0.0</AssemblyVersion>
<FileVersion>1.0.0.0</FileVersion>
<!-- Force usage of NuGet packages over project references -->
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
<!-- Generate XML documentation -->
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<!-- Disable CS1591 warnings for missing XML comments -->
<NoWarn>$(NoWarn);CS1591</NoWarn>
<!-- Enable deterministic builds -->
<Deterministic>true</Deterministic>
<!-- Use centrally-managed package versions -->
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>
<!-- Common package references for all projects -->
<ItemGroup>
<!-- Static code analysis -->
<PackageReference Include="SonarAnalyzer.CSharp" PrivateAssets="all" />
</ItemGroup>
<!-- Exclude .dcproj files from certain settings -->
<ItemGroup Condition="'$(MSBuildProjectExtension)' != '.dcproj'">
<PackageReference Include="StyleCop.Analyzers" PrivateAssets="all" />
</ItemGroup>
</Project>The Directory.Packages.props file centralizes all package versions:
<Project>
<PropertyGroup>
<!-- Enable central package management -->
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>
<!-- Common package versions for all projects -->
<ItemGroup>
<!-- Microsoft packages -->
<PackageVersion Include="Microsoft.AspNetCore.Cors" Version="2.2.0" />
<PackageVersion Include="Microsoft.Extensions.Configuration" Version="7.0.0" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="7.0.0" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="7.0.0" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="7.0.0" />
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="7.0.0" />
<PackageVersion Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="7.0.0" />
<PackageVersion Include="Microsoft.OpenApi" Version="1.6.9" />
<!-- Swagger -->
<PackageVersion Include="Swashbuckle.AspNetCore" Version="6.5.0" />
<!-- CQRS & Validation -->
<PackageVersion Include="MediatR" Version="12.1.1" />
<PackageVersion Include="MediatR.Extensions.Microsoft.DependencyInjection" Version="11.1.0" />
<PackageVersion Include="FluentValidation" Version="11.7.1" />
<PackageVersion Include="FluentValidation.DependencyInjection" Version="11.7.1" />
<PackageVersion Include="AutoMapper" Version="12.0.1" />
<PackageVersion Include="AutoMapper.Extensions.Microsoft.DependencyInjection" Version="12.0.1" />
<!-- Data Access -->
<PackageVersion Include="Dapper" Version="2.0.151" />
<PackageVersion Include="Npgsql" Version="7.0.6" />
<!-- Monitoring -->
<PackageVersion Include="prometheus-net.AspNetCore" Version="8.0.1" />
<!-- Testing -->
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.7.2" />
<PackageVersion Include="xunit" Version="2.5.0" />
<PackageVersion Include="xunit.runner.visualstudio" Version="2.5.0" />
<PackageVersion Include="Moq" Version="4.20.69" />
<PackageVersion Include="FluentAssertions" Version="6.12.0" />
<!-- Code Quality -->
<PackageVersion Include="SonarAnalyzer.CSharp" Version="9.12.0.78982" />
<PackageVersion Include="StyleCop.Analyzers" Version="1.1.118" />
</ItemGroup>
<!-- Exclude .dcproj files from central package management -->
<ItemGroup Condition="'$(MSBuildProjectExtension)' == '.dcproj'">
<PackageVersion Remove="@(PackageVersion)" />
</ItemGroup>
</Project>This standardized configuration provides numerous benefits:
- Consistent Code Style: All developers follow the same formatting rules
- Improved Code Quality: Static analysis with SonarAnalyzer catches issues early
- Reduced Build Issues: TreatWarningsAsErrors ensures clean code
- Simplified Dependency Management: Centralized package versions prevent version conflicts
- Streamlined Onboarding: New developers automatically follow team standards
- Better Documentation: XML documentation is consistently generated
- Enhanced CI/CD: Deterministic builds improve pipeline reliability
When working with these configurations:
-
During Development:
- IDEs automatically apply formatting rules from .editorconfig
- Warnings are treated as errors, enforcing high standards
- SonarAnalyzer provides real-time code quality feedback
-
Adding New Dependencies:
- Always add version to Directory.Packages.props first
- Reference the package without version in project files
- Run
dotnet restoreto update dependency locks
-
Creating New Projects:
- Properties from Directory.Build.props are automatically applied
- Ensures consistency across all projects
-
During Code Reviews:
- Automated checks enforce standards
- Reviewers can focus on architecture and business logic
- CI/CD pipelines enforce the same standards
This project follows modern C# coding practices to make the code more concise, readable, and maintainable:
Records are perfect for immutable data structures like DTOs and value objects:
// Before (class-based DTO)
public class ProductDto
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public decimal Price { get; set; }
public DateTime CreatedAt { get; set; }
}
// After (record-based DTO)
public record ProductDto(
int Id,
string Name,
string Description,
decimal Price,
DateTime CreatedAt);Benefits:
- Built-in value-based equality
- Immutability by default
- Concise syntax
- Built-in deconstruction
- With-expressions for non-destructive mutation
Primary constructors reduce boilerplate in classes that need dependency injection:
// Before
public class ProductService
{
private readonly IProductRepository _productRepository;
public ProductService(IProductRepository productRepository)
{
_productRepository = productRepository;
}
// Methods...
}
// After
public class ProductService(IProductRepository productRepository)
{
// Methods that use productRepository directly...
}Benefits:
- Less boilerplate code
- Parameter directly accessible in method bodies
- Cleaner code with fewer private fields
Using file-scoped namespaces reduces indentation and makes code cleaner:
// Before
namespace AJP.Application.Products.Queries
{
public record GetAllProductsQuery : IRequest<IEnumerable<Product>>;
// Other types...
}
// After
namespace AJP.Application.Products.Queries;
public record GetAllProductsQuery : IRequest<IEnumerable<Product>>;
// Other types...In Program.cs, top-level statements remove boilerplate:
// Before
namespace AJP.API
{
public class Program
{
public static void Main(string[] args)
{
var builder = WebApplication.CreateBuilder(args);
// Configuration...
var app = builder.Build();
// More code...
app.Run();
}
}
}
// After
var builder = WebApplication.CreateBuilder(args);
// Configuration...
var app = builder.Build();
// More code...
app.Run();Using endpoint groups for better organization:
// Group endpoints by feature
var productsGroup = app.MapGroup("/products")
.WithTags("Products")
.WithOpenApi();
productsGroup.MapGet("/", async (IMediator mediator) => /* ... */);
productsGroup.MapGet("/{id}", async (int id, IMediator mediator) => /* ... */);
productsGroup.MapPost("/", async (CreateProductCommand command, IMediator mediator) => /* ... */);Using modern pattern matching for cleaner code:
// Before
if (result.IsSuccess)
{
return Results.Ok(result.Value);
}
else
{
return Results.NotFound(result.Error);
}
// After
return result.IsSuccess
? Results.Ok(result.Value)
: Results.NotFound(result.Error);Using global usings to reduce repetitive imports:
// In GlobalUsings.cs
global using System;
global using System.Collections.Generic;
global using System.Threading;
global using System.Threading.Tasks;
global using MediatR;
global using FluentValidation;For more flexibility than records when needed:
public class UpdateRequest
{
public required string Name { get; init; }
public required decimal Price { get; init; }
}- Use records for: DTOs, value objects, immutable data
- Use classes for: Entities with identity, mutable objects, complex behaviors
- Use primary constructors for: Handlers, services, behaviors with dependencies
- Use endpoint groups for: Related API endpoints
- Use init-only properties for: Semi-immutable objects with complex initialization
In this guide, we've set up a complete development environment for a .NET application with:
- A Kubernetes cluster using kind
- DevSpace for efficient Kubernetes development
- A .NET solution with Domain-Driven Design architecture
- PostgreSQL database integration with Dapper
- CQRS pattern implementation with MediatR
- A React frontend with hot reloading
- Standardized code quality with .editorconfig, Directory.Build.props, and Directory.Packages.props
- Static code analysis with SonarAnalyzer
- Strict quality enforcement with TreatWarningsAsErrors
This setup provides a modern, scalable architecture for building complex applications while maintaining a great development experience with hot reloading, efficient workflows, and consistent code quality standards across the team.