Every internal developer portal starts the same way: a list of services, an owner per service, a few links, a scorecard. It is useful within a week. Then someone asks the question the whole thing was really bought for — if I change this, what breaks? — and the answer arrives as a list of direct dependencies, one hop deep, with a footnote about transitive ones being “on the roadmap”.
That is not a missing feature. It is the data model refusing to answer.
What a flat catalogue actually stores
A flat catalogue stores entities with attributes, and models relationships as a foreign key or a
tag: payment-api has depends_on: [orders-db]. Ask for two hops and you write a join. Ask for
four and you write four joins, decide what to do about cycles, and then discover the query planner
has opinions about your fan-out. Most products stop at one or two hops and call it “dependency
mapping”, which is a fair description of what they can afford to run.
The trouble is that the interesting questions are all multi-hop:
- What breaks if
orders-dbchanges? Not its neighbours — everything downstream of them too. - Where is this signing key used, at any depth, including by things that inherited access?
- Which teams do I need in the room before this migration starts?
- What can this AI agent actually reach, transitively, through every permission it holds?
Every one of these is reachability. Reachability is what a graph database does natively and what a relational schema does under protest.
What we run
Hodos stores the catalogue in PostgreSQL with the Apache AGE extension — an openCypher engine that lives inside Postgres. That choice was not a preference; it was a spike with a pass/fail gate. We loaded a realistic estate, ran breadth-first traversal at depth, and measured. Multi-hop traversal came in around an order of magnitude faster than the equivalent recursive SQL, well inside the budget an interactive UI needs.
The second reason for AGE matters more commercially than technically: it is one Postgres. A self-hosting platform team already knows how to run, back up, monitor and restore Postgres. Asking that team to also operate a separate graph database — a second storage engine with its own failure modes, its own backup story and its own on-call runbook — is asking for a “we will evaluate it next quarter” that never ends.
Budgets, not depth limits
The naive way to keep traversal safe is a depth limit: never go more than three hops. It is easy to implement and it produces confidently wrong answers, because interesting graphs are not uniform. A service with four shallow dependencies and a service that sits behind a shared library have very different shapes at the same depth.
We budget instead. A query gets an allowance of work — nodes visited, edges expanded, time — and when it runs out, the result says so explicitly rather than pretending the frontier was empty. A truncated answer that admits it is truncated is useful. A truncated answer that looks complete is worse than no answer at all, because someone will merge on it.
That is the same rule the product’s interface follows everywhere: never assert more than you know.
The part that only works because it is a graph
Here is the thing we did not fully appreciate until it was built. Once relationships are first-class, authorisation becomes a graph question too. “Can this subject reach this resource?” is structurally the same query as “what breaks if this changes?” — a traversal from a starting node, filtered by rules, bounded by a budget.
So the policy engine is not a separate system bolted onto the catalogue. It walks the same graph
with the same traversal machinery. When an agent connects over MCP and asks what depends on
payment-api, the answer is computed and filtered in one pass, and the nodes it is not allowed to
see are never in the result — not greyed out, not “restricted”, simply absent.
You cannot build that cleanly on a data model where relationships are an afterthought. That is the whole argument for a graph, and it is why we started there rather than adding one later.