← Back to how it's built

Draft: reviewed line by line before open testing. Don't take anything at face value.

Architecture

A React Native app, a Next.js site and a Postgres database in the European Union, with every piece of domain logic — velocity, coefficients, inbreeding, pedigree assembly, ring parsing, every importer — in one pure, unit-tested package that both clients share. Authorisation lives in the database rather than in application code. This page is the design, the decisions behind it, and the three that went badly enough to be worth reading about.

Context and goals

Pigeon racing software is a small market served badly. The established desktop tools are Windows-bound, cost upwards of €150, and have no mobile presence. The most-installed mobile app sits at 3.1 stars. Some cloud rivals make a lapsed subscription mean a fancier cannot reach his own records; here a lapsed subscription means a read-only account that still exports.

A fancier's loft book is twenty years of breeding decisions. It is the least appropriate kind of data to hold hostage, and the incumbents' weakness is not their algorithms — it is that using them is unpleasant and leaving them is hard.

So the goals, in the order they constrain the design:

  1. Mobile-first quality. The app has to be usable one-handed, in a loft, with dirty hands, on an old Android phone. That is the product.
  2. The data is the fancier's. Export is free during the trial and after it, the format is plain, and nothing is withheld when a subscription lapses.
  3. Import from where he already is. Other apps' CSVs, electronic timing exports, federation classification sheets.
  4. Romanian first, built to extend across Europe. Romanian is the source language; English mirrors it.
  5. Correctness in the numbers. A fancier who cannot trust the coefficient will not trust anything else on the screen.

Built by one person, which is a design constraint and not a footnote. It is why conventions are enforced by scripts rather than by review, and why the domain logic is separated hard enough that it can be tested without a device, a network, or a running database.

High-level design

System overview: two client surfaces and one shared domain package talk to a database, object storage and server functions; only the server functions reach the external federation site.

Six components, by role:

  • Mobile app — the product. Screens, forms, charts.
  • Web site — marketing, the public pedigree and loft pages a fancier chooses to publish, PDF rendering, and the legal pages.
  • Domain package — pure TypeScript. No I/O, no framework, no platform. Both clients import it, and it is where every number on this project's screens is produced.
  • Data layer — repositories. The only code that talks to the database.
  • Database — Postgres with row-level security on every table. Photos live in private object storage beside it.
  • Server functions — the small amount of work that cannot happen on a device: account deletion, and the federation import.

Everything except the mobile app itself is hosted in the European Union.

The federation site is external and read-only, and only the server functions ever reach it. That is not an implementation detail — it is the enforceable point that makes the import's conduct rules provable, and a doctrine rule fails the build if the app tries to reach it directly.

Key decisions and trade-offs

Expo and file-based routing, for the mobile app. One codebase, over-the-air updates for anything written in JavaScript, and a managed build service instead of a signing setup. The cost is a bright line between changes that ship instantly and changes that need a native rebuild — a distinction that has to be held in your head all the time, and which is written down because forgetting it wastes a day.

Next.js for the web surface. The public pages need to be indexable and the PDFs need a real rendering environment, neither of which a mobile framework's web target does well. Choosing a separate app rather than one universal codebase means two client surfaces to keep consistent, and the shared domain package is what makes that affordable: the two apps disagree about layout, never about what a coefficient is.

Postgres with row-level security, rather than an API tier that checks permissions. The rule "you may read your own rows" is expressed once, in the database, and every path to the data goes through it — the app, the web site, a server function, a future integration. The alternative concentrates authorisation in application code, where a new endpoint is one forgotten check away from a leak.

The trade-off is real: policies are harder to read than a permission middleware, and a mistake in one is quiet — it filters rows rather than raising an error. That is precisely why the test suites for it are written the way they are, and Security describes them.

A pnpm workspace with Turborepo. Two apps and two packages, one lockfile, one command that type-checks, lints, formats and tests the lot. The cost is tooling complexity that a single-app repo would not carry; the benefit is that the shared package is genuinely shared rather than copied, and CI runs precisely what a developer runs locally.

The domain package versus the data layer — the decision the codebase rests on. Domain logic is pure and knows nothing about storage. The data layer knows about storage and contains no domain logic. Everything a fancier would call a number is in the first; everything that is a query is in the second.

This is what makes the numbers testable at all. Velocity, inbreeding, the decile and the form curve are tested against fixtures with no database, no network and no device, which means they can be tested exhaustively — including the cases that are awkward to produce in a real loft, like a pedigree with a cycle in it or a season where no bird came home.

