The following is a quick summary of techniques that we use to program our database layer, that has allowed us to scale our DB code with minimal issues as our app has grown in complexity and user base size.
We run on GCP, using Postgres under Cloud SQL, using one primary instance and many zonal read-replicas.
We write in raw SQL using crystal-db
and crystal-pg
.
We use micrate
. It has some rough edges, but it's a great & simple tool.
We make heavy use of psql
for quick development. Particularly it becomes
very nice to work with using \e
to open $EDITOR
to edit queries.
Once the query works as we expect it, because we do not use any ORM, it is mostly just a matter of pasting the query into our code and writing a datatype (if relevant).
I can also recommend some lines from my .psqlrc
:
With options like \timing
, we get an immediate sense if something could
be slow, and also have the chance to run the query planner to do analysis
before even integrating the query into code.
It also means use the same tool locally as we do for remote debugging on staging or production database instances - it's a great tool to become familiar with.
All queries are exposed as static methods in our DB
module:
Queries are NOT written for arbitrary CRUD updates to the backing table. Sometimes we do end up writing a plain CRUD layer, but that is not how we approach writing the API.
Each query is written to serve a specific function that our app needs. In many cases, there is no singular "backing table" - any query may read a subset of a table,or join multiple tables, or even return virtual columns that do not exist on disk. In a sense, our DB layer is "feature oriented" rather than any attempt at a generic "object relational" mapping.
Treating our DB code as a sort of FFI layer, this decoupling brings many benefits.
This means if we alter or remove a query, we have a list of all app features that are affected via compiler errors.
We can also change the query underneath without affecting app code.
We can tune the performance of each query for its specific use case, rather than attempting to preemptively optimize arbitrary table access.
Cloud SQL metrics will show you the raw SQL submitted to the database, and the queries load on the database. Since we use raw SQL in our code, it is very easy to trace back the under-performing query in our code base with a grep.
Query methods are usually written in "big endian" name after the principle table
that they operate on - usually whatever table the query is FROM
, even if there
are JOIN
s involved - followed by a description of the action they perform.
This keeps names mostly predictable, and editors still autocomplete this just fine, even without LSP.
Almost all queries are written using Crystal's heredoc syntax:
This allows formatting queries in a legible and unobtrusive way, without having
to deal with any escapes. It will also trim leading whitespace using the last
SQL
as a guide, which makes logs easier to read as well. Finally, editors and
sites like GitHub will actually recognize this and enable SQL syntax
highlighting in that section.
Almost all queries use a static string. A select few build an SQL string
incrementally: in most cases, this is simply tacking on an extra WHERE
clause.
API parameters accept only what they need. Generally what this means, if a query
operates on a user ID, it does not accept User
, just user_id
.
This gives a lot more flexibility by not forcing code to produce an entire
User
object when that particular operation does not need any field other than
the user's ID.
We do not make overloads that accept User
. It just adds noise / more code than
just typing user.id
.
Every query binding accepts an untyped, named db
parameter. It has a
default value of a connection pool instance. (DB::Database
)
We have two connection pools in global constants:
SQL
, a read-write primarySQL_RR
, a zonal read-only replicaWeird naming, but
SQL
was a legacy decision when we started the codebase because we couldn't pick a better one. AndSQL_RR
was added later. You can probably pick better names.
This has a few properties:
SQL
-> SQL_RR
, or vice versa.The
*, db
enforces thatdb
must be used with an explicitdb:
argument by callers.
The untyped parameter allows for either a DB::Database
or a
DB::Connection
to be passed. This allows for trivially composing
multiple queries in a transaction with zero changes needed to the
API:
These transaction operations themselves will usually be wrapped in their
own static method, that also takes a db
arg at the top level, for
consistency with everything else.
Most often, each query is given its own Serializable
type bound to
the return type of that particular query. This way, each query is
treated as its own independent unit of code that can be freely moved around
and changed without concern of having a rippling effect across a lot of queries,
i.e. adding a new column without a default.
This also gives us a lot of control over bandwidth and memory size on a query
level - for example, some queries may only return a subset of the users
table columns that are needed for that particular query.
Every type will include ::DB::Serializable::NonStrict
. This is important for
zero-downtime updates to our app - adding new columns should not crash any
existing code.
We may place utility methods on these types, but they are always kept simple: performing some summary of data or state of the returned object, and never i.e. performing more queries.
We prefer to use ints in DB for crystal enums, with explicitly declared values.
Using pg enums we felt was not worth the investment, nor did we prefer TEXT
.
Consider also that we have things other than Crystal interacting with our
tables (Python, Go, …)
We use the following converters to override the default serialization, and everything has worked out fine.
The Str
variants were used to transition some columns away from using
strings.
Minimally, our UPDATE
or DELETE
bindings will return a bool to confirm if
the query had any affect, in case the caller needs to know this. The majority
operate directly on a PK, so there will always be 0 or 1 matches.
None of our DB bindings raise errors (besides those propagated from
the db
or pg
shard itself). If the caller needs to handle some
case, we return an enum or union, and the caller uses exhaustive case
.
Exceptions are unnecessary overhead 99% of the time, where we always just need to inform the immediate caller of some logical state. Exceptions are left for actual exceptions (protocol errors, unavailability, …).
Initially, we started by attaching static methods that do queries directly on the "model" that they primarily deal with.
For example:
It felt fine to do this at first, but also stemmed from the fact that we initially started our project with an ORM before removing it.
However, as our codebase grew, it became apparent that this was not a great idea - more complex queries begged distracting questions as to which "model" they should be placed on, so you wind up with a mess of inconsistencies.
So I suggest: completely discard the idea of attaching the queries to any "model" type; it is not useful, and does not scale.
All new queries that we write go directly as static methods on our DB
module,
with the freedom to pick an expressive name, decoupled from congregating around
any one particular datatype or table.
Today we would write it as: