architecture-boundaries
GitHub规范应用架构分层与边界,定义客户端、路由及日志配置,明确Web与公共API的职责分离,遵循端口适配器模式与DDD布局。
Trigger Scenarios
Install
npx skills add latitude-dev/latitude-llm --skill architecture-boundaries -g -y
SKILL.md
Frontmatter
{
"name": "architecture-boundaries",
"description": "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."
}
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/*)
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 (apps/*)
- Clients: Initialize integrations in
apps/*/clients.tsand import from boundaries — avoid scattering raw clients. - Routes: Use
apps/*/routes/with aregisterRoutes()(or equivalent) pattern so the HTTP surface stays modular. - Logging: Use
createLogger()from@repo/observabilitywith a stable service name per app. - Tracing: Every
Effect.runPromisecall site must includewithTracingfrom@repo/observabilityin 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
parseEnv/parseEnvOptional— see env-configuration.
Web vs public API (apps/web, apps/api, @repo/operations)
- The public API's operation definitions (route config + transport-neutral
executelogic) live inpackages/operations(@repo/operations) — the boundary-contract layer between apps and domain. One definition fans out to the HTTP route, OpenAPI, MCP tool, SDK methods, CLI command, and in-process agent tools. apps/apiis the transport shell: middleware (auth, org context, rate limiting), the MCP HTTP transport, mountingoperationModules, and the manifest emit scripts. Treat the operation contracts as externally consumed and evolve them carefully.@repo/operationssits above domain: operations validate input, map to public schemas, and orchestrate@domain/*use-cases — the same boundary responsibilities apps own, factored into a package so non-HTTP consumers (worker-side agents) can runexecutein-process.apps/webmust not call or proxy throughapps/apifor internal product features.- For web product development, implement backend behavior in
apps/webserver functions by composing domain use-cases and platform adapters directly. - Keep iteration velocity in
apps/webby adding web-private server functions/stores while preservingapps/apistability. - Shared business rules still belong in domain packages;
apps/weband@repo/operationsshould 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 —
defineOperation,OperationModulemanifests,group/sdkMethod/access/rateLimitTier,pnpm openapi:emit/pnpm mcp:emit, schema-description rules that fan out to the TS + Python SDKs, MCP tools, and thelatitudeCLI, the required declarativeaccessfield, anddefineToolset(with its access ceiling) for internal agents — 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 (packages/domain/*)
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: entitySchema + z.infer<typeof entitySchema> in src/entities/<entity>.ts. 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 indev-docs/domain-errors.md. - For how to structure those errors (tagged classes, HTTP fields, unions per flow, naming), treat
packages/domain/issuesas the reference: seepackages/domain/issues/src/errors.tsand the section Domain errors (@domain/issuesreference pattern) indev-docs/issues.md. - 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 (packages/platform/*)
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 createWeaviateClient 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 requirementsRif unavoidable). Scripts and one-off CLIs may exportasync function createXClient()asEffect.runPromise(createXClientEffect(...))only at the boundary that needs promises. - Typed errors — Model connection, validation, and bootstrap failures with
Data.TaggedError(or shared env errors from@platform/env). Union them into a singleCreateXClientError(or similar) exported next to the constructor. - Configuration — Resolve settings with
parseEnv/parseEnvOptionalfrom@platform/envinside the Effect pipeline, not ad hocprocess.envreads scattered outside the client module. - Interop — Wrap promise-based SDK calls in
Effect.tryPromiseand map failures to tagged errors. Compose steps withEffect.pipe,Effect.flatMap, andEffect.map. - 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:
migrateWeaviateCollectionsEffectafter 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 asLayer.effectorLayer.succeedvalues that depend on that client service as needed. The composition root acquires the client withcreateXClientEffectand provides it via a small helper when useful, for examplewithWeaviate(IssueProjectionRepositoryLive, client, organizationId).
Not every legacy adapter has been migrated; prefer this shape for new work and when touching client construction.
Shared utilities (packages/utils)
General-purpose utility functions that can be shared across any package (domain, platform, or app) live in @repo/utils. This package should contain pure, stateless helper functions with no domain or infrastructure dependencies.
Examples: formatCount, formatPrice, string helpers, number formatters.
When writing a utility function that is not specific to a single domain or package, place it in @repo/utils instead of keeping it local.
Shared domain vs utils
@domain/shared and @repo/utils have different responsibilities and should not be merged.
- Use
@domain/sharedfor domain-level shared contracts, types, errors, and IDs used across bounded contexts. - Use
@repo/utilsfor global pure, stateless helpers that are reusable anywhere. - If a helper has domain/business meaning, it belongs in
@domain/shared; otherwise, use@repo/utils.
Ports and adapters
- Domain depends on interfaces/tags only (ports like
Repository,CacheStore,Publisher) - 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 (
findById,findByXxxfor unique keys,listByXxx/listfor collections,save,deletevssoftDelete, etc.). - Reliability async contracts should stay project-scoped as well as organization-scoped: include both
organizationIdandprojectIdin event/task/workflow payloads by default (exceptMagicLinkEmailRequested,UserDeletionRequested,domain-events,magic-link-email, anduser-deletionpayloads).
Web standards first (domain, utils, shared)
In packages/domain/*, packages/utils, @domain/shared, 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
crypto.subtle/crypto.getRandomValuesinstead ofnode:crypto - Use
fetchinstead of Node-specific HTTP clients - Use
TextEncoder/TextDecoderinstead ofBuffer.from(…, 'utf-8') - Use
Uint8Arrayfor binary data in public interfaces - Use
ReadableStreaminstead ofnode:stream/node:fsstreams - Use
URL,URLSearchParams,Headers,Request,Responsefrom the global scope - Use
structuredCloneinstead 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
organizationId - 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
LAT_prefix (see env-configuration) - Using
"use client"or"use server"directives — these are Next.js-specific; the web app uses TanStack Start - Exporting test utilities from a package's main entry point (see testing)
Version History
- 2479822 Current 2026-08-20 10:35


