Add ASP.NET Core backend hosting business rules; FE consumes via typed client
Move the authoritative business rules off the frontend into a real backend,
realising the BFF-lite + decision-DTO design (ADR-0001) that until now lived
only in static mock JSON.
Backend (backend/):
- ASP.NET Core (.NET 10) minimal API, contract-first, Swagger UI at /swagger.
- DDD Domain/ rules layer: profession derivation + applicable policy questions
(DiplomaRules), herregistratie eligibility + reason (HerregistratieRule),
scholing threshold (IntakePolicy), submit rejections + reference generation
(SubmissionRules). In-memory seeded data, ProblemDetails (RFC 7807) errors.
- 27 xUnit tests: rule units + endpoint integration incl. BRP no-address and
DUO not-found fallbacks and 422 submit paths.
Frontend (only infrastructure/ + contracts/ change, as the architecture promised):
- NSwag-generated typed client (api-client.ts), routed through Angular HttpClient
via a small fetch adapter so the ?scenario= interceptor still applies.
- GET adapters use resource({ loader: client.x }); submit commands call the client
and map ProblemDetails -> err. The hardcoded uren==0 / manual-diploma rules are
deleted (now server-side). Domain, stores, UI and format validators unchanged.
- Deleted the now-dead public/mock/*.json.
Tooling/docs:
- npm start proxies /api -> backend; npm run gen:api regenerates the client;
docker compose up runs both (bind mounts use :z for SELinux/Fedora).
- backend/README.md walkthrough: adding a policy question is a one-file backend
change, no FE change, no client regen. Updated CLAUDE.md + ARCHITECTURE.md.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
94
backend/src/BigRegister.Api/Program.cs
Normal file
94
backend/src/BigRegister.Api/Program.cs
Normal file
@@ -0,0 +1,94 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using BigRegister.Api.Contracts;
|
||||
using BigRegister.Api.Data;
|
||||
using BigRegister.Domain.Diplomas;
|
||||
using BigRegister.Domain.Intake;
|
||||
using BigRegister.Domain.Registrations;
|
||||
using BigRegister.Domain.Submissions;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddSwaggerGen(c =>
|
||||
c.SwaggerDoc("v1", new() { Title = "BIG-register BFF", Version = "v1" }));
|
||||
builder.Services.AddProblemDetails();
|
||||
builder.Services.ConfigureHttpJsonOptions(o =>
|
||||
{
|
||||
o.SerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
|
||||
o.SerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull;
|
||||
});
|
||||
|
||||
const string SpaCors = "spa";
|
||||
builder.Services.AddCors(o => o.AddPolicy(SpaCors, p =>
|
||||
p.WithOrigins("http://localhost:4200").AllowAnyHeader().AllowAnyMethod()));
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
app.UseSwagger();
|
||||
app.UseSwaggerUI();
|
||||
app.UseCors(SpaCors);
|
||||
|
||||
var api = app.MapGroup("/api");
|
||||
|
||||
// --- GET: screen-shaped reads. Decisions are computed here, never on the client. ---
|
||||
|
||||
api.MapGet("/dashboard-view", () =>
|
||||
{
|
||||
var reg = SeedData.Registration;
|
||||
var (eligible, reason) = HerregistratieRule.Evaluate(reg, DateOnly.FromDateTime(DateTime.Today));
|
||||
return new DashboardViewDto(reg.ToDto(), SeedData.Person.ToDto(),
|
||||
new HerregistratieDecisionsDto(eligible, reason));
|
||||
});
|
||||
|
||||
api.MapGet("/notes", () =>
|
||||
SeedData.Notes.Select(n => new AantekeningDto(n.Type, n.Omschrijving, n.Datum)).ToList());
|
||||
|
||||
// BRP "no address" fallback would be `new BrpAddressDto(false, null)` — the seeded
|
||||
// citizen has one.
|
||||
api.MapGet("/brp/address", () => new BrpAddressDto(true, SeedData.BrpAddress.ToDto()));
|
||||
|
||||
api.MapGet("/duo/diplomas", () => new DuoLookupDto(
|
||||
SeedData.Diplomas.Select(d => d.ToDto()).ToList(),
|
||||
new ManualDiplomaPolicyDto(
|
||||
DiplomaRules.ManualProfessions(),
|
||||
DiplomaRules.ManualQuestions().Select(q => q.ToDto()).ToList())));
|
||||
|
||||
api.MapGet("/intake/policy", () => new IntakePolicyDto(IntakePolicy.ScholingThreshold));
|
||||
|
||||
// --- POST: submits. The server is the authority; it re-validates and decides. ---
|
||||
|
||||
api.MapPost("/registrations", (RegistratieRequest req) =>
|
||||
{
|
||||
var reject = SubmissionRules.RejectRegistratie(req.DiplomaHerkomst);
|
||||
return reject is null
|
||||
? Results.Ok(new ReferentieResponse(SubmissionRules.NewReference()))
|
||||
: Results.Problem(detail: reject, statusCode: StatusCodes.Status422UnprocessableEntity);
|
||||
})
|
||||
.Produces<ReferentieResponse>()
|
||||
.ProducesProblem(StatusCodes.Status422UnprocessableEntity);
|
||||
|
||||
api.MapPost("/herregistraties", (HerregistratieRequest req) =>
|
||||
{
|
||||
var reject = SubmissionRules.RejectZeroUren(req.Uren);
|
||||
return reject is null
|
||||
? Results.Ok(new ReferentieResponse(SubmissionRules.NewReference()))
|
||||
: Results.Problem(detail: reject, statusCode: StatusCodes.Status422UnprocessableEntity);
|
||||
})
|
||||
.Produces<ReferentieResponse>()
|
||||
.ProducesProblem(StatusCodes.Status422UnprocessableEntity);
|
||||
|
||||
api.MapPost("/intakes", (IntakeRequest req) =>
|
||||
{
|
||||
var reject = SubmissionRules.RejectZeroUren(req.Uren);
|
||||
return reject is null
|
||||
? Results.Ok(new ReferentieResponse(SubmissionRules.NewReference()))
|
||||
: Results.Problem(detail: reject, statusCode: StatusCodes.Status422UnprocessableEntity);
|
||||
})
|
||||
.Produces<ReferentieResponse>()
|
||||
.ProducesProblem(StatusCodes.Status422UnprocessableEntity);
|
||||
|
||||
app.Run();
|
||||
|
||||
// Exposed so the integration tests can spin up the app with WebApplicationFactory.
|
||||
public partial class Program { }
|
||||
Reference in New Issue
Block a user