@aventara/nest hosts a framework in a NestJS 12 application, on Express (Nest's default) or Fastify. It mounts the framework's HTTP protocol on Nest's HTTP platform and puts the AvFramework into Nest's dependency injection. All protocol rules (routes, statuses, envelopes, headers) belong to the protocol itself; the host only translates requests and responses.
For the platform differences (middleware, global prefix, CORS) see Express and Fastify. To host outside Nest, see Custom host.
AventaraModule
AventaraModule is a global module. Import it once, in your root module. aventara new and aventara init write this for you:
// src/app.module.ts
import { Module } from "@nestjs/common";
import { AventaraModule } from "@aventara/nest";
import { createPrismaAdapter } from "@aventara/prisma7-adapter";
import adapter from "./aventara.adapter.js";
import { aventaraConfig } from "./aventara.config.js";
import { PrismaModule, PrismaService } from "./prisma.service.js";
@Module({
imports: [
PrismaModule,
AventaraModule.forRootAsync({
imports: [PrismaModule],
inject: [PrismaService],
useFactory: async (prisma: PrismaService) => ({
config: {
...aventaraConfig,
adapter: await createPrismaAdapter({ ...adapter, client: prisma }),
},
}),
}),
],
})
export class AppModule {}aventaraConfig is a plain value with no adapter in it, so aventara-build can run it without a database client. The adapter is built here, from src/aventara.adapter.ts and the Prisma client (Configuration).
It takes the framework in one of three forms:
| Form | Use when |
|---|---|
AventaraModule.forRoot({ framework }) | You called createFramework(config) yourself and want your own typed AvFramework in code. |
AventaraModule.forRoot({ config }) | The module calls createFramework(config) for you. |
AventaraModule.forRootAsync({ imports, inject, useFactory }) returning { framework } or { config } | What builds the framework (a Prisma client, a configuration service) is itself a provider. |
The config form is checked like createFramework's parameter, against your adapter's model.
Injecting the framework
@InjectFramework() injects the AvFramework anywhere. Nest's DI carries no type argument, so declare a type alias from your configuration:
// src/aventara.framework.ts
import { createFramework } from "@aventara/core";
import { createPrismaAdapter } from "@aventara/prisma7-adapter";
import adapter from "./aventara.adapter.js";
import { aventaraConfig } from "./aventara.config.js";
import type { PrismaService } from "./prisma.service.js";
export const buildFramework = async (prisma: PrismaService) =>
createFramework({
...aventaraConfig,
adapter: await createPrismaAdapter({ ...adapter, client: prisma }),
});
export type AppFramework = Awaited<ReturnType<typeof buildFramework>>;// src/stats.service.ts
import { Injectable } from "@nestjs/common";
import { InjectFramework } from "@aventara/nest";
import type { AppFramework } from "./aventara.framework.js";
@Injectable()
export class StatsService {
constructor(@InjectFramework() private readonly framework: AppFramework) {}
async userCount() {
return this.framework.application.User.find.count({}); // a number; a failure throws an AvError subclass
}
}@Inject(AVENTARA_FRAMEWORK) is the same thing. See Querying for framework.application.* and framework.client.*, and Admin vs public for a worked example.
Fails before listen
These errors happen while Nest builds the application, before it listens: both or neither of framework and config given; a framework that is not an AvFramework; the module mounted twice; a platform other than Express or Fastify; and any AvConstructionError (for example an invalid exemption). Nest's default abortOnError: true exits the process on a bootstrap error; pass abortOnError: false to NestFactory.create to catch it instead.
The module owns no resource to shut down. Your Prisma service closes its own client in onModuleDestroy; call app.enableShutdownHooks() to handle signals.
Startup log
When the application starts, the host logs one line:
Aventara mounted 42 operations at /api (+ GET /api/_contract, POST /api/_transactions, POST /api/_all)The number is how many operations are advertised and /api is your api mount path. The POST /api/_transactions part appears only when transactions are interactive.
Logging
An internal failure (A3000 to A3004) writes one line to Nest's log under the context Aventara: the code, the operation, the origin, the request id and the error's class name, never its message:
ERROR [Aventara] A3000 on Counter.find.many (client origin, request checkout-17): Error. The raw error goes to the framework's diagnostics sink.This is the case when AventaraModule builds the framework from config (the form the scaffold uses). A framework you built with createFramework yourself has its own diagnostics sink, which the module does not wrap. The line is written through Nest's Logger, so your logger settings govern it. See Request IDs and diagnostics.
Mount path
AvConfig.api alone decides where the protocol is mounted:
export const aventaraConfig = { api: "/api" } as const;app.setGlobalPrefix("v1") does not move the protocol: with the prefix set, /api/_contract still answers 200 and /v1/api/_contract answers 404. Set api to where you want it ("/v1/api"), and point the generated client at that URL (Client setup).
Your own controllers may live beside it, for example GET /api/health, as long as they are outside the protocol's reserved _ paths (/api/_contract, /api/_resources/..., /api/_transactions, and any other /api/_*).
The MCP endpoint
With mcp in the configuration, AventaraModule also mounts the MCP endpoint of @aventara/mcp at that path, on Express and Fastify alike, and logs Aventara mounted the MCP endpoint at /mcp (...). Like api, the path is absolute and Nest's global prefix does not move it. Install @aventara/mcp at the same exact version as @aventara/core (npm install --save-exact @aventara/mcp@<version>); with mcp set and the package missing, the application refuses to start and prints that command with your version filled in. Your state option seeds each tool call's ctx.$state. See MCP.
What runs on Aventara routes
The protocol's routes are registered on the HTTP platform itself, one per advertised operation, plus GET <api>/_contract, and POST <api>/_transactions when transactions are interactive. The framework's own validation is outermost.
| Express | Fastify | |
|---|---|---|
Your app.use(...) middleware, app.enableCors(), helmet | runs | runs |
| Nest guards, interceptors and pipes (global or not) | never | never |
MiddlewareConsumer middleware | never | runs (keep it off with .exclude()) |
Your controller at <api>/_contract (or any _ path) | never reached | the application does not start |
Your controller at <api>/health (outside _) | reached | reached |
Authentication and authorization
Nest guards never run on Aventara routes. Put authentication and authorization into Aventara pipelines, where a guard reads ctx.$transport.headers:
client: {
pipelines: {
guards: [
(ctx: AvContext) => {
const token = ctx.$transport?.headers["authorization"];
if (!token) throw new AvError("A4000", "Sign in first.");
// verify the bearer token or cookie here
return true;
},
],
},
},Request state: what your middleware found
AventaraModule's state option hands every protocol request's stages what your own middleware found. It is called once per request with the platform's request (Express's req, Fastify's request), and what it returns is the request's ctx.$state:
declare module "@aventara/core" {
interface AvState {
user?: { id: number; role: "member" | "admin" };
}
}
AventaraModule.forRoot({
config,
state: (req: Request) => ({ user: req.user }), // set by your auth middleware (app.use)
});
// in the configuration
client: { pipelines: { guards: [(ctx: AvContext) => ctx.$state.user !== undefined] } },- Run the middleware that finds the user with
app.use(...): it runs on Aventara routes on both platforms. Nest guards never do, andMiddlewareConsumermiddleware does not on Express. - On Fastify,
app.usemiddleware writes to the Node request, so readrequest.raw.user. - Throw
new AvError("A4001", "...")fromstateto refuse the request with403; any other throw answers500 A3000. forRootAsync'suseFactoryreturnsstatebesideframeworkorconfig.
The same object reaches every stage of the request, a transaction's steps and the ctx calls they make. See The pipeline context.
Requests
- Bodies on protocol routes are read raw, never through Nest's body parser, up to
limits.maxRequestBytes. An oversized body answers413 A2010, another content type415 A2011, malformed JSON400 A2000. Your own routes keep Nest's parsedreq.body; noNestFactory.createoption is needed. Content-Encoding:gzip,deflateandbrare inflated, and the size limit counts decoded bytes. Any other coding answers415 A2011.- The request id is the client's
Aventara-Request-Idwhen it is 1-128 visible characters; anything else (empty, containing spaces, longer than 128) is silently replaced by a generated UUID.X-Request-Idis never copied into it; setAventara-Request-Idat your proxy. See Request IDs and diagnostics. - A query string is not part of the protocol path and is ignored.
Testing
Nest's testing module works: Test.createTestingModule({ imports: [AppModule] }).compile(). The module waits for the application that createNestApplication() supplies.