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/
PathResponsibility
features/<feature>/contracts.tsHTTP surface: method, path, params, request body, headers, responses, metadata, and catalog errors
features/<feature>/routes.tsFeature 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.tsFeature-owned authorization rules
features/<feature>/ports.tsFeature-specific dependency interfaces such as repositories
server/routes.tsCentral route registry and OpenAPI contract list
server/index.tsRuntime wiring: context, hooks, providers, and error mapping
server/providers.tsBeignet lifecycle providers installed at server startup
features/shared/errors.tsApplication error catalog and route-owned error schemas
app/api/Thin Next.js route files that expose server.api
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 for the selected runtime
lib/env.tsValidated deployment configuration
lib/auth.tsProvider-neutral authentication helpers
lib/use-case.tsShared 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

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/Use cases
Business authorizationfeatures/<feature>/policy.ts or app-owned policy helpersAuthorization
Persistence and transactionsfeature repository ports plus ctx.ports.uow.transaction(...)Database 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
Domain eventsfeatures/<feature>/domain/events/, feature listeners, Unit of Work event recorderEvents
Background workctx.ports.jobs and job definitionsJobs
Scheduled workfeatures/<feature>/schedules/ and a cron/provider triggerScheduled tasks
Mailctx.ports.mailer and mail provider adaptersMail
Structured loggingctx.ports.logger and request logging hooksLogging
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>/components/, React QueryReact, 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.