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

PathResponsibility
features/<feature>/contracts.tsHTTP surface: method, path, params, request body, headers, responses, metadata, and catalog errors
features/<feature>/schemas.tsShared DTO and validation schemas that contracts, use cases, ports, client modules, and tests may import
features/<feature>/routes.tsFeature 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.tsOptional agent-facing adapters over existing use cases
features/<feature>/policy.tsFeature-owned authorization rules
features/<feature>/ports.tsFeature-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.tsApplication error catalog and route-owned error schemas
features/shared/domain/Shared-kernel domain concepts used across features
server/routes.tsCentral route registry and OpenAPI contract list
server/context.tsShared context blueprint reused by the runtime server and route tests
server/index.tsRuntime wiring: context, hooks, providers, and error mapping
server/agent-capabilities.tsOptional central agent capability registry, executor, and delegated context resolution
server/providers.tsBeignet lifecycle providers installed at server startup
server/listeners.ts, server/notifications.ts, server/tasks.ts, server/outbox.ts, server/schedules.tsApp-owned workflow registries and CLI contexts for listeners, queued notification delivery, tasks, outbox draining, and schedules
server/runtime-integrity.tsOptional boot check comparing app-declared workflow artifacts against runtime registries
server/seed.tsOptional 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.tsShared 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:

ConcernPut it hereRead next
Endpoint shapefeatures/<feature>/contracts.tsContracts
Feature route wiringfeatures/<feature>/routes.tsServer
Request routingserver/routes.ts, server/index.ts, and app/api/Routes and server
Request lifecycle behaviorserver hooksRequest lifecycle, Hooks
Business workflowfeatures/<feature>/use-cases.ts or use-cases/Use cases
Agent-callable workflowfeatures/<feature>/agent-capabilities.ts and server/agent-capabilities.tsAgent capabilities
Business authorizationfeatures/<feature>/policy.ts or app-owned policy helpersAuthorization
Persistence and transactionsfeature repository ports plus ctx.ports.uow.transaction(...)Database and transactions
Database migrations and seed datachecked-in drizzle/ history plus optional server/seed.tsDatabase and transactions
Audit/activity loggingctx.ports.audit plus request actor, tenant, and requestIdAudit and activity logging
Cached reads and invalidationctx.ports.cache from infra/ or a cache providerCache
Object storagectx.ports.storage from infra/ or a storage providerStorage
Uploadsfeatures/<feature>/uploads/, StoragePort, and app-owned attachment recordsUploads
Browser updatesfeatures/<feature>/channels.ts, broadcasts.ts, server/broadcasts.ts, and client/broadcasts.tsBroadcasting
Domain eventsfeatures/<feature>/domain/events/, feature listeners, Unit of Work event recorderEvents
Background workctx.ports.jobs and job definitionsJobs
Long-running job workersan explicit runtime module under server/workers/Jobs, Runtime recipes
Scheduled workfeatures/<feature>/schedules/ and a cron/provider triggerSchedules
Mailctx.ports.mailer and mail provider adaptersMail
Notificationsfeatures/<feature>/notifications/, optional server/notifications.ts, and ctx.ports.notificationsNotifications
Structured loggingctx.ports.logger and request logging hooksLogging
Error reportingctx.ports.errorReporter and error reporting hooksError reporting
Rate limitingcontract metadata plus rate limit hooksRate limiting
Provider startup and teardownserver/providers.tsProviders
Env vars and deployment configlib/env.tsConfig, Deployment
App errorsfeatures/shared/errors.ts and contract .errors(...)Errors
OpenAPI routeapp/api/openapi/route.tsOpenAPI
Dev-only request inspectionapp/api/devtools/[[...path]]/route.tsDevtools
UI data fetchingclient/, features/<feature>/client/, features/<feature>/components/, React QueryReact, 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:

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.