Work

Shipped in
production.

Five years of professional engineering, the last three on a multi-tenant SaaS for the golf and sports industry: customer-experience surveys, BI dashboards and third-party integrations. These are the pieces I designed and built, the decisions behind them, and where the boundaries of my own work sit.

Written the way I'd explain it in an interview: the engineering and the reasoning, without customer names, vendor names or internal implementation details.

01 · Case studies

[01]AI_FEEDBACK_INDEXING
Players 1st · multi-tenant SaaS · 2025

The ingestion and indexing side of a RAG pipeline

I built the ingestion and indexing half. The comments were sitting in the analytics store as raw data, so the first problem was designing a new structure for them: what becomes a document, and for every field on it, whether it needs to be a SimpleField, a SearchableField, sortable, filterable, or facetable. That schema is what everything downstream runs on. I built a batch process to transform the historical comments and index them, a daily process to keep indexing new ones as they came in, and shipped a trial version so clubs could actually try it before it opened up to everyone.

PROBLEM

Clubs collect thousands of free-text survey comments a year, and reading them all is a job nobody has time for. The product lets someone ask a question about that feedback in plain language, which only works if the right handful of comments can be found first.

DECISIONS I'D DEFEND

  • One club can never see another's comments

    Tenant isolation starts in the schema, which was my half: the account ID is a filterable field on every document, so a search can be scoped to one club before anything is ranked or returned. That makes the isolation a property of the index rather than an instruction in the prompt, and since the model can't issue a query of its own, what reaches it can only ever be one club's documents.

  • The index stores the text, not just the IDs

    Keeping the comment and question text in the index is what makes it a search index rather than a lookup table. Storing IDs alone would mean a second round trip to the source for every hit, and the ranking would have nothing to rank on.

WHAT I BUILT

  • The index schema and its semantic configuration. The rule I applied: searchable only where the text is genuinely searched, filterable where something scopes a query, facetable only where the UI offers a breakdown. Every capability costs index size, so you don't switch them all on.
  • The pipeline that pulls comments from the analytics store, enriches each one with its club and question context, deduplicates them, and pushes them into the index.
  • Batched uploads for the historical backfill: around a thousand documents per batch, three batches running concurrently. Enough to move a lot of data, not so much that the failures become hard to reason about.
  • Retry with exponential backoff, and rollback when it keeps failing: the partial documents are deleted from the index and the comment's indexed-at marker is left unset, so a later run picks it up again. A response is never left half-indexed.
  • A scheduled function keeping new comments searchable, plus the subscription and trial-quota layer and the APIs behind it.

WHAT WAS MINE

Mine: the index schema and semantic configuration, the ingestion and transform, the batched indexer with retry and rollback, the scheduled refresh, and the trial-quota layer. Not mine: the assistant itself, the prompts, the chat UI and the query endpoint.

STACK

Azure AI SearchClickHouseMongoDBAzure Functions.NET / C#
[02]BOOKING_INTEGRATIONS
Players 1st · multi-tenant SaaS · 2024

Daily ingestion from three external booking systems

Pulling numbers from an API is the easy half. The hard half is that this runs unattended every day against three different vendors, some of whose clubs will fail auth or return nothing on any given day. And the same round must never be counted twice, because the value it feeds is cumulative.

PROBLEM

A national association wanted rounds-played statistics for every one of their member clubs. Until then a club had to look the number up and type it in by hand, month after month. That's slow, inconsistent, and across hundreds of clubs it simply doesn't happen reliably, so their national picture was always incomplete.

DECISIONS I'D DEFEND

  • With a cumulative value, the failure mode isn't a crash but a wrong number that looks right

    Each round lands as its own raw row in staging first. The transformation then sums those rows per club and month into a value fact, and new rounds are added to whatever figure is already there. So a batch processed twice against that same raw data throws nothing at all; it quietly inflates a number nobody would question for months. The processed marker on every staged row and the create-versus-add check before each write are what make a re-run safe.

  • A staging table instead of writing straight to the metrics

    Decoupling extraction from transformation means a vendor being down can't corrupt the fact table, and the data can be reprocessed without calling the vendor again. It also gives you somewhere to look when a number turns out wrong.

