App architecture
A Beignet app keeps production code in a small set of predictable places. Each folder owns one kind of decision.
app/api/
app-context.ts
client/
domain/ optional shared kernel
features/
infra/
lib/
ports/
server/
| Path | Responsibility |
|---|---|
features/<feature>/contracts.ts | HTTP surface: method, path, params, request body, headers, responses, metadata, and catalog errors |
features/<feature>/routes.ts | Feature route group that maps contracts to use cases |
features/<feature>/use-cases/ | Application workflows with input and output validation |
features/<feature>/domain/ | Feature-owned entities, value objects, and domain events |
features/<feature>/components/ | Feature-owned UI and client workflows |
features/<feature>/tests/ | Feature behavior tests for use cases, repositories, policies, and UI |
features/<feature>/policy.ts | Feature-owned authorization rules |
features/<feature>/ports.ts | Feature-specific dependency interfaces such as repositories |
server/routes.ts | Central route registry and OpenAPI contract list |
server/index.ts | Runtime wiring: context, hooks, providers, and error mapping |
server/providers.ts | Beignet lifecycle providers installed at server startup |
features/shared/errors.ts | Application error catalog and route-owned error schemas |
app/api/ | Thin Next.js route files that expose server.api |
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 for the selected runtime |
lib/env.ts | Validated deployment configuration |
lib/auth.ts | Provider-neutral authentication helpers |
lib/use-case.ts | Shared Beignet use-case builder |
client/ | Typed Beignet client and frontend adapter factories |
domain/ | Optional shared-kernel domain concepts used across features |
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. Top-level
domain/ is only for concepts that are genuinely shared across features.
beignet lint enforces the most important dependency direction rules:
domain and use cases cannot import infra, UI, route, client, provider, or
framework code; route files cannot import infra or UI; and provider packages
stay behind ports and infra adapters.
Route registration
Beignet apps keep route wiring close to the feature and compose route groups at the server boundary:
// features/todos/routes.ts
import { defineRouteGroup } from "@beignet/next";
import type { AppContext } from "@/app-context";
import * as contracts from "@/features/todos/contracts";
import { listTodosUseCase } from "@/features/todos/use-cases";
export const todoRoutes = defineRouteGroup<AppContext>({
name: "todos",
routes: [
{
contract: contracts.listTodos,
handle: async ({ ctx, query }) => ({
status: 200,
body: await listTodosUseCase.run({ ctx, input: query }),
}),
},
],
});
Then server/routes.ts owns the central route list:
import { contractsFromRoutes, defineRoutes } from "@beignet/next";
import type { AppContext } from "@/app-context";
import { todoRoutes } from "@/features/todos/routes";
export const routes = defineRoutes<AppContext>([todoRoutes]);
export const contracts = contractsFromRoutes(routes);
And server/index.ts becomes app composition:
import { createNextServer } from "@beignet/next";
import { createAnonymousActor } from "@beignet/core/ports";
import { appPorts } from "@/infra/app-ports";
import type { AppContext } from "@/app-context";
import { routes } from "./routes";
export const server = await createNextServer<AppContext, typeof appPorts>({
ports: appPorts,
createContext: ({ ports }) => ({
actor: createAnonymousActor(),
requestId: crypto.randomUUID(),
ports,
}),
routes,
});
Then a catch-all Next.js route file exposes the central handler:
// app/api/[[...path]]/route.ts
import { server } from "@/server";
export const DELETE = server.api;
export const GET = server.api;
export const HEAD = server.api;
export const OPTIONS = server.api;
export const PATCH = server.api;
export const POST = server.api;
export const PUT = server.api;
That convention keeps features responsible for their HTTP wiring while giving the CLI one source of truth for route inspection, resource generation, OpenAPI wiring, and drift checks.
Production concern map
| 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/ | Use cases |
| 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 |
| 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 |
| Domain events | features/<feature>/domain/events/, feature listeners, Unit of Work event recorder | Events |
| Background work | ctx.ports.jobs and job definitions | Jobs |
| Scheduled work | features/<feature>/schedules/ and a cron/provider trigger | Scheduled tasks |
ctx.ports.mailer and mail provider adapters | ||
| Structured logging | ctx.ports.logger and request logging hooks | Logging |
| 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>/components/, React Query | React, React Query |
Custom paths
Use beignet.config.ts when your app keeps the same architecture under
different paths:
import { defineConfig } from "@beignet/cli/config";
export default defineConfig({
paths: {
contracts: "src/features",
features: "src/features",
routes: "src/app/api",
server: "src/core/server/index.ts",
},
});
Config changes where the CLI looks and writes. It does not replace 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.