@camcima/nestjs-deprecation
v1.1.0
Published
NestJS library for RFC 9745 (Deprecation) and RFC 8594 (Sunset) HTTP response headers, with Swagger and OpenTelemetry integration
Maintainers
Readme
NestJS library for RFC 9745 (Deprecation) and RFC 8594 (Sunset) HTTP response headers — decorator-driven API deprecation with Swagger and OpenTelemetry integration.
TL;DR
Announce that an API endpoint is deprecated — and when it will be switched off — in a standard, machine-readable way, with a single decorator. Consumers learn about it from the response itself instead of a changelog or an email. You also get opt-in Swagger docs and an optional OpenTelemetry metric that answers "who is still calling this?" — two extra lines of setup each, see below.
npm install @camcima/nestjs-deprecationimport { Module, Controller, Get } from '@nestjs/common';
import { DeprecationModule, Deprecated } from '@camcima/nestjs-deprecation';
// 1. Register once in your root module.
@Module({ imports: [DeprecationModule.forRoot()] })
export class AppModule {}
// 2. Decorate the endpoint (or the whole controller).
@Controller('orders')
export class OrdersController {
@Deprecated({ deprecatedAt: '2026-07-01', sunsetAt: '2027-01-01', successor: '/v2/orders' })
@Get()
list() {
/* ... */
}
}Every response from the decorated endpoint now carries:
Deprecation: @1782864000
Sunset: Fri, 01 Jan 2027 00:00:00 GMT
Link: </v2/orders>; rel="successor-version"The library is strictly informational — it only adds headers (and optionally fires a telemetry hook); it never changes or blocks endpoint behavior. See Quick Start for the full walkthrough.
Table of Contents
- What are RFC 9745 / RFC 8594?
- Installation
- Quick Start
- Swagger integration
- Telemetry
- Strictly informational
- Known limitations
- API Reference
- License
What are RFC 9745 / RFC 8594?
RFC 9745 (published 2025) defines the Deprecation HTTP response header: a machine-readable signal that a resource has been, or will be, deprecated as of a given date. RFC 8594 defines the companion Sunset header: the date after which the resource is expected to stop responding. Together with an RFC 8288 Link header pointing clients at deprecation docs and/or a successor endpoint, they give API consumers a standard way to detect and react to deprecations without out-of-band communication (changelogs, emails, Slack messages).
To our knowledge, @camcima/nestjs-deprecation is the first NestJS implementation of RFC 9745.
A deprecated endpoint's response looks like this:
HTTP/1.1 200 OK
Deprecation: @1782864000
Sunset: Fri, 01 Jan 2027 00:00:00 GMT
Link: <https://docs.example.com/deprecations/orders-v1>; rel="deprecation", </v2/orders>; rel="successor-version"Deprecationcarries an RFC 9651 structured-field date:@followed by a Unix timestamp in seconds.Sunsetcarries an HTTP-date (IMF-fixdate) and is only sent when a sunset date is configured.Linkcarriesrel="deprecation"(RFC 9745, pointing at documentation) andrel="successor-version"(RFC 5829, pointing at the replacement), plus any other relations you add.
Installation
npm install @camcima/nestjs-deprecationpnpm add @camcima/nestjs-deprecationPeer dependencies
| Package | Version | Required |
| -------------------- | --------------------------------- | ------------------------------------------ |
| @nestjs/common | ^10.0.0 \|\| ^11.0.0 | Yes |
| @nestjs/core | ^10.0.0 \|\| ^11.0.0 | Yes |
| reflect-metadata | ^0.1.13 \|\| ^0.2.0 | Yes |
| @nestjs/swagger | ^7.0.0 \|\| ^8.0.0 \|\| ^11.0.0 | No (optional, for the ./swagger subpath) |
| @opentelemetry/api | >=1.8.0 | No (optional, for the ./otel subpath) |
Quick Start
Register DeprecationModule.forRoot() once in your root AppModule. It registers a global APP_INTERCEPTOR that writes deprecation headers on any handler or controller decorated with @Deprecated(). Accidentally importing it twice is tolerated — the first interceptor instance to see a request wins and later ones skip it, so headers and telemetry are never duplicated — but register it once, in the root module: which registration runs first follows module resolution order, so with two registrations it is the winner's options (and its onDeprecatedCall) that take effect.
// app.module.ts
import { Module } from '@nestjs/common';
import { DeprecationModule } from '@camcima/nestjs-deprecation';
@Module({
imports: [DeprecationModule.forRoot()],
})
export class AppModule {}Decorate a handler (or an entire controller class) with @Deprecated():
import { Controller, Get } from '@nestjs/common';
import { Deprecated } from '@camcima/nestjs-deprecation';
@Controller('orders')
export class OrdersController {
@Deprecated({
deprecatedAt: '2026-07-01T00:00:00Z',
sunsetAt: '2027-01-01T00:00:00Z',
link: 'https://docs.example.com/deprecations/orders-v1',
successor: '/v2/orders',
links: [{ rel: 'latest-version', href: '/v3/orders', type: 'application/json' }],
note: 'Use POST /v2/orders',
})
@Get()
list() {
/* ... */
}
}That produces exactly the response headers shown above:
Deprecation: @1782864000
Sunset: Fri, 01 Jan 2027 00:00:00 GMT
Link: <https://docs.example.com/deprecations/orders-v1>; rel="deprecation", </v2/orders>; rel="successor-version"(The links escape hatch above adds a third, custom rel="latest-version" link into the same header; it is omitted from the example response for brevity. note is never sent on the wire — it only surfaces in Swagger docs and in the telemetry event.)
@Deprecated() options
| Option | Type | Required | Description |
| -------------- | ---------------- | -------- | ---------------------------------------------------------------------------------------- |
| deprecatedAt | Date \| string | Yes | When the endpoint is (or will be) deprecated. May be in the future. |
| sunsetAt | Date \| string | No | When the endpoint stops working. Must not be earlier than deprecatedAt. |
| link | string | No | Deprecation documentation URL — emitted as Link; rel="deprecation" (RFC 9745). |
| successor | string | No | Replacement endpoint — emitted as Link; rel="successor-version" (RFC 5829). |
| links | LinkRelation[] | No | Escape hatch for arbitrary RFC 8288 relations, appended after link/successor. |
| note | string | No | Human note; never sent on the wire. Surfaces in Swagger docs and in the telemetry event. |
Both dates accept a Date or an ISO 8601 string. A string carrying a time must include a timezone designator (2026-07-01T00:00:00Z or ...+02:00); without one it would be read in the server's local timezone, making the emitted header depend on where the app runs. Date-only strings (2026-07-01) are unambiguous UTC and always fine.
Invalid options — unparseable or untyped dates (including a unix timestamp passed as a number), a sunsetAt before deprecatedAt, a malformed URL/path, a links entry repeating the link/successor relation — throw at decoration time, i.e. when your application boots, rather than on the first matching request, so misconfiguration fails loudly and early. @Deprecated() can decorate a single handler method or an entire controller class; a method-level decorator overrides a class-level one on that method. Applying it to anything else (a getter, a property, a static method) throws too, rather than silently doing nothing.
Async configuration
DeprecationModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
enabled: config.get<boolean>('DEPRECATION_HEADERS_ENABLED', true),
}),
});forRootAsync also accepts useClass or useExisting, pointing at a class that implements DeprecationOptionsFactory:
import { DeprecationModuleOptions, DeprecationOptionsFactory } from '@camcima/nestjs-deprecation';
@Injectable()
export class DeprecationConfig implements DeprecationOptionsFactory {
constructor(private readonly config: ConfigService) {}
createDeprecationOptions(): DeprecationModuleOptions {
return { enabled: this.config.get<boolean>('DEPRECATION_HEADERS_ENABLED', true) };
}
}
DeprecationModule.forRootAsync({ imports: [ConfigModule], useClass: DeprecationConfig });DeprecationModuleOptions accepts:
| Option | Type | Default | Description |
| ------------------ | ---------------------------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| enabled | boolean | true | Kill switch. forRoot({ enabled: false }) does not register the interceptor at all, so it costs nothing per request; forRootAsync resolves the flag at runtime and short-circuits instead. |
| onDeprecatedCall | (event) => void \| Promise<void> | — | Invoked on every request to a deprecated endpoint, inline before the handler runs. See Telemetry. Errors — thrown synchronously or via a rejected promise — are caught and logged; they never affect the response. Defer slow work off the request path. |
Swagger integration
The ./swagger subpath is optional and requires @nestjs/swagger (already a common dependency in NestJS apps). It discovers every @Deprecated()-decorated controller/handler via DiscoveryService and:
- Sets
deprecated: trueon the OpenAPI operation. - Appends a generated Markdown block to the operation description (deprecation date, sunset date,
note, links) — merged with, not clobbering, any@ApiOperation()you already applied. - Documents the
Deprecation/Sunset/Linkresponse headers with example values on every response of the operation. - Stamps the
x-sunsetextension on the operation whensunsetAtis set, so diff tools can enforce the deprecation window (see Breaking-change detection).
Add DiscoveryModule (from @nestjs/core) to your application module, create the OpenAPI document, and pass it through applyDeprecationDocs(document, app). The transform mutates and returns the given document instance only — it never touches decorator metadata, so you can build multiple differently-filtered documents in any order and each one is independent.
// app.module.ts
import { Module } from '@nestjs/common';
import { DiscoveryModule } from '@nestjs/core';
import { DeprecationModule } from '@camcima/nestjs-deprecation';
@Module({
imports: [DiscoveryModule, DeprecationModule.forRoot()],
})
export class AppModule {}// main.ts
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import { applyDeprecationDocs } from '@camcima/nestjs-deprecation/swagger';
const config = new DocumentBuilder().setTitle('My API').build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('/api', app, applyDeprecationDocs(document, app));applyDeprecationDocs(document, app, options?) accepts an optional filter callback to skip specific controllers per document (and xSunset, below):
const publicDocument = applyDeprecationDocs(SwaggerModule.createDocument(app, config), app, {
filter: (controller) => controller.name !== 'InternalController',
});If DiscoveryModule is not imported, applyDeprecationDocs throws a clear setup error naming the fix, rather than failing silently.
Routes are matched by recomputing each handler's route path. An application-wide setGlobalPrefix() is read from the application and applied exactly, and handlers inherited from a base controller class are documented like any other. Paths using route parameters, named wildcards (*splat), or optional-parameter groups ({/:id}) are translated to their OpenAPI form, and request methods beyond the OpenAPI eight (such as SEARCH) are covered. Handlers whose document path still cannot be resolved (e.g. custom URI versioning, per-route prefixes) fall back to an unambiguous suffix match, and are skipped with a logged warning rather than mis-annotated. One caveat: for documents built with the include option, a deprecated route excluded from the document can suffix-match a similarly named route from another module — prefer filtering with the filter callback (which skips the controller entirely) over relying on include alone.
Breaking-change detection (x-sunset)
Deprecating an operation is not a breaking change; removing it is. oasdiff — and other tools that diff two OpenAPI documents in CI — resolve that by reading an x-sunset extension alongside deprecated: true, and treating removal before that date as breaking:
/orders:
get:
deprecated: true
x-sunset: '2027-01-01'applyDeprecationDocs emits it for you from the sunsetAt you already passed to @Deprecated() — there is nothing extra to declare, and the extension cannot drift from the Sunset header or the description block, because all three derive from the same frozen value. The value is an RFC 3339 full-date (YYYY-MM-DD), the form oasdiff documents.
It is on by default: x-sunset is an inert OpenAPI extension, ignored by tooling that does not recognize it, and nothing about it reaches the wire. Opt out with xSunset: false, and note that an x-sunset you authored yourself (e.g. via @ApiExtension()) is left untouched:
applyDeprecationDocs(document, app, { xSunset: false });Two caveats. Operations deprecated without a sunsetAt get no x-sunset — which is correct, but under oasdiff's --deprecation-days-stable / --deprecation-days-beta grace-period enforcement the extension is mandatory, so those endpoints will fail that check until you give them a sunset date. And oasdiff currently reads x-sunset only at the operation level; a class-level @Deprecated() is fine, since it is stamped onto each of that controller's operations.
applyDeprecationDocs is independent of the enabled kill switch: it decorates the OpenAPI document at build time regardless of the runtime enabled setting, so if you disable the interceptor at runtime, stop calling applyDeprecationDocs too, to keep docs and runtime behavior in sync.
Telemetry
DeprecationModuleOptions.onDeprecatedCall fires once per request to a deprecated endpoint, after the response headers are written:
interface DeprecatedCallEvent {
method: string;
route: string; // route PATTERN, e.g. "/orders/:id" — not the concrete URL, to keep metric cardinality low
controllerName: string;
handlerName: string;
metadata: DeprecationMetadata; // the frozen, precomputed decorator options
isPastSunset: boolean;
}Use it for logging, custom metrics, or any sink you like:
DeprecationModule.forRoot({
onDeprecatedCall: (event) => {
myMetrics.increment('deprecated_requests', { route: event.route });
},
});./otel — ready-made OpenTelemetry listener
The ./otel subpath is optional and requires @opentelemetry/api. It calls only the OTel API (never creates or configures a MeterProvider) and defaults to the global meter registry:
import { DeprecationModule } from '@camcima/nestjs-deprecation';
import { createOtelDeprecationListener } from '@camcima/nestjs-deprecation/otel';
DeprecationModule.forRoot({
onDeprecatedCall: createOtelDeprecationListener(),
});Pass an explicit meterProvider if you don't want the global one:
createOtelDeprecationListener({ meterProvider });This registers a counter named http.server.deprecated_requests with attributes:
| Attribute | Type | Notes |
| ------------------------- | --------- | -------------------------------------------------------------------------------------------------------------------------- |
| http.request.method | string | e.g. "GET" |
| http.route | string | route pattern, e.g. "/orders/:id" |
| deprecation.past_sunset | boolean | whether the current time is past the configured sunset date |
| deprecation.sunset_date | string | ISO 8601 sunset date; omitted when no sunsetAt is set (keeps cardinality low — one value per route, not per request) |
Strictly informational
@camcima/nestjs-deprecation never changes endpoint behavior — not before the deprecation date, not after the sunset date. This is the purest reading of RFC 9745: "the act of deprecation does not change any behavior of the resource." There is no 410-enforcement mode, no brownout/probabilistic-failure mode, and no request blocking of any kind; the library only ever adds response headers and (optionally) fires a telemetry callback.
Enforcement behaviors like returning 410 Gone past sunset, or scheduled brownouts, were deliberately considered and left out of scope. If you need enforcement, build it on top of the onDeprecatedCall/isPastSunset hook rather than expecting this library to do it for you.
Known limitations
- Guards run before interceptors. A request rejected by a guard (401/403 from auth, 429 from throttling) never reaches the interceptor, so it carries no
Deprecation/Sunset/Linkheaders and fires noonDeprecatedCallevent. Clients whose credentials have expired — often the stalest integrations, and the ones most likely to be on a deprecated endpoint — will not see the signal, and migration dashboards undercount deprecated traffic by the guard-rejected share. - Decorator composition. The metadata is stored on the handler function itself. A third-party decorator that replaces
descriptor.valuewith a wrapper (rather than mutating it in place, as Nest's own decorators do) and is applied after@Deprecated()will drop the metadata, silently un-deprecating the endpoint. If you compose with wrapping decorators, keep@Deprecated()above them. - Header write ordering. The interceptor writes the
Deprecation/Sunset/Linkheaders before calling the route handler (next.handle()), so that they still land on thrown exceptions and streaming responses. A consequence: anything that sets aLinkheader after that point — e.g. inside the handler body itself, or in an interceptor registered to run closer to the handler — will overwrite rather than merge with the deprecationLinkvalue. Middleware or an interceptor registered beforeDeprecationModule's (so it runs first) is appended to correctly instead of overwritten.
API Reference
| Export | Kind | Description |
| ------------------------------- | ---------------- | ------------------------------------------------------------------------------------------------- |
| Deprecated | Decorator | Method or class decorator; emits DeprecatedOptions as metadata |
| DeprecationModule | Class | Dynamic module. Use forRoot(options?) or forRootAsync(options) |
| DeprecationInterceptor | Injectable class | Global interceptor registered by DeprecationModule; writes headers and fires onDeprecatedCall |
| DeprecatedOptions | Interface | Options accepted by @Deprecated() |
| DeprecationMetadata | Interface | Precomputed, frozen wire values stored as Reflect metadata |
| DeprecatedCallEvent | Interface | Shape of the event passed to onDeprecatedCall |
| DeprecatedCallListener | Type | `(event: DeprecatedCallEvent) => void \| Promise<void>` |
| DeprecationModuleOptions | Interface | Options accepted by forRoot() |
| DeprecationModuleAsyncOptions | Interface | Options accepted by forRootAsync() (useFactory, useClass, or useExisting) |
| DeprecationOptionsFactory | Interface | Implemented by the class given to forRootAsync({ useClass }) / ({ useExisting }) |
| LinkRelation | Interface | { rel: string; href: string; type?: string } — one entry in links |
| DEPRECATION_METADATA_KEY | Constant | Reflect metadata key under which @Deprecated() stores DeprecationMetadata |
| DEPRECATION_MODULE_OPTIONS | Symbol | DI token for the module options |
Swagger subpath (@camcima/nestjs-deprecation/swagger):
| Export | Kind | Description |
| ----------------------------- | --------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| applyDeprecationDocs | Function | Transforms the given OpenAPI document: marks @Deprecated() operations deprecated and documents their response headers. Signature: applyDeprecationDocs(document, app, options?) → returns the document. |
| DeprecationDocumentLike | Interface | Minimal structural view of the OpenAPI document accepted by applyDeprecationDocs |
| ApplyDeprecationDocsOptions | Interface | Options for applyDeprecationDocs (filter) |
| DiscoveredController | Interface | Structural controller view passed to the filter option |
OTel subpath (@camcima/nestjs-deprecation/otel):
| Export | Kind | Description |
| -------------------------------- | --------- | ---------------------------------------------------------------------- |
| createOtelDeprecationListener | Function | Returns an onDeprecatedCall listener that increments an OTel counter |
| OtelDeprecationListenerOptions | Interface | Options for createOtelDeprecationListener (meterProvider) |
| DEPRECATED_REQUESTS_METRIC | Constant | 'http.server.deprecated_requests' |
