An event-driven integration that syncs candidate data from an ATS (Teamtailor) to a CRM and to a consultant matching platform, with LLM enrichment of consultant profiles.
A portfolio project: a simplified model of a real-world integration. Not production code, but designed to behave correctly under failure: duplicate deliveries, retries, crashes and downstream outages.
flowchart LR
tt["Teamtailor<br/>(simulator)"] -->|webhook| ing["Ingress.Api<br/>outbox + relay"]
ing -->|ids only| eh{{"Event Hubs<br/>key = candidateId"}}
eh --> sw["Sync.Worker"] -->|upsert / delete contact| crm["CRM<br/>(simulator)"]
eh --> mw["Matching.Worker"] -->|upsert profile| mp["Matching Platform<br/>(simulator)"]
mw --> llm["LLM"]
- Sync.Worker: candidate created, updated or deleted → CRM contact upserted or marked as deleted.
- Matching.Worker: job application reaches the qualified stage → the LLM suggests skills and seniority →
consultant profile upserted. If the LLM fails, the profile is created as
NotEnriched.
Key decisions:
- Notify-then-fetch: events carry only IDs; workers fetch current state from the API.
- Outbox in Ingress; at-least-once delivery; idempotent upserts keyed by
candidateId. - Parking lot instead of a dead-letter queue, which Event Hubs doesn't have.
- LLM results stored by candidate, CV hash and prompt version before the profile upsert, so retries replay the same decision.
More: architecture, diagrams, domain, acceptance scenarios.
Stack: .NET 10, Azure Event Hubs SDK (no messaging framework), SQL Server 2025 + EF Core,
Microsoft.Extensions.Http.Resilience (Polly v8), Microsoft.Extensions.AI with the Anthropic SDK (Claude),
OpenTelemetry + Aspire Dashboard, xUnit v3 + Testcontainers, Docker Compose, GitHub Actions.
One webhook is one trace across the three hosts, through the outbox and Event Hubs
(ADR 010). Below: a job application reaches the qualified stage. Ingress stores
the webhook and the relay publishes it. Matching.Worker checks its inbox, fetches the application and the candidate,
finds no stored enrichment, calls Claude, stores the result, upserts the profile, writes the inbox and checkpoints.
Sync.Worker ignores job_application.* events, so its branch is only a checkpoint.
- Acceptance scenarios (docs/acceptance-scenarios.md) are the definition of done:
Given/When/Then examples of the domain rules and invariants, written before the code. Each automated scenario has
exactly one integration test named after its ID, e.g.
S4(CRM outage delays but does not lose events) orM3(LLM timeout yields aNotEnrichedprofile). The tests run the three hosts and the simulators in process against SQL Server, Azurite and the Event Hubs emulator in Testcontainers, and break the simulators on purpose. - Unit tests per project: the domain model, use cases, failure classification in the consumer skeleton, HTTP clients and their resilience, the Teamtailor anti-corruption layer.
- CI (GitHub Actions) builds and runs everything on every PR, with a test report and a coverage summary.
- Prompt evaluation (ADR 009): the enrichment prompt is scored offline
against a golden set of fictional CVs (
eval/cases/, including a prompt injection and a Polish CV) for validity, recall, precision, hallucinations, seniority and stability. Run by hand, never in CI, because it calls the real model; reports are ineval/reports/. CI only checks that a report exists for the current prompt.
The reasoning is in architecture decision records:
| ADR | Decision |
|---|---|
| 001 | Project structure by context, then by layer; a project wherever the compiler should enforce a rule |
| 002 | Event Hubs over Service Bus: replay and independent consumers, at the cost of a DLQ |
| 003 | Notify-then-fetch: the event is a signal, the API state is the truth |
| 004 | Transactional outbox in Ingress, one relay |
| 005 | Idempotent effects, per-worker inbox, checkpoint after processing, stored LLM results |
| 006 | Partition key = candidateId: ordering per candidate |
| 007 | LLM enrichment: own port, PII masking, validation, timeout and fallback to NotEnriched |
| 008 | One-way sync from Teamtailor, ownership per field |
| 009 | Offline evaluation of the enrichment prompt |
| 010 | OpenTelemetry, one trace per webhook through the outbox and Event Hubs |
| 011 | Kubernetes: our hosts only, one Helm chart, one relay pod |
Requires .NET 10 SDK and Docker.
Tests need nothing else: the integration tests start SQL Server, Azurite and the Event Hubs emulator with Testcontainers and run the hosts and simulators in process.
dotnet testFor a manual run, set the SQL password once in deploy/.env (copy deploy/.env.example) and give the three hosts
the connection string in user secrets:
for p in Ingress/TalentSync.Ingress.Api Sync/TalentSync.Sync.Worker Matching/TalentSync.Matching.Worker; do
dotnet user-secrets set "ConnectionStrings:Sql" \
"Server=localhost,1433;Database=TalentSync;User Id=sa;Password=<SQL_SA_PASSWORD>;TrustServerCertificate=True" \
--project src/$p
doneLlm:Provider is Fake by default, so the system runs without an LLM key. For Claude, keep the key in user secrets
and switch the provider (every real call is billed):
dotnet user-secrets set "Llm:Anthropic:ApiKey" "<key>" --project src/Matching/TalentSync.Matching.Worker
dotnet user-secrets set "Llm:Provider" "Anthropic" --project src/Matching/TalentSync.Matching.WorkerTo click through the whole flow by hand (success, CRM outage, candidate without a CV), run deploy/run-local.sh
(Git Bash). It starts the containers, builds, applies the EF migrations and starts the three hosts (on Windows each
in its own Windows Terminal tab). Then use
docs/e2e.http. Traces, metrics and logs of the three hosts are in the Aspire Dashboard at
http://localhost:18888 (ADR 010).
- Teamtailor, the CRM and the Matching Platform are in-memory simulators (
simulators/) that can be broken on purpose and report their calls. - CV as plain text: the real Teamtailor API exposes a file (
original-resume); the simulator exposes a plain-text attributeresume-text. - Undocumented Teamtailor behaviour is an explicit assumption, isolated in the adapter: candidate id in job
application webhooks,
404= deleted resource, stage viainclude=stage.
Accepted for the MVP and listed in architecture, e.g.:
- If SQL Server is down when a webhook arrives, Ingress returns 503 and Teamtailor may not retry, so the event is lost. A reconciliation job against the API would close this gap.
- One outbox relay replica: several would publish a candidate's events out of order.
- A profile is not refreshed when the candidate's CV changes, only on the next qualification.
The code is written in small steps with a coding agent. CLAUDE.md and .claude/rules/ hold its instructions,
.claude/settings.json enforces the rules that must hold every time, and the scenarios in
docs/acceptance-scenarios.md are the definition of done: written by hand before the code, never edited to make
a test pass.
Not affiliated with Teamtailor; built from its public documentation.
Shared for review as a portfolio project; no license is granted for reuse.
