Encapsulation is a critical architectural quality that prevents unwanted coupling and maintains clear boundaries. This document covers patterns for preventing unwanted communication both within an app (cross-layer) and between apps (cross-service).
How to read this page. The cross-layer hexagonal examples below
(domain/, application/, infrastructure/ directories, WorkspaceRepository
ports, rich Workspace domain models) are illustrative teaching patterns,
not a description of the current codebase. Tuturuuu apps are conventional
Next.js App Router trees: apps/web/src contains app/, components/,
features/, hooks/, lib/, and utils/ — there is no
domain//application//infrastructure/ layering and no separate ports/adapters
package. Likewise, apps communicate primarily through a shared Supabase
database plus Trigger.dev v4 background tasks, not through a broker with
event ACLs. Each “in Tuturuuu” snippet below is marked as either real or
illustrative. Treat the illustrative ones as design inspiration you could adopt,
not as files that exist today.
Migration in progress. apps/web (Next.js, port 7803) is being replaced by
apps/tanstack-web (TanStack Start) plus apps/backend (Rust, port 7820). The
encapsulation principles here remain valid across both stacks, but treat any
apps/web-specific path as legacy-or-current rather than permanent. See
TanStack Start and Rust Migration.
Preventing Cross-Layer Communication (Inside an App)
Even without formal hexagonal layering, an app benefits from clear internal
boundaries that prevent tight coupling. The patterns below show how dependency
inversion, DTOs, and layered responsibility could be applied; where Tuturuuu
already does something equivalent (RLS, package boundaries, internal-api
helpers), that is called out as real.
1. The Dependency Inversion Principle (via Ports)
Illustrative pattern. The ports/adapters file paths below
(packages/types/src/domain/*, apps/web/src/domain/*,
apps/web/src/infrastructure/*) do not exist in the repo today and are shown
purely to teach the dependency inversion principle. packages/types/src exposes
flat modules (db.ts, index.ts, supabase.ts, sdk.ts, …) with no domain/
directory.
High-level business logic can define interfaces (“Ports”) it requires, and
low-level infrastructure (like database code) implements them.
Architectural Principle: High-level modules should not depend on low-level modules. Both should depend on abstractions.
Illustrative example:
// Hypothetical domain layer defines interface (PORT)
// e.g. packages/types/src/domain/workspace.ts (does not exist today)
export interface WorkspaceRepository {
findById(id: string): Promise<Workspace | null>;
findByOwnerId(ownerId: string): Promise<Workspace[]>;
save(workspace: Workspace): Promise<void>;
delete(id: string): Promise<void>;
}
// Business logic depends on abstraction, NOT concrete implementation
// e.g. apps/web/src/domain/services/workspace-service.ts (illustrative path)
export class WorkspaceService {
constructor(
private readonly repository: WorkspaceRepository // Depends on interface
) {}
async createWorkspace(ownerId: string, name: string): Promise<Workspace> {
const workspace = new Workspace(ownerId, name);
// Domain logic doesn't know about Supabase, PostgreSQL, etc.
await this.repository.save(workspace);
return workspace;
}
}
// Infrastructure layer implements interface (ADAPTER)
// e.g. apps/web/src/infrastructure/repositories/supabase-workspace-repository.ts (illustrative path)
export class SupabaseWorkspaceRepository implements WorkspaceRepository {
constructor(private readonly supabase: SupabaseClient) {}
async findById(id: string): Promise<Workspace | null> {
const { data } = await this.supabase
.from('workspaces')
.select('*')
.eq('id', id)
.single();
return data ? this.toDomainModel(data) : null;
}
async save(workspace: Workspace): Promise<void> {
await this.supabase
.from('workspaces')
.upsert(this.toDatabaseModel(workspace));
}
// ... other methods
}
Benefits:
- Domain logic is pure and testable without infrastructure
- Can swap implementations (Supabase → Drizzle → Prisma) without changing business logic
- Inversion of control - domain doesn’t depend on infrastructure
- Technology agnostic core business logic
Clarifying Additions: This enforces a clean separation that preserves the integrity of business rules. Infrastructure choices become replaceable rather than deeply embedded. The architecture stays flexible as technical needs evolve.
“Anti-pattern” — but also how Tuturuuu actually writes code today:
// In the hexagonal sense this "couples business logic to Supabase".
// In practice this IS the current Tuturuuu pattern: route handlers and
// server components call the Supabase client directly.
import { createClient } from '@tuturuuu/supabase/next/server';
export async function createWorkspace(ownerId: string, name: string) {
// NOTE: createClient() from @tuturuuu/supabase/next/server is ASYNC — await it.
// (createAdminClient() is synchronous; createDynamicClient() is async.)
const supabase = await createClient();
const { data } = await supabase
.from('workspaces')
.insert({ owner_id: ownerId, name })
.select()
.single();
return data;
}
The hexagonal critique (harder to unit-test, technology lock-in) is real, but
Tuturuuu deliberately accepts direct Supabase access for app/API code and relies
on Row-Level Security, shared @tuturuuu/supabase wrappers, and
packages/internal-api helpers for boundaries instead of a ports/adapters
layer. Adopt the repository pattern only where a unit of logic genuinely needs to
be storage-agnostic or heavily unit-tested.
2. Strict Data Transfer Objects (DTOs)
The outer API layer of a service communicates with its inner Application layer using plain DTOs. This creates a strong boundary that prevents internal, behavior-rich Domain Models from being exposed to external layers, ensuring the core logic remains fully encapsulated.
Architectural Principle: External layers communicate via simple data structures, not rich domain objects.
Illustrative pattern. The rich Workspace domain class and
apps/web/src/domain/models/workspace.ts path are illustrative. Real
apps/web API routes work with plain objects and generated DB row types from
@tuturuuu/types/db, not behavior-rich domain entities. The DTO discipline (keep
HTTP request/response shapes separate from internal models, serialize dates as
ISO strings) is still worth applying.
Illustrative example:
// API layer DTO (external contract)
// e.g. apps/web/src/app/api/workspaces/create/route.ts
interface CreateWorkspaceRequest {
name: string;
description?: string;
}
interface CreateWorkspaceResponse {
id: string;
name: string;
ownerId: string;
createdAt: string; // Serialized as ISO string
}
export async function POST(request: Request) {
const body: CreateWorkspaceRequest = await request.json();
// Convert DTO to domain command
const command = new CreateWorkspaceCommand(
userId,
body.name,
body.description
);
// Execute in domain
const workspace = await workspaceService.execute(command);
// Convert domain model back to DTO
const response: CreateWorkspaceResponse = {
id: workspace.id,
name: workspace.name,
ownerId: workspace.ownerId,
createdAt: workspace.createdAt.toISOString() // Domain Date → DTO string
};
return Response.json(response);
}
// Domain model (internal, rich behavior) — illustrative; no such file exists today
// e.g. apps/web/src/domain/models/workspace.ts
export class Workspace {
constructor(
public readonly id: string,
public readonly ownerId: string,
private _name: string,
private _isActive: boolean,
public readonly createdAt: Date
) {}
// Rich domain behavior
rename(newName: string) {
if (newName.length < 3) {
throw new DomainError('Workspace name must be at least 3 characters');
}
this._name = newName;
}
activate() {
if (this._isActive) {
throw new DomainError('Workspace is already active');
}
this._isActive = true;
}
deactivate() {
this._isActive = false;
}
get name() {
return this._name;
}
get isActive() {
return this._isActive;
}
}
// Domain model NEVER exposed to API layer
// API only sees DTOs
Benefits:
- Encapsulation of domain behavior
- Clear boundaries between layers
- API stability - internal changes don’t break API contract
- Serialization control - DTOs are JSON-friendly
- Versioning - can support multiple DTO versions
Clarifying Additions: Interfaces clarify what the domain requires without exposing internal structures. This allows external systems to interact without knowing implementation details. Such boundaries minimize coupling between layers.
Anti-pattern to avoid:
// BAD: Exposing domain model directly
export async function POST(request: Request) {
const workspace = await workspaceService.create(data);
// WRONG: Returning domain model directly
return Response.json(workspace);
}
// Problems:
// - Domain behavior exposed (rename, activate methods in JSON)
// - Date objects don't serialize correctly
// - Internal structure leaked to external API
// - Can't evolve domain without breaking API
3. Explicit Layered Responsibility
The architecture enforces a clear separation of concerns. The Presentation layer handles HTTP, the Application layer orchestrates workflows, and the Domain layer contains pure business logic. This clarity prevents logic from being misplaced and ensures layers only interact through their well-defined public interfaces.
Architectural Principle: Each layer has a single, well-defined responsibility and communicates only through defined interfaces.
Actual apps/web/src structure (real):
apps/web/src/
├── app/ # Next.js App Router: pages, layouts, and api/ route handlers
├── components/ # Shared React components for this app
├── features/ # Feature-scoped UI + logic
├── hooks/ # React hooks
├── lib/ # App-level helpers and clients
├── utils/ # Cross-cutting utilities
├── context/ # React context providers
├── data/ # Static/seed data
├── trpc/ # Local tRPC stub (healthCheck only — not a product data layer)
└── i18n/ # Localization config
There is no application/, domain/, or infrastructure/ directory. Cross-app
product data flows through the shared Supabase database and
packages/internal-api helpers calling REST /api/v1 routes — not through a
ports/adapters layer.
Illustrative hexagonal structure (what an explicitly layered service could
look like — not present today):
src/
├── app/ # Presentation Layer (HTTP) — real today
│ └── api/workspaces/create/route.ts
├── application/ # Application Layer (Orchestration) — illustrative
│ └── use-cases/create-workspace.ts
├── domain/ # Domain Layer (Business Logic) — illustrative
│ ├── models/workspace.ts
│ └── services/workspace-service.ts
└── infrastructure/ # Infrastructure Layer (Technical) — illustrative
└── repositories/supabase-workspace-repository.ts
Illustrative example with clear layer responsibilities:
// PRESENTATION LAYER: HTTP concerns only
// apps/web/src/app/api/workspaces/create/route.ts
export async function POST(request: Request) {
// 1. HTTP handling (validation, auth)
const session = await getSession();
if (!session) {
return Response.json({ error: 'Unauthorized' }, { status: 401 });
}
const body = await request.json();
// Validate input schema
const validation = CreateWorkspaceSchema.safeParse(body);
if (!validation.success) {
return Response.json({ error: validation.error }, { status: 400 });
}
// 2. Delegate to application layer
const useCase = new CreateWorkspaceUseCase(
workspaceRepository,
eventPublisher
);
try {
const workspace = await useCase.execute({
ownerId: session.userId,
name: validation.data.name,
description: validation.data.description
});
// 3. HTTP response
return Response.json({ workspace }, { status: 201 });
} catch (error) {
return Response.json({ error: error.message }, { status: 500 });
}
}
// APPLICATION LAYER: Workflow orchestration
// apps/web/src/application/use-cases/create-workspace.ts
export class CreateWorkspaceUseCase {
constructor(
private readonly repository: WorkspaceRepository,
private readonly eventPublisher: EventPublisher
) {}
async execute(command: CreateWorkspaceCommand): Promise<Workspace> {
// 1. Create domain entity
const workspace = Workspace.create(
command.ownerId,
command.name,
command.description
);
// 2. Persist
await this.repository.save(workspace);
// 3. Publish domain event
await this.eventPublisher.publish(
new WorkspaceCreatedEvent(workspace.id, workspace.ownerId)
);
return workspace;
}
}
// DOMAIN LAYER: Pure business logic
// apps/web/src/domain/models/workspace.ts
export class Workspace {
static create(
ownerId: string,
name: string,
description?: string
): Workspace {
// Business rules
if (name.length < 3 || name.length > 50) {
throw new DomainError('Workspace name must be 3-50 characters');
}
return new Workspace(
generateId(),
ownerId,
name,
description,
true, // isActive
new Date()
);
}
// No HTTP, no database, no events - pure domain logic
}
Layer Communication Rules:
| From Layer | Can Call | Cannot Call | Reason |
|---|
| Presentation | Application | Domain directly | Bypass orchestration |
| Application | Domain, Infrastructure | Presentation | Circular dependency |
| Domain | Nothing | Application, Infrastructure, Presentation | Pure logic |
| Infrastructure | Domain (via interfaces) | Application, Presentation | Implementation details |
Benefits:
- Clear responsibility per layer
- Easy to reason about where code belongs
- Testability - each layer tested independently
- Maintainability - changes confined to appropriate layer
Clarifying Additions: This keeps the heart of the system stable despite environmental changes. The domain stays consistent across different execution contexts. This significantly boosts reliability and predictability.
Anti-pattern to avoid:
// BAD: Mixed responsibilities
export async function POST(request: Request) {
const body = await request.json();
// WRONG: Business logic in API layer
if (body.name.length < 3) {
return Response.json({ error: 'Name too short' }, { status: 400 });
}
// WRONG: Direct database access from API layer
const { data } = await supabase
.from('workspaces')
.insert({ name: body.name });
// WRONG (and outdated API): firing a background task inline with no structure.
// In Trigger.dev v4 you trigger a defined task, e.g.:
// await myTask.trigger({ workspaceId: data.id });
// (Trigger.dev v2 APIs like trigger.event/client.defineJob do not exist in this repo.)
return Response.json(data);
}
// Problems:
// - Business rules scattered across codebase
// - No single source of truth for validation
// - Hard to test
// - Difficult to change
Preventing Unwanted Cross-Service Communication (Between Apps)
Reality check. Tuturuuu apps do not communicate exclusively through
published events, and there is no broker-level event ACL. The current primary
inter-app pattern is a shared Supabase (PostgreSQL) database with Row-Level
Security, complemented by Trigger.dev v4 tasks for asynchronous/background
work. This matches the
Microservices Patterns
page, which lists “Shared Database (Current)” as the live pattern. The
event-as-sole-interface material below is therefore an aspirational pattern
showing the encapsulation benefits a fully event-driven design would provide —
not the way the apps interact today.
1. Background Tasks as a Decoupling Boundary
When one app needs to kick off work in another context without a synchronous
dependency, Tuturuuu uses Trigger.dev v4 task() definitions in
packages/trigger/src. A trigger payload acts as a small, explicit contract: the
caller passes only what the task needs, and the task does not reach back into the
caller’s internals.
Architectural Principle: Cross-context work is dispatched via explicit task payloads, not by calling another app’s private functions.
Real example (Trigger.dev v4):
// packages/trigger/src/schedule-tasks.ts (real, abridged)
import { task } from '@trigger.dev/sdk/v3';
import { schedulableTasksHelper } from './schedule-tasks-helper';
export const scheduleTask = task({
id: 'schedule-task',
queue: { concurrencyLimit: 10 },
// The payload is the entire contract this task depends on.
run: async (payload: { ws_id: string }) => {
const result = await schedulableTasksHelper(payload.ws_id);
if (!result.success) {
throw new Error(result.error || 'Schedule tasks failed');
}
return { ws_id: payload.ws_id, ...result, success: true };
},
});
// Dispatching the task from app code (Trigger.dev v4):
import { scheduleTask } from '@tuturuuu/trigger';
// Fire-and-forget; returns a run handle, does not block on completion.
await scheduleTask.trigger({ ws_id: workspaceId });
The import path is @trigger.dev/sdk/v3 even though the installed package is
@trigger.dev/sdk@^4.4.5 — the /v3 subpath is the v4 SDK’s stable entrypoint
for task(). The Trigger.dev v2 APIs referenced in older drafts
(client.defineJob, eventTrigger, io.runTask, trigger.event) do not
exist in this repo.
2. (Aspirational) Published Event Contracts as the Sole Interface
In a fully event-driven design, the only way services interact is through the
events they publish; internal implementation, database schema, and private
functions stay completely hidden. Tuturuuu does not implement this today
(apps share a database), but the pattern is the strongest form of cross-service
encapsulation and is worth understanding.
Architectural Principle: Services communicate only via well-defined event contracts.
Aspirational example (expressed in real Trigger.dev v4 syntax):
// Producer side: instead of a v2 trigger.event(...) broadcast,
// dispatch a typed task with an explicit payload contract.
import { setupAiWorkspaceTask } from '@tuturuuu/trigger';
export async function createWorkspace(data: CreateWorkspaceData) {
const supabase = await createClient(); // async client — await it
const { data: workspace } = await supabase
.from('workspaces')
.insert(data)
.select()
.single();
// Public contract = the task payload shape, nothing more.
await setupAiWorkspaceTask.trigger({
workspaceId: workspace.id,
ownerId: workspace.owner_id,
});
}
// Consumer side (Trigger.dev v4): a task() definition, not client.defineJob.
import { task } from '@trigger.dev/sdk/v3';
export const setupAiWorkspaceTask = task({
id: 'setup-ai-workspace',
run: async (payload: { workspaceId: string; ownerId: string }) => {
// Only knows about the payload contract, not the producer's internals.
return await createAIConfig(payload);
},
});
Event Schema Governance (aspirational):
// Illustrative: e.g. packages/types/src/events/workspace.ts (no such file today)
import { z } from 'zod';
// Versioned schemas keep payload contracts backward compatible.
export const WorkspaceCreatedEventV1 = z.object({
workspaceId: z.string().uuid(),
ownerId: z.string().uuid(),
name: z.string().min(1).max(100),
createdAt: z.string().datetime()
});
export const WorkspaceCreatedEventV2 = z.object({
workspaceId: z.string().uuid(),
ownerId: z.string().uuid(),
name: z.string().min(1).max(100),
createdAt: z.string().datetime(),
// New optional fields (backward compatible)
description: z.string().optional(),
plan: z.enum(['free', 'pro', 'enterprise']).optional()
});
export type WorkspaceCreatedEventV1 = z.infer<typeof WorkspaceCreatedEventV1>;
export type WorkspaceCreatedEventV2 = z.infer<typeof WorkspaceCreatedEventV2>;
Benefits:
- Complete encapsulation of service internals
- Clear API contract via event schemas
- Versioning support for evolution
- Independent deployment of services
- No direct dependencies between services
Clarifying Additions: Clients remain simple and unaffected by internal changes. Service boundaries stay intact because external access is tightly controlled. This enforces a clean separation between client-facing and internal concerns.
Anti-pattern to avoid:
// BAD: Direct service-to-service HTTP calls
export async function createWorkspace(data: CreateWorkspaceData) {
const workspace = await db.workspaces.insert(data);
// WRONG: Direct HTTP call to another service
await fetch('http://rewise-service/api/setup', {
method: 'POST',
body: JSON.stringify({ workspaceId: workspace.id })
});
// Problems:
// - Tight coupling to rewise-service
// - Must know its URL and API
// - Synchronous dependency (cascading failures)
// - Hard to add new services
}
3. (Hypothetical) Broker-Level Access Control (ACLs)
Hypothetical pattern — not implemented. There is no message broker and
no event ACL system in Tuturuuu. The file packages/trigger/src/events/acl.ts
and the EVENT_PRODUCERS/EVENT_CONSUMERS maps below do not exist —
packages/trigger/src contains task definitions (schedule-tasks.ts,
google-calendar-sync.ts, etc.), not an ACL layer. This section is kept only to
illustrate what infrastructure-enforced boundaries would look like if Tuturuuu
moved to a true event-driven broker.
In a broker-based design you would not rely on trust; the broker itself enforces
communication boundaries. Using ACLs you could declare that only one service may
produce user.registered events, and only specific downstream services may
consume them.
Architectural Principle: Enforce service boundaries at the infrastructure level, not just by convention.
Hypothetical example (does not reflect repo code):
// Hypothetical: packages/trigger/src/events/acl.ts (no such file exists)
export const EVENT_PRODUCERS = {
'user.registered': ['apps/web'],
'workspace.created': ['apps/web'],
'payment.processed': ['apps/web', 'apps/finance'],
'ai.chat.message': ['apps/rewise'],
'task.created': ['apps/tasks']
} as const;
export const EVENT_CONSUMERS = {
'user.registered': ['apps/rewise', 'apps/web', 'analytics-service'],
'workspace.created': ['apps/rewise', 'apps/nova', 'apps/finance'],
'payment.processed': ['apps/web', 'billing-service'],
'ai.chat.message': ['apps/web'],
'task.created': ['apps/web', 'apps/calendar']
} as const;
// Validation middleware
export function validateEventProducer(
eventName: string,
serviceId: string
): boolean {
const allowedProducers = EVENT_PRODUCERS[eventName];
if (!allowedProducers) {
throw new Error(`Unknown event: ${eventName}`);
}
if (!allowedProducers.includes(serviceId)) {
throw new Error(
`Service ${serviceId} not allowed to produce ${eventName}`
);
}
return true;
}
Benefits:
- Enforced boundaries at infrastructure level
- Security - services can’t spoof events from other services
- Audit trail - know exactly which services produce/consume events
- Documentation - ACL config documents allowed communication
Clarifying Additions: This ensures services evolve internally without breaking others. External dependencies rely on stable contracts rather than hidden details. This strengthens overall modularity across the architecture.
4. Elimination of Synchronous Coupling
Dispatching background work asynchronously avoids direct, synchronous calls
between contexts for slow or non-critical flows. This prevents the tight coupling
that arises when one caller must know the network location, API signature, and
live availability of another. Tuturuuu applies this with Trigger.dev v4 tasks:
the request returns immediately while follow-up work runs out of band.
Architectural Principle: Don’t block a user request on slow, non-critical downstream work.
Async flow (real Trigger.dev v4 syntax):
import { createAdminClient } from '@tuturuuu/supabase/next/server';
import { setupUserAiTask, sendWelcomeEmailTask } from '@tuturuuu/trigger';
// apps/web: User registers
export async function registerUser(data: UserRegistrationData) {
const supabase = createAdminClient(); // SYNC factory — do NOT await it
const user = await createUser(supabase, data);
// Dispatch background tasks (non-blocking); each returns a run handle.
await setupUserAiTask.trigger({ userId: user.id });
await sendWelcomeEmailTask.trigger({ email: user.email });
return user; // Returns immediately
}
// packages/trigger/src: each consumer is a task() definition.
import { task } from '@trigger.dev/sdk/v3';
export const setupUserAiTask = task({
id: 'setup-user-ai',
run: async (payload: { userId: string }) => {
return await createAIProfile(payload.userId);
},
});
export const sendWelcomeEmailTask = task({
id: 'send-welcome-email',
run: async (payload: { email: string }) => {
return await sendEmail(payload.email, 'welcome');
},
});
// Each task runs independently; registration never blocks on them.
Benefits:
- No cascading failures
- Independent availability of services
- Faster user responses
- Easier to add new services
Clarifying Additions: Controlled communication prevents accidental or unauthorized interactions. Service boundaries stay clear as the system grows. This improves safety and consistency in distributed communication.
Anti-pattern to avoid:
// BAD: Synchronous service-to-service calls
export async function registerUser(data: UserRegistrationData) {
const user = await createUser(data);
// WRONG: Blocking calls to other services
await rewiseService.setupAI(user.id); // 2 seconds
await emailService.sendWelcome(user.email); // 1 second
await analyticsService.track(user.id); // 1 second
return user; // User waits 4+ seconds
}
// Problems:
// - User waits for all services
// - If rewise service is down, registration fails
// - Tight coupling to multiple services
Encapsulation in Practice
Workspace Isolation via RLS (real)
This is Tuturuuu’s actual primary encapsulation mechanism: because apps share one
Supabase database, Row-Level Security — defined by migrations under
apps/database/supabase/migrations/ — keeps every app inside its permitted data
scope at the database level, regardless of which app issues the query.
-- apps/database/supabase/migrations/
-- Row-Level Security ensures data encapsulation at database level
CREATE POLICY "Users can only see their workspaces"
ON workspaces
FOR SELECT
USING (
id IN (
SELECT workspace_id
FROM workspace_members
WHERE user_id = auth.uid()
)
);
-- Services cannot access data outside their scope
-- Enforced at database level, not application level
Package Boundaries (real)
Apps depend on shared workspace:* packages and never import another app’s
internals. (Add or update these with scoped installs, e.g.
cd apps/web && bun add <pkg> — never bun add --workspace.)
// apps/web/package.json (illustrative subset)
{
"dependencies": {
"@tuturuuu/ui": "workspace:*", // ✅ Allowed
"@tuturuuu/types": "workspace:*", // ✅ Allowed
"@tuturuuu/supabase": "workspace:*" // ✅ Allowed
// ❌ Do not import from apps/rewise or apps/finance directly
}
}
Summary
Status legend: real = implemented today; illustrative/aspirational =
teaching pattern not implemented in the repo.
| Encapsulation Type | Pattern | Status | Enforced By | Benefit |
|---|
| Cross-Layer (Internal) | Dependency Inversion / ports | Illustrative | Hexagonal layering (not adopted) | Testable, flexible |
| Cross-Layer (Internal) | DTOs | Illustrative | Layer boundaries | Stable APIs |
| Cross-Layer (Internal) | Layered Responsibility | Illustrative | Directory structure (apps/web is flat) | Clear organization |
| Cross-Layer (Internal) | Row-Level Security | Real | Supabase RLS policies | Data scoping at the DB |
| Cross-Layer (Internal) | Package boundaries | Real | workspace:* deps + Biome/import rules | No cross-app imports |
| Cross-Service (External) | Background task payloads | Real | Trigger.dev v4 task() | Async decoupling |
| Cross-Service (External) | Event Contracts as sole interface | Aspirational | Event schemas | Service autonomy |
| Cross-Service (External) | Broker ACLs | Hypothetical | Broker config (no broker today) | Security, governance |
| Cross-Service (External) | Async background work | Real | Trigger.dev v4 tasks | No cascading failures |