Players Engine

Devlog · Entry 13 · Engineering Notes

Documents and graphs: why we run two databases

· Nic Vannetti · 5 min read

Running two databases on a bootstrapped budget sounds like a self-inflicted wound. Two bills, two failure modes, two sets of operational folklore to learn at 2 a.m. — and yet Players Engine runs a document store and a graph database, on purpose, and I'd make the same call again. The short justification: the platform asks two fundamentally different shapes of question, and forcing one engine to answer both is how you end up hating a perfectly good database.

Two shapes of question

Shape one: "give me this thing, fast." A profile, a post, a settings blob — key-addressed lookups of self-contained documents, millions of times a day. Shape two: "how are these things connected?" Who follows whom, which players share games and friends, what's two hops away from you but shouldn't be three. Document stores answer shape one in O(1) with per-operation pricing that stays honest at scale. Ask them shape two and you're writing recursive fan-out queries that get slower and pricier with every user you add — the exact opposite of what a social feature needs. Graph databases are the mirror image: traversals are their native tongue, but as a primary content store they'd make every boring read pay graph prices.

Each store owns what it's good at

So the split follows the questions. The document store is the system of record for content and profile data, aggressively denormalized so the hot paths — the screens people actually look at — are single indexed reads, not joins reconstructed in application code. The graph holds the social topology: nodes for the entities that relate, edges for the relationships, and just enough denormalized display data on each node to render a result list without a second round-trip. Nothing lives in both stores as an authoritative copy: every fact has exactly one owner, and the other store holds, at most, a derived shadow of it.

Sync flows one way, asynchronously

Derived shadows have to be maintained, and this is where dual- store architectures usually rot. Our rule: synchronization is one-directional and event-driven. When an owned fact changes, the owning service emits an event; a worker consumes it and updates the shadow copy in the other store. No service ever writes the same fact to both stores in one request — that's how you get the two-generals problem wearing a name tag. The plumbing (and its failure modes, which are educational) is covered in the Pub/Sub entry. The result is eventual consistency with a lag measured in seconds, which for social-topology shadows is a trade nobody has ever noticed from the outside.

The float that broke pagination

The war story this architecture owes you: one day, a paginated graph query started returning 500s, in code that had worked for weeks. The culprit took an evening to corner. JavaScript has one number type; the graph database has integers and floats, and the driver — configured to return plain JS numbers for ergonomics — was also sending plain JS numbers. The database received our page size as 25.0, a float, and refused it: you can't LIMIT by a float, says the type system, entirely reasonably, in production, at night. The fix is a one-liner (wrap pagination values in the driver's explicit integer type), but the lesson generalizes: every boundary between type systems is a place where your assumptions ship silently. The same class of bug lives between JSON and your ORM, between two services' schemas, between a form field and a query. Boundaries get explicit types or they eventually get incidents.

Plan for drift anyway

Even with disciplined one-way sync, shadows drift: a worker bug, a dropped event, a migration that touched one store and not the other. Two mechanisms keep drift boring instead of mysterious. On-read healing — when a service notices a shadow that contradicts its owner, it repairs it in passing rather than just serving the lie. And reconciliation as a scheduled activity: periodically comparing a sample of owned facts against their shadows and logging every mismatch, because drift you measure is a bug backlog, while drift you don't measure is a slowly corrupting product. Neither mechanism is glamorous. Both have caught real bugs before users did.

When you should not do this

Two databases earn their keep here because the social graph is the product, not a feature bolted on. If relationship queries are 2% of your workload, denormalize a friends list into your document store and go build something users want. The honest checklist before adding any second store: the query shape must be structurally hostile to your current engine (not just slow — wrong); the data must be cleanly ownable by one side; and you must be able to afford the second bill — including the always-on kind, as discussed in the budget entry. Two databases is one too many, right up until the moment it's exactly enough.

Want in on a future wave?

Drop your email and we'll let you know when Players Engine opens up. Early supporters get first access.

← All devlog entries