App architecture
A Beignet app keeps production code in a small set of predictable places, and
each folder owns one kind of decision. This page is the map: what lives where,
where each production concern goes, and the dependency direction
beignet lint enforces. For the first-hour loop, use
Quickstart; for the guided tour of one feature, use
Build your first feature.
What goes where
| Path | Responsibility |
|---|---|
features/<feature>/contracts.ts | HTTP surface: method, path, params, request body, headers, responses, metadata, and catalog errors |
features/<feature>/schemas.ts | Shared DTO and validation schemas that contracts, use cases, ports, client modules, and tests may import |
features/<feature>/routes.ts | Feature route group that maps contracts to use cases |
features/<feature>/use-cases.ts or use-cases/ | Application workflows with input and output validation |
features/<feature>/domain/ | Feature-owned entities, value objects, and domain events |
features/<feature>/client/ | Feature-owned client data-fetching functions, React Query hooks, and other browser-safe feature helpers |
features/<feature>/components/ | Feature-owned UI |
features/<feature>/agent-capabilities.ts | Optional agent-facing adapters over existing use cases |
features/<feature>/policy.ts | Feature-owned authorization rules |
features/<feature>/ports.ts | Feature-specific dependency interfaces such as repositories |
features/<feature>/notifications/, uploads/, jobs/, listeners/, schedules/, tasks/, seeds/ | Feature-owned workflow artifacts, added by generators when needed |
features/<feature>/tests/ | Feature behavior tests, with shared factories in tests/factories/ |
features/shared/errors.ts | Application error catalog and route-owned error schemas |
features/shared/domain/ | Shared-kernel domain concepts used across features |
server/routes.ts | Central route registry and OpenAPI contract list |
server/context.ts | Shared context blueprint reused by the runtime server and route tests |
server/index.ts | Runtime wiring: context, hooks, providers, and error mapping |
server/agent-capabilities.ts | Optional central agent capability registry, executor, and delegated context resolution |
server/providers.ts | Beignet lifecycle providers installed at server startup |
server/listeners.ts, server/notifications.ts, server/tasks.ts, server/outbox.ts, server/schedules.ts | App-owned workflow registries and CLI contexts for listeners, queued notification delivery, tasks, outbox draining, and schedules |
server/runtime-integrity.ts | Optional boot check comparing app-declared workflow artifacts against runtime registries |
server/seed.ts | Optional database seed entrypoint that composes feature seeds through an application service context |
server/workers/ | Optional long-running runtime entrypoints, such as a BullMQ job worker |
app/api/ | Thin Next.js route files that call createApiRoute(getServer) or focused route helpers |
app-context.ts | Shared request context type used by handlers, hooks, and use cases |
ports/ | App-wide dependency interfaces shared across features |
infra/ | Concrete adapters and default port wiring, including infra/port-wiring.ts |
lib/ | Small app helpers: env.ts, auth.ts, server-only context helpers, and the use-case.ts builder |
client/ | Typed Beignet client and frontend adapter factories |
drizzle/ | Checked-in SQL migrations and Drizzle migration metadata |
The CLI starter scaffolds the subset of this structure it ships: contracts,
schemas, routes, use cases, components, ports, infra, server composition, and
the client. Workflow-tier artifacts such as jobs, listeners, notifications,
schedules, uploads, the outbox registry, and operational tasks appear when
beignet make generators add them, along with their lib/ builders and
server registries. The database-backed starter includes drizzle/ migration
history. beignet make seed adds server/seed.ts when the app needs seed data;
add server/workers/ only when the deployment runs a long-lived worker
process. beignet doctor treats optional workflow entrypoints as absent until
the app opts in.
Production concern map
When you know what you need to build, this table says where it goes:
| Concern | Put it here | Read next |
|---|---|---|
| Endpoint shape | features/<feature>/contracts.ts | Contracts |
| Feature route wiring | features/<feature>/routes.ts | Server |
| Request routing | server/routes.ts, server/index.ts, and app/api/ | Routes and server |
| Request lifecycle behavior | server hooks | Request lifecycle, Hooks |
| Business workflow | features/<feature>/use-cases.ts or use-cases/ | Use cases |
| Agent-callable workflow | features/<feature>/agent-capabilities.ts and server/agent-capabilities.ts | Agent capabilities |
| Business authorization | features/<feature>/policy.ts or app-owned policy helpers | Authorization |
| Persistence and transactions | feature repository ports plus ctx.ports.uow.transaction(...) | Database and transactions |
| Database migrations and seed data | checked-in drizzle/ history plus optional server/seed.ts | Database and transactions |
| Audit/activity logging | ctx.ports.audit plus request actor, tenant, and requestId | Audit and activity logging |
| Cached reads and invalidation | ctx.ports.cache from infra/ or a cache provider | Cache |
| Object storage | ctx.ports.storage from infra/ or a storage provider | Storage |
| Uploads | features/<feature>/uploads/, StoragePort, and app-owned attachment records | Uploads |
| Browser updates | features/<feature>/channels.ts, broadcasts.ts, server/broadcasts.ts, and client/broadcasts.ts | Broadcasting |
| Domain events | features/<feature>/domain/events/, feature listeners, Unit of Work event recorder | Events |
| Background work | ctx.ports.jobs and job definitions | Jobs |
| Long-running job workers | an explicit runtime module under server/workers/ | Jobs, Runtime recipes |
| Scheduled work | features/<feature>/schedules/ and a cron/provider trigger | Schedules |
ctx.ports.mailer and mail provider adapters | ||
| Notifications | features/<feature>/notifications/, optional server/notifications.ts, and ctx.ports.notifications | Notifications |
| Structured logging | ctx.ports.logger and request logging hooks | Logging |
| Error reporting | ctx.ports.errorReporter and error reporting hooks | Error reporting |
| Rate limiting | contract metadata plus rate limit hooks | Rate limiting |
| Provider startup and teardown | server/providers.ts | Providers |
| Env vars and deployment config | lib/env.ts | Config, Deployment |
| App errors | features/shared/errors.ts and contract .errors(...) | Errors |
| OpenAPI route | app/api/openapi/route.ts | OpenAPI |
| Dev-only request inspection | app/api/devtools/[[...path]]/route.ts | Devtools |
| UI data fetching | client/, features/<feature>/client/, features/<feature>/components/, React Query | React, React Query |
Dependency direction
The rule is simple: transport, application behavior, and infrastructure do not
own each other. A feature owns its contracts, route group, use cases, policy,
domain model, UI, and feature-specific ports. App-wide ports describe
dependencies shared across features. Infra implements those ports. Domain
concepts that are genuinely shared across features live in
features/shared/domain/.
beignet lint enforces the most important directions. Domain and use cases
cannot import infra, UI, route, client, provider, or framework code. Feature
domain cannot import another feature's domain unless it comes from
features/shared/domain. Route files cannot import infra or UI, and infra
adapters cannot import UI, routes, server modules, or clients. Contract and
schema files, plus everything reachable from client/ or "use client"
modules, are also checked as client-safe import graphs that must not reach
server-only code; see the CLI reference for the full lint rules.
Agent capabilities may adapt use cases, but application workflows and helpers
cannot depend back on those adapters. Feature seeds compose app ports and test
factories rather than concrete infra or providers.
Use boundary markers as side-effect imports when a module outside a canonical server-only folder still must stay out of client bundles:
import "@beignet/core/server-only";Two placement notes that follow from the rule:
- Small feature-root helper modules are allowed — for example a
features/issues/history.tswith pure helpers shared across the feature's use cases. Keep them pure: as soon as a helper needs a dependency, give it a port.beignet lintfollows local value imports through these helpers, so a use case cannot reach infra or another forbidden layer indirectly. - Test placement follows ownership: feature behavior tests live in
features/<feature>/tests/, while infra and server modules may keep adjacent*.test.tsfiles beside the module they exercise, such as a repository test next to its adapter.
Feature-owned UI
Product UI lives with the feature it serves, in
features/<feature>/components/. Feature-specific client data-fetching
functions, React Query query/mutation options, invalidation helpers, hooks, and
browser-safe helpers may live beside the feature in
features/<feature>/client/. Shared client wiring — the typed Beignet client,
React Query helper, selected form adapter, upload client, and QueryClient
provider — lives in client/. Feature components import those shared helpers,
their feature's contracts, and any extracted client helpers, then call
endpoints through React Query and form adapters. Start with contract options
in components; extract feature client helpers
when sharing defaults or cache behavior.
Server-only request context helpers that bridge Server Components to Beignet's
request-scoped AppContext should live outside client/, usually in
lib/server-context.ts with an @beignet/core/server-only marker. It is
appropriate for layouts and Server Components to read request metadata,
ctx.auth, and ctx.tenant from that context for redirects and shell state.
Feature data and business workflows should still go through use cases.
Server-only React Query prefetch helpers may live in
lib/server-react-query.ts and consume that context. Keep the
contract-derived query key from rq(contract).queryOptions(...), but call the
use case directly only for thin { contract, useCase } route bindings whose
output matches the contract success body.
components/ may contain React Server Components and Client Components.
Server Components can call server-only modules when they are not reachable
from a client root. Client Components and anything they import cannot reach
use cases, route groups, infra adapters, server modules, provider packages,
or app-context.ts — keep server-only workflows behind route groups and
explicit server entrypoints. See React for the component patterns.
Server composition
Features keep route wiring local in features/<feature>/routes.ts, and the
server composes them at the boundary: server/routes.ts owns the central
route list, server/context.ts declares the context blueprint once for the
runtime and route tests, server/index.ts assembles ports, providers, hooks,
and routes. Next.js apps expose that server through a catch-all
app/api/[[...path]]/route.ts and createApiRoute(getServer). Standard Fetch
apps pass the same central route list to createFetchServer(...) and mount its
handler through their runtime. Both conventions give the CLI one source of
truth for route inspection, generation, OpenAPI wiring, and drift checks. The
code for each piece is on Routes and server.
CLI profile and custom paths
framework: "next" is the default CLI profile. Set framework: "web" when
server/index.ts uses createFetchServer(...) from @beignet/web; route
inspection and doctor then follow the central defineRoutes(...) registry
without requiring Next.js route files. Use path overrides when the app keeps
the same architecture under different paths:
import { defineConfig } from "@beignet/cli/config";
export default defineConfig({
framework: "next",
paths: {
appContext: "src/app-context.ts",
contracts: "src/features",
features: "src/features",
ports: "src/ports/index.ts",
portWiring: "src/infra/port-wiring.ts",
routes: "src/app/api",
server: "src/core/server/index.ts",
listeners: "src/core/server/listeners.ts",
},
});The framework profile selects the server adapter the CLI inspects, while path
overrides change where it looks and writes. Neither replaces the architecture:
feature contracts still define the HTTP boundary, the server still registers
route groups, and application code still belongs behind use cases and ports.
When appContext moves under a source root such as src/app-context.ts, client
setup follows that root at src/client/index.ts.
The config can also declare app-owned names for Beignet operational database tables when they differ from provider defaults:
import { defineConfig } from "@beignet/cli/config";
export default defineConfig({
database: {
tables: {
audit: "audit_events",
},
schemaSources: ["@acme/db/schema"],
},
});doctor uses those table names when it checks Drizzle-backed audit,
idempotency, and outbox wiring. Runtime code still needs matching provider
tableName options where setup statements and ports are created.
Use database.schemaSources when Drizzle table definitions live outside the
app's canonical database files, such as a shared workspace package. Entries can
be app-relative files or directories, @/ app paths, or package specifiers.
Provider metadata describes standard static environment requirements. If a
custom injected client receives one of those values through a platform binding
or another non-env mechanism, list the exact exception under
providerAudit.ignoreRequiredEnv. This changes doctor and provider audit
only; runtime configuration remains enforced by the app and provider.
Workspace source packages
A web app and a worker can share Beignet application code and infrastructure through ordinary workspace packages. Each runtime owns startup and shutdown; shared packages export contracts, use cases, port types, and adapter factories. Sharing an adapter implementation does not share an in-memory instance across processes. Use the same persistent database or service when runtimes need shared state.
Opt into source analysis from the app's beignet.config.ts:
import { defineConfig } from "@beignet/cli/config";
export default defineConfig({
framework: "web",
workspace: {
packages: ["../../packages/application", "../../packages/infra"],
},
});List every source package to inspect, including transitive dependencies. Paths
are relative to the app and may point to sibling packages. Each package needs a
unique package.json name and must be reachable through declared dependencies
from the app. Package roots cannot overlap. Beignet does not discover or inspect
all of node_modules; install workspace dependencies using your package manager.
Turborepo can schedule the package scripts but is not required by Beignet.
Each package uses its own beignet.config.* paths and tsconfig.json (or
jsconfig.json). An application package can keep the usual features/, ports/,
and lib/ layout. An infra package containing adapters under src/ can declare:
// packages/infra/beignet.config.ts
import { defineConfig } from "@beignet/cli/config";
export default defineConfig({
paths: { portWiring: "src/port-wiring.ts" },
});This identifies src/ as the package's infrastructure layer. A package that
only exports adapter factories does not need a port-wiring file. The consuming
app can compose those factories in its own infra/port-wiring.ts.
Expose source entrypoints through package exports, for example:
{
"name": "@example/infra",
"private": true,
"type": "module",
"exports": { "./notes": "./src/notes.ts" },
"dependencies": { "@example/application": "workspace:*" }
}The normal TypeScript resolver handles exports, subpaths, conditions, package
imports, and configured aliases. Configure the consuming build and runtime to
resolve the same source. Beignet does not rewrite imports or redirect built
exports to guessed source files. Missing exports and entries that resolve only
to excluded build output produce BEIGNET_WORKSPACE_IMPORT diagnostics.
beignet lint follows imports and reexports across included packages and applies
each owner's architecture rules. Moving an adapter to a package does not make
it accessible to use cases or browser code. Cross-package imports require a
dependency declaration in the importing package, including type-only imports.
Relative imports, TypeScript aliases, and package.json#imports aliases that
resolve outside the configured source roots also produce a workspace diagnostic;
include the owning source package in workspace.packages. Aliases to installed
external dependencies remain external imports.
App-owned provider checks recognize reexported AppPorts declarations and
provider metadata in hoisted node_modules installations. Inferred declarations
such as AppPorts = typeof initialPorts follow the underlying wiring declaration
through reexport aliases. Barrels may import a symbol and then export it, including
type-only exports of shared port interfaces.
beignet routes and doctor inspect shared contracts and explicit
defineRoutes(...) / defineRouteGroup(...) composition from the selected
server. Inspection follows the exported server or the server returned by an
exported getServer = createNextServerLoader(...). Unrelated server factories
do not register routes, and verified per-file Next.js handlers remain valid
without central registration. Contract matching follows imports and local aliases
to the declaration, so shared export names and loader-local variables remain
distinct. Computed or ambiguous workspace route registration produces
BEIGNET_WORKSPACE_ROUTES_UNRESOLVED. map and explain include shared source
and qualify shared feature names, for example @example/application:notes.
Evidence paths remain relative to the selected app and can contain ../.
map --changed includes changes to configured source packages.
Keep lifecycle provider registration, environment configuration, database
commands, and operational workflow registries app-owned. Provider and
operational doctor checks still use those app conventions; this source analysis
does not make arbitrary imported runtime factories statically verifiable.
Worker-only packages can use lint to check their dependencies; the HTTP-focused
routes and doctor commands run against the web app.
Generators and doctor repairs remain scoped to the selected app. paths.*
values must still stay inside that app; workspace.packages grants read-only
analysis, not permission to generate or repair files in another package. The
standard single-app examples remain the default architecture.
Broadcasting boundaries
Keep browser-safe defineChannel declarations in feature channels.ts files.
Feature broadcasts.ts files bind those contracts to authorization through
createBroadcasting<AppContext>() in lib/broadcasting.ts; compose bindings in
server/broadcasts.ts. The transport adapter lives in
app/api/broadcasts/route.ts for Next or server/broadcast-route.ts for a Fetch
host. Client construction belongs in client/broadcasts.ts, and query mappings
belong in features/<feature>/client/broadcasts.ts.
The existing app-owned inbox channel.ts is a notification workflow module,
so it may dispatch publication jobs through transaction ports. It is distinct
from the plural browser contract file channels.ts. Bindings and notification
handlers must not enter browser bundles. The inbox mutation and its publication
job share a transaction; the browser refetches authoritative state on reconnect.