Skip to content

Search docs

Jump between documentation pages.

Browse docs

API versioning

DaloyJS supports URL-based API versioning with ordinary route prefixes. Put a stable major version in the public path, such as /api/v1/books, and mount that version with app.group() or a prefixed plugin. The resulting paths are normal DaloyJS routes: request and response validation, OpenAPI, generated clients, hooks, auth, and rate limits all continue to work.

Use a new major path only for a breaking contract. Additive endpoints and optional inputs normally stay in the current version. This keeps consumers stable without turning every release into another permanent API surface.

One service, explicit public contracts
api.example.comDaloyJS applicationshared auth · services · storage · observability
stableBooks API v1/api/v1/books
currentBooks API v2/api/v2/books
operationsUnversioned health route/healthz
Version the public resource contract, not necessarily the whole deployment. v1 and v2 can run side by side while sharing internal services. Operational routes such as health checks normally remain unversioned.

Create /api/v1/books

A group prepends its path to every route registered inside the callback. Group tags also carry into OpenAPI, which keeps versions easy to distinguish in Scalar, Swagger UI, or Redoc.

ts
import { App } from "@daloyjs/core";
import { z } from "zod";

const BookV1 = z.object({
  id: z.string(),
  title: z.string(),
});

const app = new App({
  openapi: {
    info: { title: "Books API", version: "1.0.0" },
  },
  docs: true,
});

app.group("/api/v1", { tags: ["Books v1"] }, (v1) => {
  v1.get(
    "/books",
    {
      operationId: "listBooksV1",
      responses: {
        200: {
          description: "Books available to the caller",
          body: z.array(BookV1),
        },
      },
    },
    async () => ({
      status: 200,
      body: [{ id: "book_1", title: "Dune" }],
    }),
  );
});

// GET /api/v1/books
// OpenAPI path: /api/v1/books

Groups are ideal for a small API or a version defined in one module. For a larger public API, prefer one plugin per major version so route ownership and version-specific middleware stay explicit.

Organize major versions as plugins

Keep version-specific transport contracts near each other, but call shared domain services underneath them. Avoid copying database and business logic into every version. Usually only the route schemas and the mapping between those schemas and the domain model should differ.

text
src/
  api/
    v1/
      books.ts
      index.ts
    v2/
      books.ts
      index.ts
  domain/
    books-service.ts
  app.ts
ts
// src/app.ts
import { App } from "@daloyjs/core";
import { apiV1 } from "./api/v1/index.js";
import { apiV2 } from "./api/v2/index.js";

const app = new App({
  openapi: {
    info: { title: "Books API", version: "2.0.0" },
  },
  docs: true,
});

app.register(apiV1, {
  prefix: "/api/v1",
  tags: ["Books v1"],
});

app.register(apiV2, {
  prefix: "/api/v2",
  tags: ["Books v2"],
});

await app.ready();

Each plugin registers resource paths such as /books. The prefix supplied by the application produces /api/v1/books and /api/v2/books. Plugins also encapsulate hooks and decorations, so a compatibility adapter or limit added to v1 does not leak into v2.

When to create a new major version

Treat the public request and response contract as the versioned product. Internal refactors, database migrations, and deployment changes do not need a new URL when callers observe the same behavior.

ChangeTypical decision
Add a new endpoint or optional request parameterKeep the current major version
Fix internals without changing the contractKeep the current major version
Remove or rename a request or response fieldCreate a new major version
Tighten accepted input so valid old requests failCreate a new major version
Change the meaning of a field or operationCreate a new major version
Add a new required permission to an existing operationUsually create a new major version or run a migration period

Run DaloyJS's OpenAPI diff in CI instead of relying only on judgment. It catches removed paths and responses, newly required parameters, optional parameters that became required, and newly required request bodies.

OpenAPI and generated clients

A single DaloyJS application produces one OpenAPI document containing every public route registered on that application. If v1 and v2 run together, the default document contains both sets of paths. Use version-specific tags and globally unique operation IDs:

ts
// Good: stable and unique across the complete document
operationId: "listBooksV1"
operationId: "listBooksV2"

