All articles
Architecture2026-05-1812 min

Why we are splitting the server into packages you can swap

Auth, database, queue, vector store. We are turning every layer into a port interface with a default adapter. Here is what we are doing, why, and where we are so far.

E
Elvis MagagulaOSS Maintainer

The monolith problem

SupaProxy is a single server today. One repository. One process. MySQL for storage, Redis for queues, LanceDB for vectors, JWT for auth. It works. But we keep asking ourselves what happens if someone wants to use PostgreSQL instead of MySQL, or plug in their own SSO instead of our JWT auth. The answer is fork the server and rewrite the internals.

The platform is public but we have not advertised yet. This is the right time to fix the foundation, before adoption makes it painful to change.

So we are restructuring. Not by adding configuration flags or plugin hooks, but by reorganising the codebase around a principle we already believe in but have not fully committed to. Dependency inversion.

Depend on abstractions, not implementations

Dependency Inversion is the D in SOLID. The idea is simple. High-level modules should not depend on low-level modules. Both should depend on abstractions.

In practice, this means the use case that handles a query should not know it is talking to MySQL. It should talk to a WorkspaceRepository interface. Whether that interface is backed by MySQL, PostgreSQL, SQLite, or a mock in tests gets decided at composition time, not at coding time.

We have this pattern in our codebase already. Every repository is an interface. Every external service goes through a port. But we cheat in one critical place, the composition root. Our container.ts file imports every concrete class directly and wires them together. If you want to swap MySQL for PostgreSQL, you have to rewrite container.ts. If you want different auth, you have to rewrite container.ts. The abstractions exist but the composition is hardcoded.

The composition root principle

The place where you wire abstractions to implementations should be the thinnest possible layer, owned by the host application, not by the library it composes.

What we are changing

We are splitting the server into focused packages. Each package will own one concern. The host application (your server, our cloud, an embedded integration) composes them at startup.

Auth, the first extraction

We are starting with auth because it is the layer most likely to be swapped. Every deployment has different auth requirements. Self-hosters want JWT with bcrypt. Enterprise wants SAML or LDAP. Our cloud will need OAuth with scoped tokens. A test environment wants no auth at all.

Today, auth is baked into the server. The container creates a BcryptPasswordService, a JwtTokenService, a SignupUseCase, and a LoginUseCase. It creates auth routes and auth middleware. All hardcoded. All internal.

The change we are making is that the container accepts auth from outside.

// Today: auth hardcoded in container.ts
const passwordService = new BcryptPasswordService()
const tokenService = new JwtTokenService(JWT_SECRET)
const signupUseCase = new SignupUseCase(orgRepo, passwordService, tokenService)
const loginUseCase = new LoginUseCase(orgRepo, passwordService, tokenService)
const requireAuth = createRequireAuth(tokenService)
const authRoutes = createAuthRoutes({ signupUseCase, loginUseCase, ... })
// What it becomes: auth injected from outside
import { createAuthRoutes } from '@supaproxy/auth'

const { routes, requireAuth } = createAuthRoutes({
  repo: orgRepoAdapter,
  options: { jwtSecret: JWT_SECRET },
  generateId,
})

const container = createContainer(pool, { authRoutes: routes, requireAuth })

Three things change. First, the container loses its auth dependency. It will not import bcrypt, jsonwebtoken, or any auth use case. Second, the auth package becomes self-contained. It owns its password hashing, token signing, routes, and middleware. Third, the host application becomes the composition root. It decides which auth to use.

We have the auth package built and the container refactored. The PRs are open. This part is close to landing.

The adapter pattern

The auth package defines an AuthRepository interface. The host maps its existing org repository to this interface using a thin adapter. No shared types, no package coupling. Just structural compatibility.

Why not just use configuration?

The tempting alternative is configuration flags. Set AUTH_PROVIDER=oauth in your environment and the server switches strategy at runtime. Many frameworks work this way.

The problem is that configuration flags accumulate. Each one adds a conditional branch. Each branch needs testing. Each combination of flags creates a different runtime behaviour that may never have been tested together. AUTH_PROVIDER=oauth plus DB_DRIVER=postgres plus QUEUE_BACKEND=sqs is a configuration that nobody tested because nobody anticipated that combination.

