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:
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:
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:
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:

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):
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):
Illustrative example with clear layer responsibilities:
Layer Communication Rules: 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:

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):
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):
Event Schema Governance (aspirational):
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:

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):
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):
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:

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.

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.)

Summary

Status legend: real = implemented today; illustrative/aspirational = teaching pattern not implemented in the repo.