// Avoid: duplicate operation IDs fail during registration
operationId: "listBooks"

The OpenAPI info.version identifies the release of the document or API contract. It does not add a URL prefix and it does not select a route at runtime. Likewise, the route-level version property is informational metadata; it is not a routing switch and is not emitted into OpenAPI. The URL prefix is what makes /api/v1 real.

Current boundary: generateOpenAPI() does not currently filter a document by tag or version. If consumers need separately published v1 and v2 specifications or SDKs, register the reusable version plugin on a dedicated contract app for each codegen run.
ts
import { App } from "@daloyjs/core";
import { generateOpenAPI } from "@daloyjs/core/openapi";
import { apiV1 } from "./api/v1/index.js";

const v1Contract = new App();
v1Contract.register(apiV1, {
  prefix: "/api/v1",
  tags: ["Books v1"],
});

await v1Contract.ready();

const v1Spec = generateOpenAPI(v1Contract, {
  info: { title: "Books API v1", version: "1.8.0" },
});

// Write v1Spec for publication or feed it to the v1 SDK codegen job.

Reusing the same plugin for the runtime app and contract app prevents a second handwritten route inventory from drifting away from production. See OpenAPI generation and typed-client codegen for the publication workflow.

Retire an old version safely

Do not replace v1 in place when v2 is breaking. Run both versions, announce the migration, observe remaining v1 traffic, and remove v1 only after the published support window.

ts
app.get(
  "/api/v1/books",
  {
    operationId: "listBooksV1",
    sunset: "2027-06-30T00:00:00Z",
    responses: {
      200: {
        description: "Books in the v1 representation",
        body: z.array(BookV1),
      },
    },
  },
  async () => ({ status: 200, body: await books.listV1() }),
);

// Every response includes:
// Deprecation: true
// Sunset: Wed, 30 Jun 2027 00:00:00 GMT
//
// OpenAPI includes:
// deprecated: true
// x-sunset: Wed, 30 Jun 2027 00:00:00 GMT
  1. Ship v2 while v1 remains available.
  2. Publish migration examples and a concrete retirement date.
  3. Mark v1 routes with sunset, which also marks them deprecated.
  4. Track usage by version and contact active consumers.
  5. Remove v1 after the date and the promised support window.

Read API lifecycle & breaking changes for the response headers, OpenAPI extensions, CLI diff, and CI gate.

Authentication, quotas, and paid APIs

Versioning keeps a customer's integration stable. It does not by itself turn an endpoint into a commercial API product. DaloyJS provides the data-plane building blocks that run on every request:

  • bearer, JWT, JWK, mTLS, and HTTP-signature verification;
  • requireScopes() for operation-level permissions;
  • rateLimit() with a Redis-backed store for multi-instance request limits;
  • structured logs, metrics, tracing, and request IDs;
  • OpenAPI documentation, generated clients, and contract-change gates.

Your application or an external API-management control plane still owns customer onboarding, credential issuance and revocation, plan entitlements, billing, invoices, a developer portal, and durable usage records. Those policies are business-specific and are not a turnkey DaloyJS subsystem.

Security: a group's auth option documents the OpenAPI security requirement; it does not verify a credential by itself. Install an enforcement hook such as jwk(), bearerAuth(), or a reviewed custom API-key hook. Key limits by a stable authenticated customer ID, not by the raw secret, and use a shared store when more than one instance serves traffic.

For the enforcement pieces, continue with OAuth2/OIDC architecture, JWT and auth safeguards, and the Redis rate-limit store.

Supported versioning styles

StyleDaloyJS supportGuidance
URL path: /api/v1/booksFirst-class through groups and plugin prefixesRecommended for public APIs
Header: Accept-Version: 1No automatic route negotiationImplement explicit application logic only when required
Versioned media typesNo automatic route negotiationUseful only when your API contract already requires it
Query string: ?version=1Possible as ordinary input, not a versioning featureAvoid for public contracts because caches and docs are less clear

URL path versioning is deliberately boring: it is visible in logs, caches, documentation, generated clients, gateway policies, and support tickets. For a public resource API, that clarity is usually more useful than implicit negotiation.