WHAT I BUILT

  • Three extractors, one per booking system. Before writing any mapping code I hit each vendor's endpoint and read the raw payload. They didn't even agree on the field name for nine- versus eighteen-hole rounds.
  • One staging table they all normalise into: club, provider, hole count, played-at, and a processed marker. The vendors differ at the edge; past that table nothing downstream knows or cares which system a round came from.
  • Each club processed inside its own try/catch, with any error recorded as data and retried on the next run. In a multi-tenant batch job, an exception is data, not a stop signal. One club's expired credential shouldn't end the day for the other two hundred.
  • The transformation: group the staged rounds by club and by month, split nine- from eighteen-hole onto separate metrics, create the yearly container record if the year has rolled over, then either create the fact or add to the value already there.
  • A structured run log recording every outcome, including "there was nothing to process". Running unattended, doing nothing silently had to be a state someone could actually see.

WHAT WAS MINE

The platform already had an idempotency service for imports, so I plugged the three new providers into it rather than inventing a second pattern. That's what guards the extraction side, and it isn't mine. The staging marker and the create-versus-add check that protect the transformation are, along with the three extractors, the transformation service, the staging table and run log, the fact handlers, and the unit tests.

STACK

.NET / C#Azure FunctionsPostgreSQLMongoDB
[03]SHARE_OF_WALLET
Players 1st · multi-tenant SaaS · 2024

A survey question type built on geospatial search

The interesting part isn't the question. Any survey tool can ask "how many rounds". It's that the comparison list has to be different for every club, generated from geography, and stay correct as radii and settings change, without ever breaking the historical answers that already point at it.

PROBLEM

Every satisfaction survey tells a club how happy its members say they are. None of them say how much of a member's actual play the club is capturing. If a golfer plays 30 rounds a year and 15 are at your club, your share is 50%, and the other half is going to clubs you can name.

DECISIONS I'D DEFEND

  • A survey option isn't configuration. It's the key past answers point at

    When a club shrinks its radius, clubs drop off its comparison list. Hard-deleting them would orphan every historical answer that referenced them: the numbers survive, but you can no longer say which club they belonged to. So options are soft-deleted instead, and revived with the same ID if the radius expands again, which keeps the history continuous.

  • An enum, not two booleans

    The original design had separate "use radius" and "use custom list" flags. Two booleans express four states and two of them are nonsense. A single enum makes the invalid states unrepresentable. It's the difference between validating that a combination is legal and making the illegal combination impossible to express. A third selection method later becomes one new enum value instead of a third flag and all its interactions.

WHAT I BUILT

  • Made clubs geographically searchable in the first place. The account record held an address but no coordinates, so I added a GeoJSON location and used MongoDB's geospatial query to find clubs within a radius, so the database does the distance work instead of fetching everything and filtering in code.
  • The domain model: selection mode as an explicit enum (radius or hand-picked list), plus facility-type filters and min/max limits on both the generated list and what a respondent may answer.
  • A sync service keeping each club's comparison list correct across four different triggers, adding clubs as they come into range and soft-deleting those that leave.
  • The configuration API, and the question editor in the internal admin platform.
  • The answer transformation: each response lands as fact rows, and the dashboard groups by club and sums at query time. Nothing stores a per-club total; the distribution chart is computed when someone opens the report.

WHAT WAS MINE

Mine: the question type and its domain model, the geospatial search, the sync service, the API, the editor UI, and the branch on the existing transformation pipeline that carries these answers through. Not mine: the predefined club list structure itself, and populating each club's real coordinates. A colleague with access to that data did that.

STACK

.NET / C#MongoDBClickHouseBlazor ServerMudBlazor
[04]RETENTION_INTELLIGENCE
Players 1st · multi-tenant SaaS · 2025-26

