top of page

How to preserve Data Isolation in Supabase. How to Prevent Your Database from Becoming a Big Ball of Mud.

  • Writer: Igor Miazek
    Igor Miazek
  • 2 days ago
  • 7 min read

Supabase makes it incredibly easy to build software. Authentication, storage, realtime features, and PostgreSQL are available from day one, allowing teams to focus on delivering business value instead of infrastructure. As applications grow, however, many teams discover that the database has quietly become the place where architectural boundaries disappear. New features begin querying existing tables, cross-domain foreign keys accumulate, and a modular codebase gradually turns into a tightly coupled relational model.


The challenge is that tenant isolation is not the same as domain isolation. Row Level Security (RLS) answers who can access a row, but it does not answer which business capability should own that data. In this article, we'll explore how to preserve sub-domain boundaries in Supabase by treating PostgreSQL schemas, Drizzle ORM sessions, and migrations as part of each bounded context. The result is a modular architecture that remains easy to evolve while retaining the simplicity and operational benefits of a single Supabase project.


A few buildings together and the sky showing interesting architecture.
Photo by Simone Hutsch on Unsplash

The Hidden Coupling Problem


One of the biggest misconceptions in software architecture is that a modular codebase automatically leads to a modular system. In practice, many applications have well-organized folders, separated services, and clearly defined business modules, yet all of them interact with the same database schema through a single ORM client. The architectural boundaries that exist in the application layer often disappear entirely once data is persisted.


A typical Supabase project starts with a single public schema. As new features are introduced, developers naturally add new tables and relationships, often creating foreign keys and joins across different business capabilities. What begins as a simple and efficient data model gradually evolves into a highly connected graph where almost every module depends on data owned by another module.


On disk the system may look modular:

src/
  modules/
    fleet/
    orders/
    routing/

But the persistence layer often collapses everything into one shared namespace:

// Everything lives in public ownership is only a folder convention
import { pgTable, uuid, varchar } from "drizzle-orm/pg-core";

export const vehicles = pgTable("vehicles", {
  id: uuid("id").primaryKey(),
  registrationNumber: varchar("registration_number", { length: 32 }).notNull(),
});

export const orders = pgTable("orders", {
  id: uuid("id").primaryKey(),
  trackingNumber: varchar("tracking_number", { length: 32 }).notNull(),
});

export const routes = pgTable("routes", {
  id: uuid("id").primaryKey(),
  // Cross-domain FK: Routing now depends on Fleet at the database level
  vehicleId: uuid("vehicle_id").references(() => vehicles.id),
  // Cross-domain FK: Routing now depends on Orders too
  orderId: uuid("order_id").references(() => orders.id),
});

From PostgreSQL’s point of view this is normal. From a business-architecture point of view, Routing can no longer evolve without coordinating migrations, deployments, and data ownership with Fleet and Orders.


For example, an order may reference a customer, a vehicle, a courier, a delivery route, a warehouse, pricing information, notifications, and tracking events. From a relational database perspective, this design is perfectly valid. From a business architecture perspective, however, it creates hidden coupling. Every new feature gains knowledge of additional domains, making independent evolution increasingly difficult.


The problem becomes even more apparent at the ORM layer. A single database client exposes every table in the application, making it trivial for any developer to query or modify data across domain boundaries:

// One client, every table boundaries exist only in the developer's head
import { drizzle } from "drizzle-orm/postgres-js";
import * as schema from "./schema"; // vehicles + orders + routes + ...

export const db = drizzle(process.env.DATABASE_URL!, { schema });

// Nothing then prevents a Routing feature from reaching straight into Fleet:

// Inside modules/routing/assignVehicle.ts
import { db } from "../../db";
import { vehicles, routes } from "../../schema";
import { eq } from "drizzle-orm";

export async function assignFirstAvailableVan(routeId: string) {
  // Routing code now knows Fleet's table shape and status vocabulary
  const [van] = await db
    .select()
    .from(vehicles)
    .where(eq(vehicles.status, "available"));

  await db
    .update(routes)
    .set({ vehicleId: van.id })
    .where(eq(routes.id, routeId));
}

At this point the folder named routing/ is only organizational. The real integration layer is the shared schema and the shared ORM client. Orders can reach into Pricing the same way; Fleet can start reading order payloads “just for convenience.” The database becomes the integration layer for the entire system.


This coupling is rarely intentional. It emerges gradually because relational databases optimize for data consistency, not for business ownership. PostgreSQL has no concept of bounded contexts, and neither does Supabase. Without explicit architectural decisions, every domain eventually becomes dependent on every other domain.


Before discussing solutions, it's important to understand that this isn't a limitation of Supabase or PostgreSQL. It's a consequence of treating the database as a shared resource rather than as a collection of independently owned business capabilities one public schema, one Drizzle db, and foreign keys that quietly turn every module into every other module’s dependency.



From Technical Modules to Business Boundaries


To preserve architectural integrity as a system grows, it's important to distinguish technical organization from business organization. Splitting code into folders or packages improves maintainability, but it does not create true isolation if every module shares the same database model. Instead, the primary boundary should be the business capability a concept central to Domain-Driven Design (DDD).


A bounded context represents an area of the business that owns a specific set of responsibilities, data, and rules. In a logistics platform, capabilities such as Fleet Management, Orders Management, and Routing solve different business problems. Fleet is responsible for vehicles and drivers, Orders manages the shipment lifecycle, and Routing plans deliveries. Each capability should evolve independently, exposing only the information other domains genuinely need.