With composition, there are no flags. You import the packages you want. You wire them together. The TypeScript compiler checks that your wiring satisfies the interfaces. If it compiles, the integration is structurally correct. If it does not compile, you get an error before you run anything.

Configuration flags

  • ●Runtime switching via env vars.
  • ●Combinatorial explosion of states.
  • ●Errors at runtime, in production.
  • ●All implementations bundled.
  • ●Cannot tree-shake unused code.

Composition

  • ●Compile-time wiring via imports.
  • ●One explicit composition per host.
  • ●Errors at compile time, before deploy.
  • ●Only imported implementations bundled.
  • ●Unused adapters never downloaded.

Where we are heading

Auth is the first extraction. The same pattern will apply to every infrastructure layer. This is what the full composition will look like when we are done.

// The composable server (your index.ts)
import { createContainer, createApp } from '@supaproxy/core'
import { createAuthRoutes } from '@supaproxy/auth'
import { createMysqlInfra } from '@supaproxy/mysql'
import { createBullMqQueue } from '@supaproxy/bullmq'
import { createLanceDBVectors } from '@supaproxy/lancedb'
import { createRedisSession } from '@supaproxy/redis'

const infra = createMysqlInfra(pool)
const queue = createBullMqQueue(redisUrl)
const vectors = createLanceDBVectors('./data')
const sessions = createRedisSession(redisUrl)
const auth = createAuthRoutes({ ... })

const container = createContainer({
  ...infra, queue, vectors, sessions,
  authRoutes: auth.routes,
  requireAuth: auth.requireAuth,
})

Every line will be a choice. Swap MySQL for PostgreSQL by changing one import. Swap LanceDB for Qdrant. Swap BullMQ for SQS. Swap JWT for OAuth. Each swap is one import change and zero code changes inside core. We are not there yet, but the auth extraction proves the pattern works.

What this will mean

For self-hosters, it will mean you stop fighting the framework. You use the database you already run. You use the auth your organisation already has. You install only the packages you need.

For contributors, it will mean you can add a PostgreSQL adapter without understanding the agent loop. You can add an OAuth provider without understanding how queries are executed. Each adapter is a focused package with a focused test suite.

For us, it means the cloud overlay becomes thinner. Cloud is just another host application that composes core with its own auth, managed queues, and a billing layer. The same core code runs everywhere. The adapters change.

The cost

Composability is not free. The getting-started experience becomes more verbose. Instead of "clone the server and run it", you write a composition root. Instead of one package, you install six. Instead of one README, you read several.

We plan to mitigate this with defaults. The @supaproxy/auth package is the default. The @supaproxy/mysql package will be the default. If you do not care about swapping, you use the defaults and your composition root is twelve lines of code. The complexity is opt-in.

But the cognitive overhead is real. When you read the server entrypoint, you see imports from six packages instead of one. When something goes wrong, the error might be in core, in the auth adapter, or in the repo adapter. The debugging surface is wider.

We think this trade-off is worth making. A wider debugging surface is better than a framework you cannot change without forking. But we are still early in this process and learning as we go.

The composability trade-off

Composability trades initial simplicity for long-term flexibility. The first five minutes are harder. The next five years are easier.

Language portability

There is a subtler benefit we are starting to see. When core has zero infrastructure dependencies, its port interfaces become language-agnostic contracts. A WorkspaceRepository interface is just a list of methods with typed inputs and outputs. Anyone could implement that interface in Go, Rust, Python, or Java.

The domain logic stays in TypeScript for now. But the adapter layer is open. A company that runs Go could write Go adapters for their PostgreSQL database and their SQS queues, then call the TypeScript core via a thin bridge. We are not building that bridge today, but we are not blocking it either.

That is what composability gives you. Not just the ability to swap implementations today, but the architectural permission to evolve in directions you have not imagined yet.

Current status

The rename from supaproxy-server to @supaproxy/core is done and deployed. The @supaproxy/auth package is built and the container refactor is in PR. Infrastructure adapter extraction (MySQL, BullMQ, LanceDB, Redis into separate packages) is planned but not started.

We are sharing this now, mid-process, because the philosophy site is a design journal, not a product blog. These are the decisions we are making and the trade-offs we are weighing. If you have thoughts on the approach, we would like to hear them.

composabilitydddports-and-adaptersdependency-inversionopen-source