Backend Libraries & Architecture — Laundry Management System
| Field |
Value |
| Project |
Laundry Management System |
| Version |
1.0 |
| Language |
English |
| Framework |
ASP.NET Core 10 + .NET 10 |
| Document Type |
Backend Libraries & Developer Guide |
Table of Contents
- Solution Structure
- Complete NuGet Package List
- Clean Architecture Layers
- Dependency Injection Conventions
- EF Core — Code-First Configuration
- CQRS with MediatR
- FluentValidation Setup
- Serilog + OpenTelemetry + Seq
- Health Checks Configuration
- Error Handling
- Code Conventions
- appsettings.json Template
- Program.cs Template
1. Solution Structure
backend/
├── Laundry.sln
│
├── Laundry.Api/ # ASP.NET Core host
│ ├── Controllers/
│ │ ├── AuthController.cs
│ │ ├── InvoicesController.cs
│ │ ├── CustomersController.cs
│ │ ├── PaymentsController.cs
│ │ ├── CarpetController.cs
│ │ ├── TailoringController.cs
│ │ ├── InventoryController.cs
│ │ ├── ReportsController.cs
│ │ └── AdminController.cs
│ ├── Middleware/
│ │ ├── ClientTokenMiddleware.cs
│ │ ├── LicenseValidationMiddleware.cs
│ │ ├── GlobalExceptionMiddleware.cs
│ │ └── RequestLoggingMiddleware.cs
│ ├── Filters/
│ │ ├── PermissionFilter.cs
│ │ └── ValidateModelFilter.cs
│ ├── Program.cs
│ ├── appsettings.json
│ ├── appsettings.Development.json
│ └── Laundry.Api.csproj
│
├── Laundry.Application/ # Use cases, CQRS, DTOs, validators
│ ├── Invoices/
│ │ ├── Commands/
│ │ │ ├── CreateInvoiceCommand.cs
│ │ │ ├── UpdateInvoiceStatusCommand.cs
│ │ │ └── ...
│ │ ├── Queries/
│ │ │ ├── GetInvoiceByIdQuery.cs
│ │ │ ├── SearchInvoicesQuery.cs
│ │ │ └── ...
│ │ ├── Dtos/
│ │ │ ├── InvoiceDto.cs
│ │ │ ├── InvoiceItemDto.cs
│ │ │ └── ...
│ │ ├── Validators/
│ │ │ └── CreateInvoiceValidator.cs
│ │ └── Handlers/
│ │ ├── CreateInvoiceHandler.cs
│ │ └── ...
│ ├── Payments/ ...
│ ├── Customers/ ...
│ ├── Carpet/ ...
│ ├── Tailoring/ ...
│ ├── Inventory/ ...
│ ├── Reports/ ...
│ ├── Sync/ ...
│ ├── Common/
│ │ ├── Interfaces/
│ │ ├── Behaviors/ # MediatR pipeline behaviors
│ │ └── Mappings/
│ └── Laundry.Application.csproj
│
├── Laundry.Domain/ # Entities, enums, value objects
│ ├── Entities/
│ │ ├── Invoice.cs
│ │ ├── Customer.cs
│ │ ├── Payment.cs
│ │ ├── JournalEntry.cs
│ │ └── ...
│ ├── Enums/
│ │ ├── OperationalStatus.cs
│ │ ├── FinancialStatus.cs
│ │ ├── PaymentMethod.cs
│ │ └── ...
│ ├── ValueObjects/
│ │ ├── Money.cs
│ │ ├── BranchId.cs
│ │ └── ...
│ ├── Events/
│ │ ├── InvoiceCreatedDomainEvent.cs
│ │ ├── PaymentReceivedDomainEvent.cs
│ │ └── ...
│ ├── Interfaces/
│ │ ├── IInvoiceRepository.cs
│ │ ├── IJournalEntryRepository.cs
│ │ ├── IUnitOfWork.cs
│ │ └── ...
│ └── Laundry.Domain.csproj
│
├── Laundry.Infrastructure/ # EF Core, services, repositories
│ ├── Persistence/
│ │ ├── LaundryDbContext.cs
│ │ ├── Configurations/ # EF Core IEntityTypeConfiguration
│ │ │ ├── InvoiceConfiguration.cs
│ │ │ └── ...
│ │ ├── Migrations/
│ │ └── Repositories/
│ ├── Accounting/
│ │ ├── AccountingRulesEngine.cs
│ │ └── JournalEntryGenerator.cs
│ ├── Licensing/
│ │ ├── LicenseValidator.cs
│ │ └── HardwareFingerprint.cs
│ ├── Sync/
│ │ ├── SyncExportService.cs
│ │ └── SyncImportService.cs
│ ├── Services/
│ │ └── DateTimeService.cs
│ └── Laundry.Infrastructure.csproj
│
└── Laundry.Tests/ # xUnit tests
├── Unit/
├── Integration/
└── Laundry.Tests.csproj
2. Complete NuGet Package List
Laundry.Api
| Package |
Version |
Purpose |
Microsoft.AspNetCore.Authentication.JwtBearer |
10.* |
JWT authentication |
Asp.Versioning.Mvc |
8.* |
API versioning |
Swashbuckle.AspNetCore |
7.* |
Swagger / OpenAPI |
Serilog.AspNetCore |
9.* |
Structured logging |
Serilog.Sinks.Seq |
9.* |
Log to Seq |
Serilog.Sinks.PostgreSQL |
5.* |
Log to database |
OpenTelemetry.Extensions.Hosting |
1.* |
OpenTelemetry core |
OpenTelemetry.Instrumentation.AspNetCore |
1.* |
HTTP request tracing |
AspNetCore.HealthChecks.UI |
8.* |
Health dashboard |
AspNetCore.HealthChecks.UI.Client |
8.* |
Health UI client |
AspNetCore.HealthChecks.Npgsql |
8.* |
PostgreSQL health check |
AspNetCore.HealthChecks.Uris |
8.* |
Traefik/Seq health check |
Laundry.Application
| Package |
Version |
Purpose |
MediatR |
12.* |
CQRS — commands + queries |
FluentValidation.AspNetCore |
11.* |
Input validation |
AutoMapper |
13.* |
DTO ↔ Entity mapping |
Microsoft.Extensions.Logging.Abstractions |
10.* |
Logging abstractions |
Laundry.Domain
| Package |
Version |
Purpose |
MediatR.Contracts |
2.* |
Domain event contracts |
Laundry.Infrastructure
| Package |
Version |
Purpose |
Npgsql.EntityFrameworkCore.PostgreSQL |
10.* |
PostgreSQL EF Core provider |
Microsoft.EntityFrameworkCore.Sqlite |
10.* |
SQLite for offline branches |
Microsoft.EntityFrameworkCore.Design |
10.* |
EF Core CLI tools |
Dapper |
2.* |
Raw SQL for reports/ledger |
Quartz.Extensions.Hosting |
3.* |
Background job scheduling |
BCrypt.Net-Next |
4.* |
Password hashing |
OpenTelemetry.Instrumentation.EntityFrameworkCore |
1.* |
EF query tracing |
Laundry.Tests
| Package |
Version |
Purpose |
xunit |
2.* |
Test framework |
Moq |
4.* |
Mocking |
FluentAssertions |
7.* |
Assertion library |
Testcontainers.PostgreSql |
4.* |
Integration test DB |
Microsoft.AspNetCore.Mvc.Testing |
10.* |
Web API integration tests |
coverlet.collector |
6.* |
Code coverage |
3. Clean Architecture Layers
┌──────────────────────────────────────────────┐
│ Laundry.Api │
│ Controllers, Middleware, Filters, Swagger │
│ → References Application + Infrastructure │
└──────────────────────────────────────────────┘
│
┌──────────────────────────────────────────────┐
│ Laundry.Application │
│ Commands, Queries, Handlers, DTOs, Validators│
│ → References Domain only │
└──────────────────────────────────────────────┘
│
┌──────────────────────────────────────────────┐
│ Laundry.Domain │
│ Entities, Enums, ValueObjects, Interfaces │
│ → References nothing (pure C#) │
└──────────────────────────────────────────────┘
│
┌──────────────────────────────────────────────┐
│ Laundry.Infrastructure │
│ DbContext, Repositories, Accounting, Sync │
│ → References Domain + Application │
└──────────────────────────────────────────────┘
Rule: Dependencies flow inward. Domain knows nothing about Infrastructure. Application knows nothing about Infrastructure. API wires everything via DI.
4. Dependency Injection Conventions
// Program.cs — Registration pattern
builder.Services.AddScoped<IInvoiceRepository, InvoiceRepository>();
builder.Services.AddScoped<IUnitOfWork, UnitOfWork>();
builder.Services.AddScoped<IAccountingRulesEngine, AccountingRulesEngine>();
builder.Services.AddScoped<ILicenseValidator, LicenseValidator>();
builder.Services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(typeof(ApplicationAssembly).Assembly));
builder.Services.AddValidatorsFromAssembly(typeof(ApplicationAssembly).Assembly);
builder.Services.AddAutoMapper(typeof(ApplicationAssembly).Assembly);
// Pipeline behaviors (executed in order)
builder.Services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));
builder.Services.AddTransient(typeof(IPipelineBehavior<,>), typeof(LoggingBehavior<,>));
builder.Services.AddTransient(typeof(IPipelineBehavior<,>), typeof(TransactionBehavior<,>));
5. EF Core — Code-First Configuration
DbContext
public class LaundryDbContext : DbContext
{
public DbSet<Invoice> Invoices => Set<Invoice>();
public DbSet<InvoiceItem> InvoiceItems => Set<InvoiceItem>();
public DbSet<Customer> Customers => Set<Customer>();
public DbSet<Payment> Payments => Set<Payment>();
public DbSet<JournalEntry> JournalEntries => Set<JournalEntry>();
public DbSet<JournalEntryLine> JournalEntryLines => Set<JournalEntryLine>();
// ... all entities
protected override void OnModelCreating(ModelBuilder builder)
{
builder.ApplyConfigurationsFromAssembly(typeof(LaundryDbContext).Assembly);
// UUID generation
foreach (var entity in builder.Model.GetEntityTypes())
{
if (entity.ClrType.GetProperty("Id")?.PropertyType == typeof(Guid))
{
entity.FindProperty("Id")!.ValueGenerated = Microsoft.EntityFrameworkCore.Metadata.ValueGenerated.Never;
}
}
}
}
Entity Configuration Example
public class InvoiceConfiguration : IEntityTypeConfiguration<Invoice>
{
public void Configure(EntityTypeBuilder<Invoice> builder)
{
builder.ToTable("invoices");
builder.HasKey(x => x.Id);
builder.Property(x => x.Id).ValueGeneratedNever(); // UUID from app layer
builder.Property(x => x.InvoiceNumber).HasMaxLength(50).IsRequired();
builder.HasIndex(x => x.InvoiceNumber).IsUnique();
builder.Property(x => x.BranchId).IsRequired();
builder.Property(x => x.OperationalStatus)
.HasConversion<string>().HasMaxLength(20);
builder.Property(x => x.FinancialStatus)
.HasConversion<string>().HasMaxLength(20);
builder.Property(x => x.GrandTotal).HasColumnType("numeric(18,4)");
// Money columns use numeric(18,4) — never float
builder.HasMany(x => x.Items)
.WithOne(x => x.Invoice)
.HasForeignKey(x => x.InvoiceId);
}
}
Migrations
# Create migration
dotnet ef migrations add InitialCreate -p Laundry.Infrastructure -s Laundry.Api
# Apply migration
dotnet ef database update -p Laundry.Infrastructure -s Laundry.Api
Command Example
// Command
public record CreateInvoiceCommand(
Guid CustomerId,
Guid BranchId,
List<InvoiceItemDto> Items,
string? Notes
) : IRequest<Result<InvoiceDto>>;
// Handler
public class CreateInvoiceHandler : IRequestHandler<CreateInvoiceCommand, Result<InvoiceDto>>
{
private readonly IInvoiceRepository _repo;
private readonly IAccountingRulesEngine _engine;
private readonly IUnitOfWork _uow;
public async Task<Result<InvoiceDto>> Handle(CreateInvoiceCommand request, CancellationToken ct)
{
// 1. Validate
// 2. Create domain entity
var invoice = Invoice.Create(request.CustomerId, request.BranchId, request.Items);
// 3. Persist
await _repo.AddAsync(invoice, ct);
// 4. Generate accounting entries
var entries = _engine.Evaluate("InvoiceCreated", invoice);
await _uow.AddJournalEntriesAsync(entries, ct);
// 5. Commit
await _uow.SaveChangesAsync(ct);
// 6. Return DTO
return Result<InvoiceDto>.Success(_mapper.Map<InvoiceDto>(invoice));
}
}
Query Example (Dapper for Reports)
// Query
public record GetCustomerBalanceQuery(Guid CustomerId) : IRequest<Result<decimal>>;
// Handler — uses Dapper, not EF Core
public class GetCustomerBalanceHandler : IRequestHandler<GetCustomerBalanceQuery, Result<decimal>>
{
private readonly IDbConnection _db;
public async Task<Result<decimal>> Handle(GetCustomerBalanceQuery request, CancellationToken ct)
{
var balance = await _db.QuerySingleOrDefaultAsync<decimal>(@"
SELECT COALESCE(SUM(debit), 0) - COALESCE(SUM(credit), 0)
FROM journal_entry_lines
WHERE customer_id = @CustomerId
AND account_code LIKE '120%'", new { request.CustomerId });
return Result<decimal>.Success(balance);
}
}
7. FluentValidation Setup
public class CreateInvoiceValidator : AbstractValidator<CreateInvoiceCommand>
{
public CreateInvoiceValidator()
{
RuleFor(x => x.CustomerId).NotEmpty();
RuleFor(x => x.BranchId).NotEmpty();
RuleFor(x => x.Items).NotEmpty().Must(x => x.Count > 0)
.WithMessage("Invoice must contain at least one item.");
RuleForEach(x => x.Items).SetValidator(new InvoiceItemValidator());
}
}
// MediatR pipeline behavior — auto-validates before handler
public class ValidationBehavior<TRequest, TResponse>
: IPipelineBehavior<TRequest, TResponse>
where TRequest : IRequest<TResponse>
{
private readonly IEnumerable<IValidator<TRequest>> _validators;
public async Task<TResponse> Handle(TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken ct)
{
if (_validators.Any())
{
var context = new ValidationContext<TRequest>(request);
var results = await Task.WhenAll(_validators.Select(v => v.ValidateAsync(context, ct)));
var failures = results.SelectMany(r => r.Errors).Where(f => f != null).ToList();
if (failures.Count != 0) throw new ValidationException(failures);
}
return await next();
}
}
8. Serilog + OpenTelemetry + Seq
// Program.cs
builder.Host.UseSerilog((ctx, cfg) => cfg
.ReadFrom.Configuration(ctx.Configuration)
.Enrich.FromLogContext()
.Enrich.WithMachineName()
.WriteTo.Console()
.WriteTo.Seq(ctx.Configuration["SeqServerUrl"] ?? "http://localhost:5341"));
builder.Services.AddOpenTelemetry()
.ConfigureResource(r => r.AddService("Laundry.Api"))
.WithTracing(t => t
.AddAspNetCoreInstrumentation()
.AddEntityFrameworkCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddOtlpExporter(o => o.Endpoint = new Uri("http://seq:5341/ingest/otlp/v1/traces")))
.WithMetrics(m => m
.AddAspNetCoreInstrumentation()
.AddRuntimeInstrumentation()
.AddPrometheusExporter());
9. Health Checks Configuration
builder.Services.AddHealthChecks()
.AddNpgsql(builder.Configuration.GetConnectionString("Default")!)
.AddUrlGroup(new Uri("http://traefik:8080/ping"), "traefik")
.AddUrlGroup(new Uri("http://seq:5341/health"), "seq");
builder.Services.AddHealthChecksUI(s => s
.AddHealthCheckEndpoint("Laundry API", "/health"))
.AddInMemoryStorage();
// Map endpoints
app.MapHealthChecks("/health", new HealthCheckOptions
{
ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse
});
app.MapHealthChecksUI(o => o.UIPath = "/health-ui");
app.MapPrometheusScrapingEndpoint("/metrics");
10. Error Handling
public class GlobalExceptionMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<GlobalExceptionMiddleware> _logger;
public async Task InvokeAsync(HttpContext context)
{
try
{
await _next(context);
}
catch (ValidationException ex)
{
context.Response.StatusCode = 400;
await context.Response.WriteAsJsonAsync(new
{
error = "VALIDATION_ERROR",
details = ex.Errors.Select(e => new { field = e.PropertyName, message = e.ErrorMessage })
});
}
catch (NotFoundException ex)
{
context.Response.StatusCode = 404;
await context.Response.WriteAsJsonAsync(new { error = "NOT_FOUND", message = ex.Message });
}
catch (LicenseExpiredException ex)
{
context.Response.StatusCode = 403;
await context.Response.WriteAsJsonAsync(new { error = "LICENSE_EXPIRED", message = ex.Message, daysRemaining = ex.DaysRemaining });
}
catch (Exception ex)
{
_logger.LogError(ex, "Unhandled exception");
context.Response.StatusCode = 500;
await context.Response.WriteAsJsonAsync(new { error = "INTERNAL_ERROR", message = "An unexpected error occurred." });
}
}
}
11. Code Conventions
| Convention |
Rule |
| Naming |
PascalCase for public (types, methods, properties). camelCase for parameters, locals. _camelCase for private fields. I prefix for interfaces. |
| Files |
One class/enum/interface per file. File name matches type name. |
| Async |
All I/O operations are async. Suffix methods with Async. Use CancellationToken. |
| Nullable |
Enable nullable reference types in all projects (<Nullable>enable</Nullable>). |
| Records |
Use record for DTOs and value objects. Use record class for entities (EF Core compatible). |
| Errors |
Use Result<T> pattern for expected failures. Throw exceptions only for unexpected errors. |
| Money |
Use decimal for all monetary values. Map to numeric(18,4) in PostgreSQL. Never use float/double. |
| UUIDs |
All primary keys are Guid. Generated via Guid.NewGuid() in the application layer. Never auto-generated by DB. |
| Transactions |
Every command handler wraps in a DB transaction via the TransactionBehavior MediatR pipeline behavior. |
12. appsettings.json Template
{
"ConnectionStrings": {
"Default": "Host=postgres;Port=5432;Database=laundry;Username=laundry;Password=${DB_PASSWORD}"
},
"BranchMode": "offline_sync",
"BranchId": "550e8400-e29b-41d4-a716-446655440001",
"BranchCode": "CAIRO-01",
"Domain": "laundry.local",
"SeqServerUrl": "http://seq:5341",
"LicensePath": "/license/license.dat",
"ClientTokenSecret": "",
"Jwt": {
"Key": "",
"Issuer": "laundry-api",
"ExpiryMinutes": 480
},
"Serilog": {
"MinimumLevel": { "Default": "Information", "Override": { "Microsoft": "Warning" } },
"WriteTo": [
{ "Name": "Console" },
{ "Name": "Seq", "Args": { "serverUrl": "http://seq:5341" } }
]
},
"HealthChecksUI": {
"HealthChecks": [ { "Name": "Laundry API", "Uri": "/health" } ],
"EvaluationTimeInSeconds": 30
}
}
13. Program.cs Template
var builder = WebApplication.CreateBuilder(args);
// Serilog
builder.Host.UseSerilog((ctx, cfg) =>
cfg.ReadFrom.Configuration(ctx.Configuration)
.Enrich.FromLogContext()
.WriteTo.Console()
.WriteTo.Seq(ctx.Configuration["SeqServerUrl"]!));
// Services
builder.Services.AddControllers();
builder.Services.AddApiVersioning();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
// Auth
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(o =>
{
o.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Key"]!))
};
});
// EF Core
builder.Services.AddDbContext<LaundryDbContext>(o =>
o.UseNpgsql(builder.Configuration.GetConnectionString("Default")));
// Dapper
builder.Services.AddScoped<IDbConnection>(_ =>
new NpgsqlConnection(builder.Configuration.GetConnectionString("Default")));
// MediatR + FluentValidation + AutoMapper
builder.Services.AddMediatR(cfg => cfg.RegisterServicesFromAssemblyContaining<ApplicationAssembly>());
builder.Services.AddValidatorsFromAssemblyContaining<ApplicationAssembly>();
builder.Services.AddAutoMapper(typeof(ApplicationAssembly));
// Health Checks + OpenTelemetry
builder.Services.AddHealthChecks()
.AddNpgsql(builder.Configuration.GetConnectionString("Default")!);
builder.Services.AddHealthChecksUI(s => s.AddHealthCheckEndpoint("API", "/health"))
.AddInMemoryStorage();
builder.Services.AddOpenTelemetry()
.WithTracing(t => t.AddAspNetCoreInstrumentation().AddOtlpExporter())
.WithMetrics(m => m.AddAspNetCoreInstrumentation().AddPrometheusExporter());
// Application services
builder.Services.AddScoped<IInvoiceRepository, InvoiceRepository>();
builder.Services.AddScoped<IAccountingRulesEngine, AccountingRulesEngine>();
// ... all repositories and services
var app = builder.Build();
// Middleware pipeline
app.UseSerilogRequestLogging();
app.UseMiddleware<GlobalExceptionMiddleware>();
app.UseMiddleware<LicenseValidationMiddleware>();
app.UseMiddleware<ClientTokenMiddleware>();
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseHttpsRedirection();
app.UseStaticFiles(); // Serve Angular wwwroot
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.MapHealthChecks("/health");
app.MapHealthChecksUI(o => o.UIPath = "/health-ui");
app.MapPrometheusScrapingEndpoint("/metrics");
app.MapFallbackToFile("index.html"); // Angular SPA fallback
// Auto-run migrations on startup
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<LaundryDbContext>();
await db.Database.MigrateAsync();
}
app.Run();
Revision History
| Date |
Version |
Author |
Changes |
| 2026-05-10 |
1.0 |
Backend Lead |
Initial backend libraries & architecture guide |