Writing / Architecture
One database, one owner: why the product asks instead of reads
Two systems care about the same facts. Customer Success configures clients and contracts through an internal admin tool. The learner-facing platform has to enforce whatever they configured — who has access, to what, until when.
There are three ways to arrange that, and the differences between them are almost entirely about who is trusted with what.
The tempting one: give each system its own copy
Build the admin tool against its own store, sync to the platform on a schedule or through an event queue, and both sides move independently.
It's decoupled, which is the appeal, and the cost is a synchronization problem you now own forever. Two copies of one fact need something keeping them in agreement, and that something is either a job that can lag or fail, or a pipeline that can drop or reorder. You've traded one place to look for two places that are supposed to agree, usually do, and announce their disagreement as a support ticket from a customer whose access doesn't match what they were sold.
The obvious one: let both systems read the table
One database, no sync, nothing to drift. This is better, and it's where the design started.
But it puts the learner platform inside the contract tables. Every query the platform writes is a query that could return a row it shouldn't, and the only thing preventing that is the platform remembering to scope correctly, every time, in every query, forever. Authorization becomes a property distributed across two codebases written by people solving different problems.
What it actually does: the platform asks
The admin server owns the database. Nothing else connects to it. When the platform needs to know whether a learner's access is valid, it sends an internal request to the admin server and gets an answer back — the request never leaves the node it's running on.
Customer Success ──▶ Admin UI ──▶ Admin server ──▶ Postgres
▲
internal request
│
Learner platformThe difference from reading the table looks small and isn't. The platform can no longer construct a query, so it can no longer construct a wrong query. It doesn't ask for rows, it asks a question, and the component that answers is the one that owns the rules. Authorization lives in exactly one codebase because only one codebase is capable of reaching the data.
There's still no second copy of anything, so the sync problem never comes back. What's added isn't duplication — it's a door.
What this forecloses on purpose
A contract misconfigured in the admin tool is immediately what the platform enforces, because there's no interval between configuration and effect for a state to be misconfigured in. That's the property you want when the whole point is handing configuration to people who will never read the code that acts on it. Their mistake is visible immediately rather than at the next sync.
It also means the admin tool cannot be casually wrong about what the platform will do. There's no "the admin UI says active but the product says expired" class of bug, because there is structurally nowhere for those two statements to differ.
What it costs
A network hop on a hot path. Checking access is now a request rather than a join. Keeping it on the same node makes that cheap, but it is not free, and it makes the platform's availability depend on the admin server's. A component used by a handful of Customer Success staff is now load-bearing for every learner.
Coupling by contract instead of by schema. The two systems move together — not because they share a schema, but because they share an interface. That's a better coupling than a shared table, and it is still coupling: changing what the platform can ask means changing two things.
One more service in the request path to deploy, monitor, and reason about, in exchange for authorization that can only be implemented once.
Where a different shape fits
- The consumers genuinely don't need consistency. If a stale answer is acceptable for minutes, a read replica or a cache in front of the ask removes the hop and the availability coupling. The reason it isn't done here is that access checks are exactly the thing you don't want stale.
- The database can enforce it. Postgres row-level security pushes the scoping into the engine, which makes direct reads safe again and removes the service boundary. It's a real alternative, and it moves authorization into a place fewer engineers are comfortable debugging.
- More than two consumers appear. At two systems, a direct internal request is simple and obvious. At six, that's a hub every one of them depends on, and the shape worth considering is a service that publishes decisions rather than answering them one at a time.
The thing worth taking from this isn't "don't duplicate data" — everybody agrees with that already. It's that removing the second copy still leaves a question about who is allowed to touch the first one, and answering it with a service boundary rather than a code review is what makes the guarantee hold.