How to engineer a modular SaaS framework in Go and Nuxt 3 that abstracts multi-tenant complexity for junior developers.
Building a multi-tenant SaaS typically introduces a severe cognitive burden on development teams. The moment you mix multiple clients into a single database, you rely on behavioural discipline—hoping a junior developer never forgets a WHERE tenant_id = ? clause.
To solve this, I designed OASIS (Opinionated Architecture for Secure Isolated SaaS). OASIS shifts data isolation from a behavioural requirement to a structural constraint. By applying systems theory principles (specifically Donella Meadows’ framework of stocks, flows, and feedback loops), we can analyse how this architecture provides the security of isolated multi-tenancy while preserving the developer experience (DevEx) of a single-tenant CRUD application.
Here is a technical breakdown of the OASIS framework and the systemic reasoning behind it.
The Subsystems (Nodes)
The framework is built on a highly opinionated tech stack, structured into distinct subsystems:
- Ingress: Caddy (Wildcard subdomains, On-Demand TLS, CORS-free local dev).
- Frontend: Nuxt 3 (TypeScript, SSR routeing via Host headers).
- Backend: Go (Lightweight concurrency, strict typing).
- Persistence: PostgreSQL (Isolated schemas) managed by the Ent ORM.
- Authorisation: Casbin (
ent-casbinadapter,casbin-redis-watcher). - Orchestration: OpenTofu, LXD, and Docker.
Tracking Stocks and Flows
To understand how OASIS abstracts complexity, we must map how resources and information flow through the system.
The Core Stocks
- Tenant Data (Intangible): Walled off dynamically. Rather than row-level isolation (shared tables), OASIS uses Isolated Schemas (
tenant_01h45...). Each tenant is a completely separate namespace within a single PostgreSQL instance. - Database Connections (Tangible): Go maintains a single global
database/sqlconnection pool. Managing this stock efficiently is critical to preventing noisy-neighbour exhaustion.
The Context Flow
The defining flow of OASIS is the Go context.Context. It acts as the immutable carrier of identity.
When a request hits the Go backend, middleware extracts the JWT and the tenant subdomain. It then injects this identity into the context.Context and passes it down the chain. The handler business logic never needs to inspect the HTTP request headers directly.
Cross-System Synergies & Friction Reduction
By cross-referencing these subsystems, we can identify powerful synergies that eliminate developer friction and prevent systemic failures.
Friction Reduction: The Transactional Schema Guardrail
The highest risk in multi-tenancy is data leakage. OASIS mitigates this by injecting a database transaction bound to a specific search_path directly into the request flow.
When a request arrives, the Go middleware acquires a connection from the global pool, executes SET LOCAL search_path TO tenant_x, starts an Ent ORM transaction (*ent.Tx), and attaches it to the context.Context.
To the junior developer, the code looks like standard single-tenant CRUD logic:
go
func GetUsersHandler(w http.ResponseWriter, r *http.Request) {
// rbac.Can natively reads the Casbin identity from the context flow
if !rbac.Can(r.Context(), "GET", "/api/users") {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
// fetchTx extracts the already-isolated Ent transaction
tx := fetchTx(r.Context())
users, _ := tx.User.Query().All(r.Context())
renderJSON(w, users)
}
Because the search_path is tied to the transaction, it automatically resets on commit or rollback. Connection pool poisoning is impossible, and developers are structurally incapable of querying the wrong tenant.
Resource Cascades: Nuxt 3 Host Routeing
Instead of maintaining three separate repositories for the marketing site, global admin panel, and tenant apps, OASIS utilises a resource cascade at the SSR layer.
A single Nuxt 3 application parses the incoming Host header. Caddy dynamically routes *.saasdomain.tld, and Nuxt's server middleware intercepts it. The host identity cascades down to the Vue components, dynamically mounting the correct layouts and executing Zod-validated state injections (useAuth and useRBAC) without URL parameter clutter.
Reinforcing Loops: Distributed Authorisation
For authorisation, OASIS uses Casbin with domain-scoped roles g(user, role, domain). However, a stateless Go backend deployed across multiple Docker containers creates a stale-cache problem.
To create a reinforcing synchronisation loop, OASIS integrates casbin-redis-watcher.
An admin updates a policy on Node A.
Node A writes the rule to the global public.casbin_rule PostgreSQL table.
Node A publishes a Redis Pub/Sub ping.
Nodes B, C, and D intercept the flow, instantly reloading their in-memory Casbin graphs from the database.
The system self-heals its security posture across the entire cluster in milliseconds.
Licensing as a System Boundary (BUSL-1.1)
Finally, protecting the framework itself requires a legal boundary. OASIS uses the Business Source Licence (BUSL-1.1) (often referred to as Fair Source).
Under BUSL-1.1, the code is public and completely free for hobbyists, learners, and startups. The Additional Use Grant explicitly permits commercial production use, provided the entity's gross revenue is under £1,000,000 GBP.
This creates a mutualistic loop: junior developers get unfettered access to enterprise-grade architectural tooling to learn the trade, while massive corporations are prevented from exploiting the framework without purchasing a commercial licence. After four years, each release automatically converts to a permissive Apache 2.0 licence, ensuring the code eventually benefits the wider open-source ecosystem permanently.
By treating multi-tenancy as a systemic architecture problem rather than a behavioural coding standard, OASIS provides a bulletproof foundation. The complexity remains beneath the surface, allowing developers to do what they do best: ship features.
Top comments (0)