In code, that ownership should be visible at a glance not only as folder names, but as separately published modules:

src/modules/
  fleet-management/     # owns depots, vehicles, drivers
  orders-management/    # owns customers, orders, parcels
  routing/              # owns delivery zones, routes, stops
// src/index.ts — namespaces, not one shared schema dump
export * as FleetManagement from "./modules/fleet-management/index.js";
export * as OrdersManagement from "./modules/orders-management/index.js";
export * as Routing from "./modules/routing/index.js";

Each module then packages its tables and its database session as the unit other code is allowed to depend on:

// What Fleet exposes to the rest of the app
export { db, depots, vehicles, drivers } from "./…";

// What Orders exposes
export { db, customers, orders, parcels } from "./…";

// What Routing exposes
export { db, deliveryZones, routes, routeStops } from "./…";


The important shift is semantic: a module is no longer “a place where Routing files live.” It is a business capability with an owned model. Fleet may freely relate vehicles to depots; Orders may relate parcels to orders. Those are intra-context rules. What Routing needs from Fleet is not the `vehicles` table it is a stable piece of information (for example, a vehicle id) that Fleet already owns.


Once ownership is defined at the business level, the database should reflect the same structure.



PostgreSQL Schemas as Domain Boundaries that Provide Data Isolation for Supabase


PostgreSQL schemas provide a simple yet effective way to express domain ownership inside a single database. Rather than placing every table in the default `public` schema, each bounded context owns its own namespace.


Instead of:

public.orders
public.vehicles
public.routes

the database becomes:

orders.orders
fleet.vehicles
routing.routes

With Drizzle, that mapping is explicit: each module declares its schema first, then defines tables inside it.

// modules/fleet-management/schema.ts
import { pgSchema, uuid, varchar } from "drizzle-orm/pg-core";

export const fleetPgSchema = pgSchema("fleet");

export const vehicles = fleetPgSchema.table("vehicles", {
  id: uuid("id").defaultRandom().primaryKey(),
  registrationNumber: varchar("registration_number", { length: 32 }).notNull().unique(),
  // …capacity, status, depotId (FK only to fleet.depots)
});
// modules/orders-management/schema.ts
export const ordersPgSchema = pgSchema("orders");

export const orders = ordersPgSchema.table("orders", {
  id: uuid("id").defaultRandom().primaryKey(),
  trackingNumber: varchar("tracking_number", { length: 32 }).notNull().unique(),
  // …sender/recipient FKs stay inside the orders schema
});
// modules/routing/schema.ts

export const routingPgSchema = pgSchema("routing");

export const routes = routingPgSchema.table("routes", {
  id: uuid("id").defaultRandom().primaryKey(),
  code: varchar("code", { length: 32 }).notNull().unique(),
  // vehicleRef / depotRef are plain UUIDs — no FK into fleet.*
});

This is more than an organizational convention. A schema defines ownership, making it immediately clear which domain is responsible for a particular table. It also encourages teams to think twice before introducing dependencies between business capabilities: importing `fleet.vehicles` into Routing is no longer a casual autocomplete away from a shared `schema.ts` it is a conscious cross-context dependency.


The session layer reinforces the same boundary. Each module opens a Drizzle client scoped to its schema, rather than one global client that can see everything:

// modules/fleet-management/db.ts
import { drizzle } from "drizzle-orm/postgres-js";
import * as schema from "./schema.js";

export const PG_SCHEMA = "fleet" as const;

export const db = drizzle({
  connection: {
    /* credentials */
    connection: { search_path: PG_SCHEMA },
  },
  schema, // only Fleet tables
});

Using separate schemas does not mean creating separate databases or abandoning the operational simplicity of a single Supabase project. Authentication, Row Level Security, backups, monitoring, and the Data API continue to work as before custom schemas simply need to be exposed to PostgREST when you want HTTP access:

// Same boundary over the Data API
const fleet = supabase.schema("fleet");
const { data } = await fleet.from("vehicles").select("*");

The difference is architectural: the database is no longer viewed as a single shared model, but as a collection of independently owned business domains that happen to coexist within the same PostgreSQL instance `fleet`, `orders`, and `routing` as first-class namespaces, each with its own tables, enums, and ORM session.



Supporting Long-Term Architecture and Growth for Supabase Projects


Architectural decisions made early in a project have a significant impact on how easily the system can evolve. Organizing a Supabase application around bounded contexts, with each domain owning its own PostgreSQL schema, Drizzle session, and migration history, creates clear ownership boundaries that remain valuable as the business grows.


As new features are introduced, teams can extend existing domains or introduce new ones without increasing coupling across the entire platform. Business capabilities evolve independently, changes become easier to reason about, and development can be distributed across multiple teams without every engineer needing to understand the entire database.


This approach also provides a natural migration path if the architecture ever needs to evolve beyond a modular monolith. Because domains already communicate through application-level contracts rather than shared database relationships, individual bounded contexts can be extracted into independent services with significantly less refactoring. The architectural boundaries already exist; only the deployment model changes.


The goal is not to build microservices from day one, but to avoid building a tightly coupled system that makes future evolution expensive. By preserving sub-domain isolation inside a single Supabase project, you retain the operational simplicity of one PostgreSQL database while establishing an architecture that can support years of business growth.

 
 
 
bottom of page