Architecture and layer boundaries
When to use: Layering and boundaries, web vs public API, app layout (clients, routes, logging), ports/adapters, runtime-portable domain/shared/utils code, multi-tenancy, DDD layout, or anti-patterns.
App boundaries ()
Apps only handle:
- Input validation
- Authentication and authorization
- Organization access enforcement
- Routing to domain use-cases
No business logic in handlers, controllers, or jobs.
Application layout ()
- Clients: Initialize integrations in and import from boundaries — avoid scattering raw clients.
- Routes: Use with a (or equivalent) pattern so the HTTP surface stays modular.
- Logging: Use from with a stable service name per app.
- Tracing: Every call site must include from in the pipe chain to connect Effect spans to the OTel pipeline. See effect-and-errors for the full tracing rules.
- Configuration values: Read env through / — see env-configuration.
Web vs public API ( and )
- is the stable public API surface. Treat its routes/contracts as externally consumed and evolve them carefully.
- must not call or proxy through for internal product features.
- For web product development, implement backend behavior in server functions by composing domain use-cases and platform adapters directly.
- Keep iteration velocity in by adding web-private server functions/stores while preserving stability.
- Shared business rules still belong in domain packages; and should both orchestrate domain use-cases rather than duplicating policy.
- Latitude product capabilities should be equally accessible to humans through the web UI and to other LLM agents through MCP/API surfaces.
- Do not dead-end product behavior into UI-only flows. Preserve the boundary rules above, but design schemas, use-cases, and public capabilities so machine-facing access can exist without redesign.
- For the concrete recipe — , factories, / , schema-description rules that fan out to the TS SDK and MCP tools — see api-endpoints.
Cross-cutting implementation constraints
- Public request/response schemas should remain boundary-specific; they may reuse shared domain schemas or narrower projections rather than forcing full domain entities onto every surface.
- When a capability is part of the product contract, preserve a machine-facing MCP/API surface instead of making it web-only.
Domain layer ()
Business logic lives here. Domain packages expose:
- Use-cases
- Canonical entity schemas and inferred entity types
- Domain types and errors
- Dependency ports (interfaces/tags)
Domain package layout
Domain
entities are
Zod-first:
+
z.infer<typeof entitySchema>
in
. See
dev-docs/domain-entities.md
and
docs/adr/0001-domain-entity-schema-style.md
.
- Treat canonical domain entity schemas as the source of truth. Schemas and types elsewhere in the same domain, plus app/platform boundary schemas, should derive from or reuse the entity shapes whenever practical instead of re-declaring the same fields.
- When a boundary schema must differ materially from the entity shape, still reuse the relevant domain constants, field schemas, and literal unions rather than hardcoding duplicated lengths or sentinel values again.
- Canonical entity schemas and their inferred entity types belong in
packages/domain/*/src/entities/<entity>.ts
.
- Domain package constants belong in
packages/domain/*/src/constants.ts
.
- Domain package errors belong in
packages/domain/*/src/errors.ts
. A full package-by-package inventory and import rules live in dev-docs/domain-errors.md
.
- For how to structure those errors (tagged classes, HTTP fields, unions per flow, naming), treat as the reference: see
packages/domain/issues/src/errors.ts
and the section Domain errors ( reference pattern) in .
- Small domain-scoped shared helpers such as predicates or lifecycle helpers belong in
packages/domain/*/src/helpers.ts
.
- Types and schemas that exist only as inputs to one domain use-case belong in that use-case file rather than a generic side module, unless several use-cases truly share the exact same contract.
- App and platform layers should build boundary-specific schemas by reusing or deriving from domain entity/use-case schemas whenever practical rather than redefining the same contract from scratch.
Infrastructure ()
Infrastructure details live here only. Platform packages implement adapters for domain ports.
Platform adapters: Effect-based clients
Reference implementation: packages/platform/db-weaviate/src/client.ts
—
createWeaviateClientEffect
(and the thin
wrapper used by scripts).
Use this pattern when a platform package owns an external SDK client so composition roots can stay in Effect and errors stay typed.
- Primary constructor is an Effect — Export
createXClientEffect(...): Effect.Effect<Client, E, never>
(or with requirements if unavoidable). Scripts and one-off CLIs may export async function createXClient()
as Effect.runPromise(createXClientEffect(...))
only at the boundary that needs promises.
- Typed errors — Model connection, validation, and bootstrap failures with (or shared env errors from ). Union them into a single (or similar) exported next to the constructor.
- Configuration — Resolve settings with / from inside the Effect pipeline, not ad hoc reads scattered outside the client module.
- Interop — Wrap promise-based SDK calls in and map failures to tagged errors. Compose steps with , , and .
- Bootstrap in the pipeline — If the client must apply schema/migrations/health checks before use, run those as Effects in the same pipeline (see Weaviate:
migrateWeaviateCollectionsEffect
after connect) so callers get a ready client or a single error channel.
- Live layers — Expose a thin
XClientLive(client, scope...)
layer for the external SDK client and keep repository adapters as or values that depend on that client service as needed. The composition root acquires the client with and provides it via a small helper when useful, for example withWeaviate(IssueProjectionRepositoryLive, client, organizationId)
.
Not every legacy adapter has been migrated; prefer this shape for new work and when touching client construction.
Shared utilities ()
General-purpose utility functions that can be shared across any package (domain, platform, or app) live in
. This package should contain pure, stateless helper functions with no domain or infrastructure dependencies.
Examples:
,
, string helpers, number formatters.
When writing a utility function that is not specific to a single domain or package, place it in
instead of keeping it local.
Shared domain vs utils
and
have different responsibilities and should not be merged.
- Use for domain-level shared contracts, types, errors, and IDs used across bounded contexts.
- Use for global pure, stateless helpers that are reusable anywhere.
- If a helper has domain/business meaning, it belongs in ; otherwise, use .
Ports and adapters
- Domain depends on interfaces/tags only (ports like , , )
- Platform packages implement adapters
- Composition roots in apps provide live layers
- Domain must never import concrete DB/cache/queue/object storage clients
- Repository method names: Use the standard verbs in dev-docs/repositories.md (, for unique keys, / for collections, , vs , etc.).
- Reliability async contracts should stay project-scoped as well as organization-scoped: include both and in event/task/workflow payloads by default (except , , , , and payloads).
Web standards first (domain, utils, shared)
In
,
,
, or any code that may run outside Node (browser, edge, isolates), prefer
Web Standard APIs over Node-only modules so those layers stay portable.
- Use / instead of
- Use instead of Node-specific HTTP clients
- Use / instead of
- Use for binary data in public interfaces
- Use instead of / streams
- Use , , , , from the global scope
- Use instead of JSON round-trips for deep cloning
Node-only APIs are acceptable in build tooling, scripts, CLI utilities, and test infrastructure. If you need Node outside those scopes, add a brief comment explaining why.
Data and infrastructure (overview)
- Postgres: Control-plane and relational data (users, organizations, memberships, config)
- ClickHouse: High-volume telemetry storage and analytical reads
- Weaviate: Vector database for embeddings storage and semantic similarity search
- Redis: Cache and BullMQ backend
- Object storage: Durable raw ingest payload buffering
For access patterns, schema, and migrations, see database-postgres and database-clickhouse-weaviate.
Multi-tenancy
- Every request is organization-scoped
- A user may belong to many organizations
- Organization membership checks happen at boundaries before domain execution
- All telemetry persistence and query paths include
- Organization-scoped Redis or cache keys must start with
org:${organizationId}:...
; keep the org id first in the key
Domain design (DDD)
- Organize by bounded context (e.g. telemetry, organizations, identity, alerts)
- Domains should be single-responsibility and focused on policy/rules
- Use in-memory adapters for fast tests where possible
Anti-patterns to reject
- Cross-domain logic without clear ownership
- New provider integrations without a core capability contract
- Introducing application env vars without the prefix (see env-configuration)
- Using or directives — these are Next.js-specific; the web app uses TanStack Start
- Exporting test utilities from a package's main entry point (see testing)