Back to projects
In production · API + web client
Case study

A CV score you can audit.

BuildCv scores a résumé against one specific job posting and explains where every point came from. No language model anywhere in the calculation: the weights, the thresholds and the rules are published, and the same CV and posting return the same number today and next month.

1,418 test methods
4 layers, each tested
47 endpoints
v4 scoring model
01 — Context

The problem is not the rejection. It is not knowing why.

Job seekers send dozens of applications and hear nothing back. The tools promising to 'optimise your CV for the ATS' return a number with no explanation, and when there is an LLM behind it the same CV can score 68 today and 74 tomorrow with nothing changed. A score you cannot reproduce is not a diagnosis — it is an opinion formatted as data.

  • The score is pure arithmetic in C#. Zero calls to a language model anywhere on the scoring path — checkable by grepping the repository.
  • Section weights are public and sum to 1.00. Anyone can recalculate their own score by hand.
  • Every recommendation re-runs the whole formula with that one gap closed and reports the real difference, not an estimate of how much it might help.
  • A free product for Spanish-speaking job seekers, the population with the fewest serious tools available to them.
02 — Architecture

Four layers, each with its own test project.

Real hexagonal architecture, not folders named after layers. Dependencies point inward: the domain knows nothing about the database or about HTTP. The number beside each layer is its test-method count.

DOMAIN 291 tests

BuildCv.Domain

Pure business rules

The entities, the weights and the bands. No outward dependencies: this is where the definition of a match lives, and it can be tested without starting anything.

C#.NET 10xUnit
APPLICATION 425 tests

BuildCv.Application

Use cases · CQRS

The scoring engine and the recommendation builder. Orchestrates the domain and declares the ports that infrastructure implements.

C#MediatR-style handlersxUnit
INFRASTRUCTURE 388 tests

BuildCv.Infrastructure

Persistence and adapters

Implements the ports: repositories, migrations, encryption. Its tests run against a real SQL Server started by Testcontainers, not against a double.

EF CoreSQL ServerTestcontainers
API 314 tests

BuildCv.Api

Minimal API · 68 endpoints

The HTTP surface. Secure by default: the global authorization policy requires a session and opting out is explicit with AllowAnonymous.

ASP.NET CoreOpenAPIRate limiting
CLIENT Separate repo

buildcv-v2-web

Next.js · BFF pattern

The browser never talks to the API. Tokens live in httpOnly cookies on this origin and every call goes through a route handler acting as a backend-for-frontend.

Next.js 15TypeScriptPlaywright
03 — The model

The weights are published. That is the point.

Six sections, each carrying a fixed share of the total. The bar is that share: Skills fills 45% of it because it weighs 0.45 of the score. Anyone can take these numbers and check their own result.

Default weights · model v4 · they sum to 1.00
Skills 0.45
Experience 0.20
Education 0.10
Certifications 0.10
Languages 0.10
Projects 0.05
Bands · the cuts, stated rather than implied
  • Low 0 – 39
  • Medium 40 – 59
  • Good 60 – 79
  • Strong 80 – 100

A section the posting does not ask about consumes no weight: its share is redistributed proportionally across the sections it does ask about, so the ceiling is 100 for every posting. The previous version handed an unasked section a neutral 0.5, which made half its weight unreachable — a flawless CV scored 95 and the candidate had no way to find out why. In a product whose whole purpose is explaining the score, that was the worst possible bug.

04 — Explainability

Every suggestion carries a measured number.

When BuildCv suggests a change it does not estimate the benefit: it re-runs the whole formula with that single gap closed and reports the difference. A '+3.2 points' is a score that was calculated, not a forecast. If the engine cannot measure it, it does not claim it.

  • Impact is computed in 0..1 and shown as points out of 100, to one decimal. The precision of the unit reflects the precision of the calculation.
  • The client never re-implements a server rule. The engine recognises aliases — 'React.js' satisfies 'React' — so a local comparison would contradict the score sitting next to it.
  • A section whose weight is 0 renders 'not measured', never a score. The weight is the signal for whether it applies; there is no separate flag that could drift out of sync.
  • The two scores — match and readability — are different models and are never added. Averaging them would produce a number that describes nothing.
05 — Security

Decisions measured, not assumed.

Authorization
Secure by default: the global policy requires a session. Opting out is explicit with [AllowAnonymous] — the opposite of forgetting to protect a new endpoint.
Rate limiting
5/min per IP on auth, 100/min globally. 429 with Retry-After and a ProblemDetails body.
Body size
413 before the request is buffered. One unauthenticated 121 MB POST took the container from 56 to 305 MiB. It now answers 413 in 11 ms.
Ingress
Cloudflare only. The raw origin answers 403. The edge cannot be walked around, and the deployment script re-asserts it.
CSRF
Origin check in the client middleware. azurecontainerapps.io is not on the Public Suffix List, so SameSite is not enough: every Container App would be same-site with this one.
Session
Two httpOnly cookies on the BFF origin. The browser never holds an API credential.
06 — Testing

1,418 methods, and none switched off.

1,418 test methods
0 skipped tests
0 TODOs in 334 files
3 CI jobs
  • Every layer has its own test project: Domain 291, Application 408, Infrastructure 388, Api 302.
  • Integration tests run against a real SQL Server via Testcontainers. A double is written from the same belief that was wrong.
  • compose-smoke brings up the whole topology — database, migrator and API — registers a real user and asserts it survives a restart.
  • docs/api-contract.md is validated by tests, so the documentation cannot silently drift from the code.
  • The web client adds Playwright: smoke against a real API, and WCAG AA accessibility over the public screens, which runs in CI because it needs no backend.
07 — Deployment

Verifying the artifact is not verifying the deployment.

That the source compiles, that the image behaves, and that what is running is what you shipped are three different claims. All three are checked separately, and the third produced the most surprises.

  • The verification script establishes which revision it is measuring before it measures anything: it waits for a single revision to hold 100% of traffic and refuses to measure during a rollout.
  • Without that I nearly reported a working fix as broken: health answered, but it was the previous revision answering while the new one was still starting.
  • The shutdown grace period was measured rather than assumed: a request finishing inside the window gets a complete response and exit 0; one that outlasts it is SIGKILLed and its caller gets a severed connection with no status.
  • The app runs at min-replicas 0, so the verifier wakes it on a long leash first — a cold start read as a dead deployment on the first run.
  • The image publishes to GHCR only from main and only after the container verification passed. Deploy the SHA, never latest.
08 — Status

What is done and what is not.

In production
  • Deterministic scoring engine with a versioned model (v4)
  • Readability score, available without a job posting
  • Recommendations with measured impact, not estimated
  • API and web client deployed behind Cloudflare
  • Public front door with the whole model published on it
In progress
  • Operator details on the legal pages — the only fields the code cannot fill in for itself
  • A no-account trial, so someone can decide before registering
  • Publishing code coverage, which is collected today but uploaded nowhere
  • A CI job for the OpenAPI contract, pending a regeneration against the live API

The code is open.

Both repositories are public: the C# API and the Next.js client. Nothing claimed on this page requires you to take my word for it.

See all projects