Skip to main content
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).
For a comprehensive comparison of how different architectural patterns handle encapsulation, see Architectural Patterns Comparison.
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 LayerCan CallCannot CallReason
PresentationApplicationDomain directlyBypass orchestration
ApplicationDomain, InfrastructurePresentationCircular dependency
DomainNothingApplication, Infrastructure, PresentationPure logic
InfrastructureDomain (via interfaces)Application, PresentationImplementation 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 existpackages/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 TypePatternStatusEnforced ByBenefit
Cross-Layer (Internal)Dependency Inversion / portsIllustrativeHexagonal layering (not adopted)Testable, flexible
Cross-Layer (Internal)DTOsIllustrativeLayer boundariesStable APIs
Cross-Layer (Internal)Layered ResponsibilityIllustrativeDirectory structure (apps/web is flat)Clear organization
Cross-Layer (Internal)Row-Level SecurityRealSupabase RLS policiesData scoping at the DB
Cross-Layer (Internal)Package boundariesRealworkspace:* deps + Biome/import rulesNo cross-app imports
Cross-Service (External)Background task payloadsRealTrigger.dev v4 task()Async decoupling
Cross-Service (External)Event Contracts as sole interfaceAspirationalEvent schemasService autonomy
Cross-Service (External)Broker ACLsHypotheticalBroker config (no broker today)Security, governance
Cross-Service (External)Async background workRealTrigger.dev v4 tasksNo cascading failures