Churn-risk BI module, front to back

By the time the prompt is built, the data is six aggregate numbers for one account. There's nothing to leak even if the model were adversarially prompted.

PROBLEM

Clubs could see how satisfied their members were, but not which of them were about to leave, or what losing them would cost.

DECISIONS I'D DEFEND

  • Deterministic first, generative second

    The model's job is to phrase, not to calculate. Churn-rate deltas and comparisons are worked out in code before the prompt exists, because LLMs are unreliable at arithmetic and a wrong number in a retention dashboard is worse than no insight at all. The worst a bad generation can do here is read awkwardly.

WHAT I BUILT

  • The member churn-risk table with risk categories and contact-member workflows, KPI cards, and the revenue-at-risk and risk-distribution views.
  • Every number an insight refers to: churn-rate deltas, comparisons, whatever the story needs. That comes from the backend already computed. My side is the frontend: display it, and write the base prompt that hands those finished numbers to the model, so the model never does arithmetic, only phrasing.
  • The account ID taken from an httpOnly cookie and re-validated server-side on every request, never read from the request body.
  • The insight card streamed separately, so waiting on the model doesn't block the KPIs and tables from rendering.
  • My first production work in Next.js: App Router, React 19, TypeScript and TanStack Query/Table on top of the existing .NET backend.

WHAT WAS MINE

Mine: the dashboard, the KPI cards, the revenue-at-risk and risk-distribution views, and the base prompt handed to the model. Not mine: the numbers the prompt is built from, computed server-side before they ever reach my frontend, the integration that actually calls the model, and the churn-prediction model itself, which belongs to the data team.

STACK

Next.jsReactTypeScriptTanStack Query.NET / C#ClickHouse
[05]SURVEY_PROPAGATION
Players 1st · multi-tenant SaaS · 2024

Survey propagation across multi-account organizations

The interesting decision was refusing to auto-resolve. A club that customised its wording usually did so for a reason.

PROBLEM

Organizations running many venues need one survey to stay consistent across every child account. But child accounts make their own local edits, so pushing a change down can't simply overwrite whatever is already there.

DECISIONS I'D DEFEND

  • Not last-write-wins

    The easy version overwrites the child and moves on, which silently destroys customisation somebody made on purpose. Excluding diverged children by default turns a silent data loss into a decision a human has to make.

WHAT I BUILT

  • Propagation of survey changes from a parent account down to its children.
  • A three-way diff (the parent before, the parent after, and the child's current state), the same shape as a git merge. Comparing only the two current versions can't tell "never diverged" from "deliberately customised"; bringing the common ancestor in can.
  • Children that have diverged are excluded from the push by default and surfaced to an admin instead, with force-overwrite as a separate, deliberate action.

STACK

.NET / C#MongoDBBlazor ServerMudBlazor

02 · Also shipped

  • 01Full CRUD for survey questions, answer options, scale/NPS configuration, multi-language naming and conditional skip logic in the internal admin platform, letting staff configure surveys through the UI instead of asking a developer to edit records directly in the database.
  • 02Survey distribution rebuilt around per-collection URL slugs, enabling several concurrent campaigns per survey, with access guards for expired or cancelled collections and backwards-compatible fallbacks for links already in the wild.
  • 03Ongoing work on the platform's test suite, where I'm among its most frequent contributors by commit count, across unit and handler-level tests.
  • 04Earlier, at PwC Argentina: event-driven backends on Azure. Service Bus queue-trigger functions that enriched incoming messages, chained processes that published to the next queue, and a timer-trigger batch, applying CQRS and the repository pattern over SQL Server and EF Core.

Products I've built on my own time.

my own projects

$ 03 · Get in touch

Looking for
someone like this?

# I'm actively looking for my next developer role: full-time, in Aarhus or remote. Happy to go deeper on any of the above.

Get in touchdownload cv