Organise the backend into domain modules that query Postgres through sqlx directly
Decision
Split the backend by business domain: trips, memberships, invitations, versions, auth and the rest. Each domain module owns its types, its error codes, its service logic and its GraphQL resolvers. Domain types carry no GraphQL imports, and resolvers convert domain errors into structured GraphQL error codes at the edge.
Domains import each other directly, and resolvers reach shared services through the application state in the GraphQL context. Services run sqlx queries against Postgres themselves, with no repository layer in between.
Why
A backend organised by technical layer spreads one concept, such as an invitation, across a controller, a service and a repository, and its dependencies follow those layers instead of the problem. We wanted everything about one concept in one module, with a shape a new domain can copy.
A repository abstraction exists mostly so that services can be tested against an in-memory fake. We would rather our tests run against a real Postgres through sqlx::test. That removes the only strong reason to keep the abstraction, and sqlx's query macros check queries against the schema at compile time.
Rejected alternatives
- Controllers, services and repositories as layers. One concept ends up spread across every layer.
- A repository interface over the database. It is one more layer to maintain, and it invites tests against fakes that skip Postgres's constraints and transaction semantics.
Consequences
A domain module has a predictable layout, and everything about invitations sits in the invitations module. Operations that span domains, such as accepting an invitation, which touches membership and realtime, have no formal coordination between them. Direct imports mean a change in one domain can ripple into the domains that use it. A domain's services are tied to Postgres and sqlx, so moving to another store would mean rewriting them.