Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Database Structure #4

We’ve redesigned the database layer a couple of times by now. Currently, any service can access any database, which is a situation we would like to resolve. Therefore, it’s time to refactor the database layer again.

Goals

  • We want to separate our database queries from the rest of our code
  • We want a solid overview of which services can write to which tables. This way, any guarantees a single service sets up about its database can be upheld.
  • We want every service to read from every table
  • We don’t want any SQL injections

A Short History of Our Database Layer

First Iteration - Database Everywhere

When we started needing a database, we just threw in sqlx::query! calls wherever we needed, directly in the handlers. The only rule was that the macro had to be used, to ensure that the queries were compiled offline, such that introducing SQL injection vulnerabilities would be very difficult.

Second Iteration - Database Modules

We wanted a clearer separation between the database code, and the rest of the code, and we therefore created a db module in each service crate, which would contain all the queries. This also had the goal of making queries reusable.

However, queries were not reusable across services, something that is a goal, as spelled out in our architecture documentation.

Third Iteration - Database Crates

In order to make the code easier to navigate, we split the database modules into their own crate in the crates/libs/db/ directory. This also introduced the DefaultDb abstraction, where each database crate would expose a trait with all its available queries, and implement it for the DefaultDb struct.

This also ended up letting us compile and test database crates individually, something that turned out to be quite beneficial. For instance, with complicated queries such as message ordering or sync, this has been a big boon.

The goal would be that every table, had roughly one crate for all queries relating to it. But this did not make much sense, considering that JOINs exist. The rule quickly got many exceptions, to the point where it didn’t make much sense any more.

The Next Iteration

Each service in Arminius should have a database user. For each table there exists one user, which has the privileges to update the table. All users are allowed to read from all tables. When the monolith is spawned, it gets a special monolith user, which is allowed to read from and write to all tables.

We split our database crates up into two crates per service. A write crate, which is only usable by the service which is supposed to write to its tables, and a read crate, which is allowed to read from the tables. We use sqlx.toml files to ensure that each db crate and each service uses the correct postgres account to connect to the database.

Example

The arminius-server-client owns the global_account_data table. Therefore, all queries which write to the global_account_data table are found in arminius-lib-db-client-write, and all queries which read from the table are found in arminius-lib-db-client-read. Only arminius-lib-db-client-read may be depended on by other crates than arminius-server-client.