← Blog

The Data Model Nobody Warns You About: Risks Aren't Trees, They're Graphs

A payment gateway timed out for nineteen minutes. By the time we'd finished writing it up, the same incident had been claimed three times: infrastructure filed it as a Technology risk, operations logged it as a service-availability failure, and compliance wanted it flagged against a regulatory reporting obligation. One event, three owners, three categories. My data model could only let it pick one.

That single foreign key is where today's post lives.

The gap this closes

Post 1 called fragmentation the real enemy, not a shortage of data. This is that same problem, but hiding inside the schema instead of across five tools. If one incident can only point at one risk, you have quietly rebuilt the thing the register was meant to kill. The full shape of an event gets split, either across systems or across three people's private spreadsheets, and nobody sees all of it at once.

The model I started with was a tree

My first schema was a tree. Every incident carried a single categoryId, one foreign key into the risk taxonomy from the last post. It was clean, it charted in one line, and it was wrong within about a week of real use.

Risks and incidents are not trees. One incident touches many risks. One risk is evidenced by many incidents. That outage was Technology (the gateway), Operational (the service was down), and Legal/Regulatory (a reportable event) at the same time, and none of those was more true than the others. The relationship is many-to-many in both directions, which is the definition of a graph, not a hierarchy.

The build

1 First model: an incident belongs to one risk category via a single foreign key.
2 Reality check: a nineteen-minute outage is Technology, Operational, and Regulatory at once.
3 Wrong turn: duplicate the incident, one row per category, so the counts add up.
4 The fix: an IncidentRisk join table, so one incident links to many risks.
5 Add an isPrimary flag on each link, so the dashboard still has one headline category.
6 Reporting now aggregates through the join table, not the foreign key.

The join table is the whole move. In Prisma it is a small explicit model sitting between Incident and Risk, and every row carries its own metadata: whether this is the primary association and how strong the link is. The graph became the source of truth, and the old single-category view became just one query over it.

The trade-off: normalization vs. the reporting query

The tree had one real advantage, and I gave it up on purpose. Count incidents by category used to be a one-line group-by on a foreign key. Once the truth lives in a join table, that same question has to fan out through the join and can double-count if you are not careful, because one incident legitimately appears under three categories.

So I kept a controlled piece of denormalization: the isPrimary flag. The 30-second dashboard reads the primary link and gets a clean headline number with no double counting. Anyone who drills in gets the full graph, every category the incident actually touched. Normalized truth underneath, one fast view on top. That split is the part nobody warns you about, and it is worth being deliberate about rather than discovering it under a board deadline.

What I got wrong

My first fix was worse than the problem. Instead of a join table, I duplicated the incident, one row per category. Two rows, same outage. It made the dashboard counts add up, and it quietly corrupted the audit trail. Close one copy, the other stays open, and now the register disagrees with itself about whether a nineteen-minute outage was ever resolved. For a tool whose entire job is being the single source of truth, that is close to the worst failure mode there is. I ripped it out the next day and never trusted a just duplicate the row shortcut again.

Where it stands

Today an incident links to as many risks as it genuinely touches, each link carries its own metadata, and the dashboard still gets one headline number because the primary flag hands it one. The graph is the truth. The tree is only a view.

Next up (Post 4): the first 48 hours of scaffolding this whole platform, and what I deliberately over-built and under-built on day one.

Following the build? Subscribe below and the next post lands in your inbox.