Skip to content

Backend Architecture

Vulcan's backend consists of independent .NET 10 microservices using Vertical Slice Architecture with CQRS pattern.

Technology Stack

TechnologyPurpose
.NET 10Runtime
Minimal APIsHTTP endpoints
Mediator 3.0.1CQRS dispatcher
Entity Framework Core 10ORM
PostgreSQLDatabase
FluentValidationRequest validation
Swagger/OpenAPIAPI documentation
SerilogLogging
xUnitTesting

Capability Structure

Each capability (microservice) follows the same structure:

vulcan-be-leads/
├── src/
│   └── Vulcan.Leads/
│       ├── Abstractions/       # Base classes (MasterEntity)
│       ├── Database/           # AppDbContext, interceptors
│       ├── Entities/           # Domain entities
│       ├── Extensions/         # Endpoint mappings, DI
│       ├── Features/           # Vertical slices
│       │   └── Leads/
│       │       ├── GetAllLeads/
│       │       ├── GetLead/
│       │       ├── CreateLead/
│       │       ├── UpdateLead/
│       │       └── DeleteLead/
│       ├── HealthChecks/       # Custom health checks
│       ├── Middleware/         # Custom middleware
│       ├── Services/           # Application services
│       └── Validators/         # Shared validators
├── tests/
│   └── Vulcan.Leads.Tests/
├── Dockerfile
└── .gitlab-ci.yml

Vertical Slice Pattern

Each feature contains all its CRUD operations as self-contained slices.

Query Example (Read)

csharp
// Features/Leads/GetLead/GetLeadQuery.cs
public record GetLeadQuery(Guid Id) : IRequest<LeadResponse?>;

// Features/Leads/GetLead/GetLeadHandler.cs
public class GetLeadHandler : IRequestHandler<GetLeadQuery, LeadResponse?>
{
    private readonly AppDbContext _context;

    public GetLeadHandler(AppDbContext context)
    {
        _context = context;
    }

    public async ValueTask<LeadResponse?> Handle(
        GetLeadQuery request,
        CancellationToken cancellationToken)
    {
        var lead = await _context.Leads
            .FirstOrDefaultAsync(x => x.Id == request.Id, cancellationToken);

        return lead?.ToResponse();
    }
}

// Features/Leads/GetLead/LeadResponse.cs
public record LeadResponse(
    Guid Id,
    string Name,
    string Email,
    LeadStatus Status,
    DateTime CreatedAt
);

Command Example (Write)

csharp
// Features/Leads/CreateLead/CreateLeadCommand.cs
public record CreateLeadCommand(
    string Name,
    string Email,
    string? Phone
) : IRequest<CreateLeadResponse>;

// Features/Leads/CreateLead/CreateLeadHandler.cs
public class CreateLeadHandler : IRequestHandler<CreateLeadCommand, CreateLeadResponse>
{
    private readonly AppDbContext _context;

    public CreateLeadHandler(AppDbContext context)
    {
        _context = context;
    }

    public async ValueTask<CreateLeadResponse> Handle(
        CreateLeadCommand request,
        CancellationToken cancellationToken)
    {
        var lead = new Lead
        {
            Name = request.Name,
            Email = request.Email,
            Phone = request.Phone,
            Status = LeadStatus.New
        };

        _context.Leads.Add(lead);
        await _context.SaveChangesAsync(cancellationToken);

        return new CreateLeadResponse(lead.Id);
    }
}

// Features/Leads/CreateLead/CreateLeadValidator.cs
public class CreateLeadValidator : AbstractValidator<CreateLeadCommand>
{
    public CreateLeadValidator()
    {
        RuleFor(x => x.Name).NotEmpty().MaximumLength(100);
        RuleFor(x => x.Email).NotEmpty().EmailAddress();
        RuleFor(x => x.Phone).MaximumLength(20);
    }
}

Minimal API Endpoints

csharp
// Extensions/LeadEndpoints.cs
public static class LeadEndpoints
{
    public static void MapLeadEndpoints(this IEndpointRouteBuilder app)
    {
        var group = app.MapGroup("/api/leads")
            .WithTags("Leads")
            .RequireAuthorization();

        group.MapGet("/", async (IMediator mediator) =>
            await mediator.Send(new GetAllLeadsQuery()));

        group.MapGet("/{id:guid}", async (Guid id, IMediator mediator) =>
            await mediator.Send(new GetLeadQuery(id)));

        group.MapPost("/", async (CreateLeadCommand cmd, IMediator mediator) =>
            await mediator.Send(cmd));

        group.MapPut("/{id:guid}", async (Guid id, UpdateLeadCommand cmd, IMediator mediator) =>
            await mediator.Send(cmd with { Id = id }));

        group.MapDelete("/{id:guid}", async (Guid id, IMediator mediator) =>
            await mediator.Send(new DeleteLeadCommand(id)));
    }
}

Program.cs Setup

csharp
var builder = WebApplication.CreateBuilder(args);

// Add services
builder.Services.AddDbContext<AppDbContext>(options =>
    options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection")));

builder.Services.AddMediator();
builder.Services.AddValidatorsFromAssembly(typeof(Program).Assembly);
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();

var app = builder.Build();

// Configure pipeline
app.UseSwagger();
app.UseSwaggerUI();
app.UseAuthentication();
app.UseAuthorization();

// Map endpoints
app.MapLeadEndpoints();
app.MapHealthChecks("/health");

app.Run();

Naming Conventions

TypePatternExample
Commands<Action><Feature>CommandCreateLeadCommand
Queries<Action><Feature>QueryGetLeadQuery
Handlers<Action><Feature>HandlerCreateLeadHandler
Responses<Feature>ResponseLeadResponse
Validators<Action><Feature>ValidatorCreateLeadValidator

Database Conventions

TypeConventionExample
Tablessnake_case pluralleads, work_packages
Columnssnake_casefirst_name, created_at
Primary Keysidid
Foreign Keys[entity]_idcustomer_id
Indexesix_[table]_[columns]ix_leads_email

Commands

bash
dotnet run                           # Start service
dotnet build                         # Build
dotnet test                          # Run tests
dotnet ef migrations add <Name>      # Create migration
dotnet ef database update            # Apply migrations

Gotchas

Important

  • Use ValueTask<T> not Task<T> for Mediator handlers
  • All endpoints require authorization unless [AllowAnonymous]
  • One database per capability - never share databases
  • Validators are colocated with Commands in feature folders

Built with VitePress | v1.1.0