Application architecture
Transport adapters, command/query handlers, permission gates, transactions, audit, repositories, providers, and failure boundaries.
The application layer is the shared use-case boundary between delivery mechanisms and infrastructure. A server action, REST route, or MCP tool may authenticate differently, but it must not grow its own version of validation, permission-resource construction, or persistence rules.
Request-to-effect path
Caption: transports resolve authentication and map typed results; handlers own use-case semantics and can() gates; repositories own PostgreSQL writes and command-side audit. emitMutationCommitted() is the stronger mutation + outbox transaction for capabilities that have adopted it. It is not yet the producer path for every existing worker job, so delivery semantics must be described per call site.
Responsibilities by layer
| Layer | Owns | Must not own | Source anchor |
|---|---|---|---|
| Transport | Session/OAuth scope, request parsing, localized labels, HTTP/status mapping, revalidatePath, transport metadata |
A second authorization or persistence implementation | apps/web/src/lib/actions/tasks.ts, apps/web/app/api/v1/tasks/route.ts, apps/mcp-server/src/mcp.ts |
| Command handler | Normalization required by the use case, can() calls, permission-resource construction, race-to-typed-outcome mapping, injected side effects |
Next.js, cookies, React, concrete provider construction | packages/application/src/commands/shared.ts, packages/application/src/commands/tasks.ts, packages/application/src/commands/matters.ts |
| Query handler | Permission-filtered projections, not_found versus forbidden, list filtering |
Transport DTO/status mapping | packages/application/src/queries/tasks.ts, packages/application/src/queries/list/runner.ts |
| Repository | Drizzle/PostgreSQL operations, row locks, transaction-scoped audit | Request/session concerns | packages/db/src/repos/tasks.ts, packages/db/src/client.ts |
| Provider port | External capability behind an interface; deterministic substitute in tests | Domain authorization or tenant selection | packages/integrations/src/contract.ts, packages/integrations/src/factory.ts |
Command and query contracts
Commands receive a CommandContext containing a tenant handle, actor, and policy. They return discriminated unions such as created, invalid, forbidden, and not_found; genuine infrastructure faults still throw. Queries reuse the same context rather than introducing a near-identical read context.
The task handlers demonstrate two security-sensitive distinctions:
getTaskQuery()refuses an individually unreadable record withforbidden.listTasksQuery()filters unreadable rows and excludes them fromtotal; a list must not leak the existence or count of hidden records.- Matter-derived department and access-level scope is constructed once in the application query and reused by task commands.
- A selected relation or matter ID is input, not authority; handlers reload it through the readable/active boundary before use.
Transaction, audit, and outbox boundaries
withTenantTransaction() pins one connection, rolls back on throw, and joins an existing transaction instead of nesting BEGIN/COMMIT. Repositories such as createTask() mutate and append audit in that same boundary. commitMutationWithOutbox() adds a pending job row atomically and derives an idempotency key from capability, resource, and version. The dispatcher performs queue I/O after commit, with at-least-once delivery and inbox deduplication; it never promises exactly-once execution.
Provider or index calls made directly after a repository commit are a separate failure boundary: a search outage can leave committed source data requiring reconciliation. Do not imply that such an external call rolls the database mutation back.
Invariants
- Transport authentication selects an actor; only the shared
can()decision authorizes the resource action. - Permission-resource construction belongs with the use case, not in every transport.
- A business mutation and its command-side audit either commit together or roll back together.
- Provider and queue network I/O never runs while the transaction that created an outbox row is open.
- Lists filter hidden rows; get-by-id endpoints return a structured refusal.
- Typed business outcomes cross the application boundary; unexpected infrastructure faults remain observable failures.
Failure modes to design for
| Failure | Expected behavior | Evidence |
|---|---|---|
| Forged or stale related-record ID | Reload through the active/readable boundary and return invalid or not_found |
packages/application/src/commands/matters.ts |
| Parent matter deleted between gate and insert | Repository lock/check fails; command maps the known race to not_found |
packages/application/src/commands/tasks.ts, packages/db/src/repos/tasks.ts |
| Transaction throws after mutation or audit | Whole pinned transaction rolls back | packages/db/src/client.ts, packages/application/src/events/mutation-event.test.ts |
| Queue publish fails | Pending outbox remains retryable or becomes dead after the configured threshold |
packages/db/src/repos/outbox.ts |
| Post-commit index call fails | Source-of-truth row remains committed; reconciliation/retry must repair the derived index | packages/application/src/commands/tasks.ts, apps/workers/src/jobs/matter-search-reconcile.ts |
Focused validation
pnpm --filter @blinqx/application test -- --maxWorkers=2
pnpm --filter @blinqx/application typecheck
pnpm --filter @blinqx/db test -- --maxWorkers=2
Source anchors
- Application contracts:
packages/application/src/commands/shared.ts,packages/application/src/commands/tasks.ts,packages/application/src/queries/tasks.ts - Transport adapters:
apps/web/src/lib/actions/tasks.ts,apps/web/app/api/v1/tasks/route.ts,apps/mcp-server/src/mcp.ts - Transactions and audit:
packages/db/src/client.ts,packages/db/src/repos/tasks.ts - Transactional events:
packages/application/src/events/mutation-event.ts,packages/db/src/repos/outbox.ts
See also: Architecture · Tenant isolation · Workers and integrations