@beignet/core
Version:
Core framework primitives for Beignet
323 lines (268 loc) • 14.7 kB
Markdown
---
name: app-architecture
description: "Build Beignet app feature slices with @beignet/core primitives: schemas, contracts, use cases, agent capabilities, app errors, ports, policies, TenantScope, context, providers, runtime integrity, domain events, listeners, jobs, schedules, tasks, notifications, outbox, mail, uploads, seeds, tests, auth helpers, audit, idempotency, storage, cache, rate limits, flags, entitlements, payments, search, webhooks, and explicit core subpath imports. Use when adding or fixing Beignet application behavior, dependency boundaries, tenant-owned repository access, app-facing ports, authorization, workflow primitives, agent entrypoints, or app-bound lib builders."
---
# Beignet App Architecture
Use this skill when working in an app that depends on `@beignet/core`.
Read the app's `AGENTS.md`, `beignet.config.*`, and closest existing feature
slice before changing code.
## Source Order
1. Follow local app instructions and configured paths.
2. Reuse the nearest feature slice's naming and helper exports.
3. Inspect installed `@beignet/core/*` exports and types when an API is unclear.
4. Read narrow Beignet docs only for the concept being changed.
## Feature Slice
Normal feature code belongs here:
```txt
features/<feature>/
contracts.ts
schemas.ts
routes.ts
agent-capabilities.ts
use-cases/
domain/
ports.ts
policy.ts
client/
components/
jobs/
listeners/
notifications/
schedules/
tasks/
uploads/
seeds/
tests/
factories/
```
Small features do not need every file. The starter ships a lean subset;
workflow folders, `lib/` builders, and central registries appear when a
generator or deliberate feature change introduces that concern.
Bind route declarations to the app context once in `lib/routes.ts` with
`createRoutes<AppContext>()`. Feature route files should import the resulting
`defineRoute` or `defineRouteGroup` helper instead of binding `AppContext`
themselves.
## Core Boundaries
- Contracts and schemas must be import-safe for clients and OpenAPI.
- Use cases own business workflows and depend on `ctx.ports`, not infra.
- Domain and use-case code must not import React, routes, providers, server
runtime, or concrete adapters.
- Infra implements ports and may import provider packages, SDKs, and database
clients.
- UI and feature client modules call server behavior through contracts and a
typed client, not by importing use cases.
- Server-only request context helpers may bridge Server Components to
Beignet's request-scoped `AppContext`. Keep them outside `client/`, usually
in `lib/server-context.ts`, and mark them with `@beignet/core/server-only`.
The context factory receives `requestInfo` from the server's explicit
`trustedProxy` policy; store it on request contexts when layouts need the
external URL, origin, host, protocol, or client IP. Layouts may read that
metadata with `ctx.auth` and `ctx.tenant` for redirects and shell state;
feature data still belongs behind use cases.
- Server-only React Query prefetch helpers may consume that context for
hydrated client state. Use them only for direct `{ contract, useCase }` route
bindings whose use-case output matches the contract success body.
- Server, provider, and infra code may import `@beignet/core/server-only`;
browser-reachable client helpers may import `@beignet/core/client-only` as
lint markers when the app uses them.
## Contracts And Schemas
Use `@beignet/core/contracts` and keep shared DTOs in `schemas.ts`.
```ts
import { defineContractGroup } from "@beignet/core/contracts";
import { z } from "zod";
import { errors } from "@/features/shared/errors";
export const projectSchema = z.object({
id: z.uuid(),
name: z.string().min(1),
});
const projects = defineContractGroup()
.namespace("projects")
.prefix("/api/projects");
export const getProject = projects
.get("/:id")
.pathParams(z.object({ id: z.uuid() }))
.errors({ ProjectNotFound: errors.ProjectNotFound })
.responses({ 200: projectSchema });
```
If a route-backed use case throws `appError("ProjectNotFound")`, the matching
contract should declare `ProjectNotFound` with `.errors(...)`.
## Use Cases
Use the app-bound `useCase` builder from `lib/use-case.ts`.
```ts
import { requireUserId } from "@beignet/core/ports";
import { appError } from "@/features/shared/errors";
import { projectIdInputSchema, projectSchema } from "@/features/projects/schemas";
import { useCase } from "@/lib/use-case";
export const getProjectUseCase = useCase
.query("projects.get")
.input(projectIdInputSchema)
.output(projectSchema)
.run(async ({ ctx, input }) => {
const userId = requireUserId(ctx);
const project = await ctx.ports.projects.findById(input.id);
if (!project) throw appError("ProjectNotFound", { details: input });
await ctx.gate.authorize("projects.read", project);
return project;
});
```
Use `ctx.ports.uow.transaction(...)` when writes, audit entries, events, jobs,
or outbox records must commit together.
## Errors And Policies
- Generated apps usually place the error catalog in `features/shared/errors.ts`.
Some apps centralize it elsewhere; reuse the existing catalog.
- Business authorization belongs in use cases or feature policies, not only
route metadata.
- Put a policy in the feature that owns the authorized resource. Cross-feature
abilities may still live there when they inspect that resource.
## Ports And Providers
- Feature-specific ports usually live in `features/<feature>/ports.ts`.
- App-wide ports live in `ports/`.
- `ports/index.ts` defines the completed `AppPorts` and transaction-port types.
- `infra/port-wiring.ts` exports `initialPorts`, the app-owned values and
deferred keys passed into server startup.
- `bound` ports are app-owned at boot, such as gates, config, clocks, IDs, or
no-op reporters.
- `deferred` ports are supplied by providers at startup, such as auth,
database, mail, logger, jobs, repositories, idempotency, rate limits,
storage, search, payments, flags, locks, and error reporting.
Do not import provider packages from contracts, use cases, routes, UI, or
feature client modules.
Prefer typed options objects for provider factories. Explicit first-party
options override matching environment values. Treat injected SDK or database
clients as caller-owned unless an option such as `closeOnStop: true` delegates
shutdown to one provider instance, and preserve provider instrumentation when
using a lower-level direct adapter.
Tenant-owned repository methods should accept `TenantScope` instead of raw
tenant IDs. Use cases derive it with `requireTenantScope(ctx)` or
`createTenantScope(...)`; adapters unwrap it with `tenantScopeId(scope)` and
include that storage ID in read, write, update, and delete predicates.
Provider-correlation lookups such as webhook customer IDs are not tenant
authorization by themselves.
Resolve request tenants only from membership-backed app state or provider
claims that the app explicitly treats as authoritative and current. Keep that
decision behind an app-owned resolver. Never turn caller-supplied tenant IDs or
unverified session fields directly into `TenantContext`.
## Workflows
Feature-owned workflow definitions belong under the feature:
```txt
features/<feature>/domain/events/
features/<feature>/jobs/
features/<feature>/listeners/
features/<feature>/schedules/
features/<feature>/notifications/
features/<feature>/tasks/
features/<feature>/uploads/
features/<feature>/seeds/
```
Small existing apps may use feature-root `jobs.ts` or `schedules.ts`; follow
local style unless migrating deliberately. Central registries and service
contexts live under `server/`.
Context-free declarations stay top-level, such as `defineEvent(...)`.
Context-bound definitions should use app-bound builders created once in `lib/`,
for example `createJobs<AppContext>()`, `createListeners<AppContext>()`,
`createSchedules<AppContext>()`, `createNotifications<AppContext>()`, and
`createTasks<AppContext>()`.
Agent-callable application actions use `@beignet/core/agent-capabilities`.
Create the app-bound capability and registry builders together, keep
definitions feature-owned, and compose them into one explicit registry. The
bound registry rejects definitions from another context or principal type.
Delegate business behavior to existing use cases. The executor's app-owned
context resolver must authenticate the transport principal, re-read tenant
membership from an authoritative port, and call
`server.createServiceContext(...)`; never trust a role from agent claims or
assemble `AppContext` directly. Agent registration, grants, approval, and token
verification belong to the transport integration, not the capability handler.
Executor completion hooks receive validated input and output for best-effort
activity projections. Error hooks receive validated input only after parsing
succeeds. Keep mandatory audit writes in the workflow and never copy raw
malformed input or unvalidated output into instrumentation.
Register workflow artifacts explicitly:
- listeners in `server/listeners.ts`, then through `registerListeners(...)` in server provider wiring
- schedules in `server/schedules.ts`
- tasks in `server/tasks.ts`
- outbox events and jobs in `server/outbox.ts`
- Inngest-backed job functions in `server/inngest.ts`; share one app-owned
client with the jobs provider and mount the registry through the host's
Inngest adapter
- queued notification definitions and their delivery job in
`server/notifications.ts`, then add that job to every worker/outbox registry
- seeds through the app's seed entrypoint, usually `server/seed.ts`
Notification channels run independently and return per-channel outcomes. Use
the inline dispatcher for immediate delivery and the queued dispatcher/provider
for one independently retryable job per channel. Preferences are an optional
app-owned `NotificationPreferencesPort`; inbox persistence remains app-owned
through `beignet make inbox`.
For apps that opt into runtime boot checks, declare workflow artifacts in
`server/runtime-integrity.ts` with `defineRuntimeManifest(...)`, compare them
to the central registries with `defineRuntimeRegistries(...)`, and pass the
resulting `runtimeIntegrity` check to server startup. This check is pure and
serverless-safe: it proves registry coverage, not worker deployment health.
Uploads are feature-owned definitions under `features/<feature>/uploads/`,
then exposed through an app route with `createUploadRouter(...)` and the
platform adapter. Webhooks and payment webhooks are inbound transport concerns:
define reusable verification or fulfillment in feature/application modules and
keep provider-specific route glue in focused route files.
GitHub and Stripe signature verification use the route-bound
`@beignet/webhooks-github` and `@beignet/webhooks-stripe` integrations. They do
not fill ports or install lifecycle providers, so keep them out of
`server/providers.ts` and provider-audit expectations.
Providers should not start unbounded background loops on server boot. Expose
cron routes, worker entrypoints, task runners, schedule runners, or outbox
drains instead.
In Next.js 15.1 and newer, an app may request one bounded outbox drain through
`after()` after its Unit of Work commits. Treat this as push-assisted polling,
not durable execution: the transaction-scoped outbox row owns durability and a
recovery cron still owns missed callbacks, delayed messages, and retries.
When tracing is installed, pass the tracing port to outbox recorders and job
dispatchers. Beignet's durable event-bus, BullMQ, and Inngest adapters capture
the active W3C trace carrier automatically and restore it as the consumer-span
parent. Trace metadata is versioned and best-effort: malformed metadata or a
capture failure must never prevent message delivery.
Report failures once at the boundary that knows they are terminal. Retryable
job and outbox attempts stay in instrumentation and logs. Use
`tryReportException(...)` for app-owned listener or direct-runner boundaries;
do not also report inside handlers when an HTTP hook, queue worker, route, or
CLI runner owns the terminal failure. Attach stable identifiers instead of
payloads, and remember that structured redaction does not rewrite exception
messages or make `AppError.details` safe to export.
Best-effort capture is bounded to one second by default. Tune `timeoutMs` when
needed; use `false` only when reporting may intentionally block the boundary.
## Runtime Safety
- HTTP idempotency defaults to the current actor and includes the current
tenant when one exists. The default actor scope fails closed when the
required identity is missing. Use
`meta.scope: "global"` only for deliberately public operations whose callers
should share one key namespace.
- Idempotency ports must reject `complete(...)` and `fail(...)` when the
fingerprint, reservation token, or in-progress state no longer matches.
Never silently drop a stale mutation: callers would mistake an unstored
result for a replayable one. The memory adapter throws
`IdempotencyMutationError`; Drizzle dialects expose corresponding
`Drizzle*IdempotencyMutationError` classes.
- `formatMailAddress(...)` rejects carriage returns and line feeds before a
provider builds headers; providers still own complete email-syntax
validation. Mail providers make one vendor call, so put retryable delivery
behind jobs or the outbox.
- Keep storage keys relative and reuse `assertValidStorageKey(...)`,
`normalizeStorageKeyPrefix(...)`, `prefixStorageKey(...)`, and
`createStoragePublicUrl(...)` from `@beignet/core/ports` in custom adapters.
Add provider-specific restrictions only after the shared validation.
- Uploads are protected by default. Declare `authorize(...)` or deliberately
opt into `access: "public"`; server uploads authorize each file before
reading bytes for validation. Include actor, tenant, or resource ownership
in object keys.
- Direct-upload completion is stateless, so make `onComplete(...)` idempotent
by upload ID or object key and use app-owned issuance state for single-use or
revocable grants. Before app-owned completion starts, a failed server batch
removes objects already written. After `onComplete(...)` starts, durable
references may exist and transaction or compensation belongs to the app.
## Validation
After core app-architecture changes, run from the app root:
```bash
beignet lint
beignet doctor --strict
bun run test
bun run typecheck
```
Use the app's package manager and scripts. If package-manager shims are not on
PATH, use local binaries such as `./node_modules/.bin/beignet lint`.