Highlander Rules: Can there only be one? Identity management in distributed systems
Why I stopped treating a user as a row and started treating them as a node with edges into every product.

TL;DR. One person signed in to my platform exists in three identity stores across Dating, Billboard, BoxOffice and chat, and the stores did not agree on who that person was. Model the person as a node and each product relationship as an edge, and the disagreements become visible instead of silently empty screens.
Series: The graph you already have
- This post
- Groundhog Day: Why did my retry only happen once? Kafka consumer redelivery semantics (coming 6 Oct)
- Seinfeld: A read about nothing? Successful queries that quietly return empty results (coming 7 Oct)
- Two-Face: One status code, two meanings? Query APIs that separate NotFound from Unimplemented (coming 8 Oct)
- Fellowship of the Ring: One diagram to rule them all? Four product failures mapped as a graph (coming 9 Oct)
The short version
The first reason to reach for a graph is not scale, it is identity. When one person acts across several products, every product keeps its own idea of who they are, and the joins between those ideas are where data goes missing without an error. Draw the person as one node and the products as edges and you can see which edge is broken.
Background
I built a music streaming platform (Billboard), a video service (BoxOffice), a dating app with real-time chat, and the services between them, all behind one login. It is a one-person platform with a small real user base, running on one server plus Cloudflare.
The login is shared. The idea of "the user" is not. Postgres has a Django table called public.accounts_identity. The Go services target a different table, public.identities. The dating gateway reads a third thing at request time, a Cassandra table called users_root, in a different database from the other two.
Scope
What this covers
- How one person became three identity records, and how that showed up as empty screens
- The entity and edge model that replaced the joins
- What Netflix's Part 1 journey (one member, streaming then games on another device) has in common with it
What it does not
- Real-time ingestion, storage and query. Posts 2, 3 and 4 of this series cover those
- Any claim about Netflix's internals beyond what the post itself says
- Scale. Netflix writes millions of records a second. I have a handful of real users
The challenge
The failure never looked like an identity bug. It looked like an empty profile. A real account opened its dating profile on the web and saw a blank page. The profile existed: display name, bio, prompts and preferences were all sitting in the match_cards table in Cassandra. The web app read localStorage and nothing else, and fell back to a blank profile on a miss. The obvious tables, dating_profiles and dating_photos, had no row for that user at all. And every photo URL on the card was a file:// path on the phone that uploaded it, so the media could never cross devices. Nothing threw. Every read returned a valid, empty answer.

Solutions and process
- Attempt one: find the right table. I looked in
dating_profilesfirst because the name said profile. Wrong. The match card IS the dating profile, and about 21,000 candidate cards live inmatch_cardswhile about 40,000 rows sit indating_profiles. A user can be in one population and not the other. - Attempt two: look the card up by user id.
match_cardsis partitioned by(service_region, geohash4), and nothing indexed the user id, so a profile screen could not find its own owner's card. A lookup table,match_card_by_user, fixed that, backfilled with 20,998 rows. It is a hint, never the source of truth, because four write paths across two services insert intomatch_cards. - The reframe: stop asking which table holds the user. Draw one account node, then an edge per product: owns-card (Dating), owns-feed-profile (Billboard), watches (BoxOffice), member-of (chat). Every screen that showed nothing was an edge that nobody had written, or an edge pointing at the wrong node.
- The rule that fell out of it, which I set for the feed service: one profile per mode under one login. The account is the node. Each product's profile is its own node, joined by an edge, never merged into one record, so a visitor to your music profile cannot walk across to your dating profile.
- What I did not do: build a graph database. At this size the edges are rows in the stores that already exist. What changed is the model I reason with, and the checks I write against it.
The model, as types. One account node, one profile node per product, and edges that say who wrote them.
type Mode = "dating" | "billboard" | "boxoffice" | "chat";
type Node =
| { kind: "account"; id: string }
| { kind: "profile"; mode: Mode; id: string };
type Edge = {
from: Node;
to: Node;
rel: "owns" | "follows" | "matched" | "messaged";
writer: string; // the one service allowed to write it
at: number; // ms since epoch
};
const owns = (
accountId: string,
mode: Mode,
profileId: string,
writer: string,
): Edge => ({
from: { kind: "account", id: accountId },
to: { kind: "profile", mode, id: profileId },
rel: "owns",
writer,
at: Date.now(),
});type Mode = "dating" | "billboard" | "boxoffice" | "chat";
type Node =
| { kind: "account"; id: string }
| { kind: "profile"; mode: Mode; id: string };
type Edge = {
from: Node;
to: Node;
rel: "owns" | "follows" | "matched" | "messaged";
writer: string; // the one service allowed to write it
at: number; // ms since epoch
};
const owns = (
accountId: string,
mode: Mode,
profileId: string,
writer: string,
): Edge => ({
from: { kind: "account", id: accountId },
to: { kind: "profile", mode, id: profileId },
rel: "owns",
writer,
at: Date.now(),
});

What it gets wrong
- This is a model I reason with, not a graph database. The edges are still rows in Postgres and Cassandra, so nothing enforces them yet.
- The lookup table
match_card_by_useris a hint, not the truth. Four write paths across two services insert intomatch_cards, and only some keep the hint current. - I have a handful of real users. None of this has been tested at the scale where a graph store starts to earn its cost.
- The identity split is diagnosed, not fixed. Three tables still claim to be the user; the model just makes the disagreement visible.
Takeaway
- An empty screen with no error is often a missing edge. Ask which relationship was never written.
- Name the entity once. If three tables each claim to be the user, you already have a graph, just an undrawn one.
- Keep per-product profiles as separate nodes joined to the account. Merging them leaks one product into another.
- Cost: every edge is a write that some service must own. Four writers to one table is how I got here.
Where this meets Netflix's engineering
How and Why Netflix Built a Real-Time Distributed Graph: Part 1, Ingesting and Processing Data Streams at Internet Scale: Its example journey is a member who watches Stranger Things and then plays Stranger Things: 1984 on a tablet, which is why the graph spans streaming, games and devices.
The shape is the same, one person acting across several products and devices; the difference is everything else, since Netflix ingests millions of records a second into a purpose-built graph and I have a handful of real users whose edges are still rows in the stores that already existed.
Over to you
Identity and platform engineers: when one person uses several of your products, where does the canonical 'who is this' live, and who is allowed to write the edges?
References
- How and Why Netflix Built a Real-Time Distributed Graph: Part 1
- ByteByteGo: How Netflix built a real-time distributed graph
- Designs by Duhart, live demos
Next: the Kafka consumer that skipped every error it was supposed to retry, and why 'error means redeliver' is an assumption.
More: LinkedIn · Instagram. Portfolio and case studies: designsbyduhart.org.
If any of this saved you an afternoon, Buy me a coffee.