Backend Architecture
Vulcan's backend consists of independent .NET 10 microservices using Vertical Slice Architecture with CQRS pattern.
Technology Stack
| Technology | Purpose |
|---|---|
| .NET 10 | Runtime |
| Minimal APIs | HTTP endpoints |
| Mediator 3.0.1 | CQRS dispatcher |
| Entity Framework Core 10 | ORM |
| PostgreSQL | Database |
| FluentValidation | Request validation |
| Swagger/OpenAPI | API documentation |
| Serilog | Logging |
| xUnit | Testing |
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.ymlVertical 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
| Type | Pattern | Example |
|---|---|---|
| Commands | <Action><Feature>Command | CreateLeadCommand |
| Queries | <Action><Feature>Query | GetLeadQuery |
| Handlers | <Action><Feature>Handler | CreateLeadHandler |
| Responses | <Feature>Response | LeadResponse |
| Validators | <Action><Feature>Validator | CreateLeadValidator |
Database Conventions
| Type | Convention | Example |
|---|---|---|
| Tables | snake_case plural | leads, work_packages |
| Columns | snake_case | first_name, created_at |
| Primary Keys | id | id |
| Foreign Keys | [entity]_id | customer_id |
| Indexes | ix_[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 migrationsGotchas
Important
- Use
ValueTask<T>notTask<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