Data access behind a repository seam. Screens do not query the database. They hold a client and hand it to a repository function, which owns the query and the parsing. The seam exists so that the storage mechanism can change — an offline-sync engine is the intended future — without rewriting screens.

Honestly: the seam is a convention here, not yet a mechanism. It holds almost everywhere, and there is one screen that queries a table directly. That is a defect, it is recorded as one, and the rule that holds the line is written and running — it fails the build if another one appears — but it is pinned at the sites that exist rather than at none, because closing the hole properly needs more than a one-line change and doing it badly would be worse than describing it accurately. A page like this one is worth less if it only tells you about the parts that went well.

What that exception is not is a security problem. The seam is about keeping data access in one layer so it can be changed in one place; it is not what keeps your rows yours. That query goes through row-level security exactly like every other, and the database scopes it to its owner whether it was issued from a repository or from a screen — which is the whole argument for putting authorisation in the database rather than in the code that calls it. The consequence of the exception is inconsistency, and the cost of inconsistency is paid on the day the storage layer changes.

And the ones that hurt

The mobile JavaScript engine does not have the APIs Node has. Tests ran in Node, passed, and the code would have crashed on a real phone — the engine ships without a global crypto and without some text-encoding support that the test environment provides for free. A whole class of defect that a green test suite actively hides.

The fix was not a code review, because a code review catches it once. It is a rule in the build that fails when the offending APIs appear in the app's source at all. That pattern — when a defect can recur silently, write the rule rather than the note — is most of this project's engineering discipline, and Quality is about it.

A timezone cutover, where the code was internally consistent and externally wrong for an entire sprint. The race form asked for a liberation time in UTC. Fanciers typed the local time printed on the race sheet. The importers produced local wall-clock strings and handed them to timestamp columns that read them as UTC. Everything agreed with everything else; the stored instants were two or three hours off, and the app displayed them back in the device's zone so nothing looked wrong.

Nothing in the codebase disagreed with itself, which is exactly why it survived so long. The repair is not a migration — it is a recipe, run per owner, by a human who reads a histogram of the stored hours first, because the two populations (typed-as-local and typed-as-UTC) overlap and no automatic rule can separate them. Running it blind would shift correct rows again, undetectably. The lesson kept: a bug that is self-consistent is invisible to tests, and only an external reference point finds it.

A native module held back a major version, deliberately. Two dependencies are pinned behind what the framework expects, for reasons that were correct at the time. The cost is a permanently noisy dependency check and the loss of a convenient testing shell, so every device smoke test needs a full development build. It is written down as a decision with its consequences rather than tolerated as noise, because an unexplained warning becomes a warning everyone ignores.

Data flow and integration points

A race result

A race result from the form through the domain package, the repository seam and row-level security into the database, and back through the query cache.

The fancier types the arrival — a clock time, sometimes to the millisecond, as his electronic timing system printed it. The domain package turns that plus the liberation time and his loft's own distance into a velocity, and converts the local wall clock to an instant through one tested module rather than the platform's timezone database, which the mobile engine cannot be relied on to have.

The repository writes it. Row-level security decides, in the database, whether the row belongs to the person asking. Reading back returns through the same repository into a cache the screen renders from — so the number on screen came from the same function that computed it on the way in.

One consequence worth naming. Because the database is the authorisation point, there is no server-side code that could accidentally return another fancier's result. A query that asked for one gets an empty answer, not a refusal — which is why the tests that prove it assert emptiness rather than errors.

An import from the federation

A tap starts it. Everything after that is shaped by conduct rules the project committed to before writing any of it, and four of them are visible in this flow:

  1. The fetch happens because the fancier asked. There is no scheduled job, no background sweep, no prefetching, no bulk crawl — ever. A request to the federation is always the direct result of somebody tapping import.
  2. The request identifies us honestly, with a contactable address in it. We never pretend to be a browser.
  3. Only the fancier's own rows are kept. The classification sheet is parsed whole, in memory, and then minimised: his rows, plus race-level facts that name nobody. Other fanciers' names, codes and localities are discarded before anything is written. This is the rule with teeth, and it is the reason the parsing and the minimising are pure functions with tests rather than a step inside a network call.
  4. Nothing is republished. Imported results live in the owner's account. We do not rebuild a classification, we do not offer a public race browser, and we never let a visitor query the federation through us.

Caching sits underneath all of it, so that many fanciers who flew the same race cost the federation one read rather than many — which is politeness and is also the argument that our access places no burden worth objecting to.

Further reading

  • Security — the authorisation model and the tests that hold it.
  • Your data — the same system described for the fancier whose data is in it.