NestJS Serialization Patterns

How I structure class-transformer groups, ClassSerializerInterceptor, role-based field exposure, pagination envelopes, and serialization-driven API versioning in NestJS, with the war stories that pushed me there.

A Wednesday afternoon at the live-video creator platform I led engineering at. A German creator tweeted a screenshot of his own profile preview, except the photo and tagline were someone else’s. EU users were seeing US users’ Open Graph cards and the other way around. The cache layer at the edge was happily mixing them up because someone (me, on review) had approved a key change that dropped locale from the composition. The thing is, none of it would have mattered if the serialization layer had been treating locale as part of the response identity.

I think about that one a lot when I’m wiring up serialization in NestJS. Most of the bugs I’ve shipped around response shape weren’t about wire format. They were about who the response was for and which version of “the same object” the client got back. Here’s how I structure it.

Use groups, not five DTO classes

NestJS gives you ClassSerializerInterceptor and class-transformer decorators out of the box, and the temptation is to ignore them and make a fresh UserPublicDto, UserAdminDto, UserBillingDto for every audience. Don’t. You end up with five classes that drift, five mappers, and five places to forget a new field.

Lean on groups instead. One entity, one set of @Expose({ groups: [...] }) declarations, and the interceptor decides which groups to apply per request.

import { Exclude, Expose, Type } from 'class-transformer';

@Exclude()
export class UserSerialized {
  @Expose({ groups: ['public', 'admin', 'self'] })
  id!: string;

  @Expose({ groups: ['public', 'admin', 'self'] })
  displayName!: string;

  @Expose({ groups: ['admin', 'self'] })
  email!: string;

  @Expose({ groups: ['admin'] })
  stripeCustomerId!: string;

  @Expose({ groups: ['admin'] })
  internalNotes!: string;

  @Type(() => SubscriptionSerialized)
  @Expose({ groups: ['admin', 'self'] })
  subscriptions!: SubscriptionSerialized[];
}

The default is @Exclude() at the class level. Nothing leaks unless I opted in. The first time I tried this with @Expose() as the default, a junior on my squad added a passwordResetToken column. Build green, tests green, field went out on a /me response. Caught it in code review the next morning. Rule got flipped that afternoon and has stayed flipped.

The interceptor reads the actor

The group decision can’t be made on the controller. It has to come from the authenticated actor, the same place the audit log gets its who. I write a thin wrapper around ClassSerializerInterceptor that pulls groups from req.user.

import {
  ClassSerializerInterceptor,
  ExecutionContext,
  Injectable,
  PlainLiteralObject,
} from '@nestjs/common';
import { ClassTransformOptions } from 'class-transformer';

@Injectable()
export class ActorAwareSerializer extends ClassSerializerInterceptor {
  serialize(
    response: PlainLiteralObject | PlainLiteralObject[],
    options: ClassTransformOptions,
  ): PlainLiteralObject | PlainLiteralObject[] {
    const ctx = (options as any).__ctx as ExecutionContext | undefined;
    const req = ctx?.switchToHttp().getRequest();
    const actor = req?.user;
    const groups = resolveGroups(actor, req?.params);
    return super.serialize(response, { ...options, groups });
  }
}

function resolveGroups(actor: any, params: any): string[] {
  if (!actor) return ['public'];
  if (actor.role === 'admin') return ['admin'];
  if (params?.id && params.id === actor.sub) return ['self'];
  return ['public'];
}

There’s a small __ctx trick I attach in a base interceptor so serialize can see the request, because by default ClassSerializerInterceptor doesn’t hand it down. It’s a minor wart, worth it for one place that decides exposure.

The reason this lives in serialization and not in the service is honest: the service should return the full entity. Filtering by audience at the service layer means every service method has to know about roles, and that knowledge bleeds everywhere. Filter at the boundary, once.

Nested and circular references

@Type() is the only way class-transformer knows how to recurse into nested objects. Forget it and you get plain shapes back, group decorations ignored, fields leaking. I’ve debugged that on a Sunday. It is not fun.

Circular references are the other trap. A User has subscriptions, a Subscription has a back-pointer to creator, and if both sides expose the other under any group, you serialize until the stack gives up. The fix is asymmetric exposure: the parent declares the child, the child declares only its id and a thin reference shape.

@Exclude()
export class SubscriptionSerialized {
  @Expose({ groups: ['admin', 'self', 'creator'] })
  id!: string;

  @Expose({ groups: ['admin', 'self', 'creator'] })
  status!: 'active' | 'past_due' | 'canceled';

  @Expose({ groups: ['admin', 'self'] })
  appleOriginalTransactionId!: string;

  // Reference only. Never inline the user from inside the subscription.
  @Expose({ groups: ['admin'] })
  userId!: string;
}

You’ll know the moment this matters because your response payload will balloon by 20x and your p99 will follow it.

Pagination envelopes belong in the response, not the controller

Every controller I’ve worked on eventually gets the question: where does pagination metadata live? I’d argue you want one envelope, applied at the interceptor layer, never duplicated in handlers. Handlers return { items, total, cursor }, the interceptor wraps it.

import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
import { map, Observable } from 'rxjs';

interface Paged<T> {
  items: T[];
  total?: number;
  cursor?: string | null;
}

@Injectable()
export class PaginationEnvelopeInterceptor implements NestInterceptor {
  intercept(ctx: ExecutionContext, next: CallHandler): Observable<unknown> {
    return next.handle().pipe(
      map((value) => {
        if (!isPaged(value)) return value;
        const req = ctx.switchToHttp().getRequest();
        return {
          data: value.items,
          meta: {
            total: value.total ?? null,
            cursor: value.cursor ?? null,
            nextUrl: value.cursor
              ? `${req.baseUrl}${req.path}?cursor=${value.cursor}`
              : null,
          },
        };
      }),
    );
  }
}

function isPaged(v: any): v is Paged<unknown> {
  return v && Array.isArray(v.items) && ('total' in v || 'cursor' in v);
}

Cursor-first, count is optional. On a multi-terabyte Aurora writer at the creator economy platform I worked at, SELECT COUNT(*) on hot tables was the kind of query that made the on-call’s pager an instrument of cruelty. The envelope makes count opt-in, the controller decides whether to pay for it, the client gets the same shape either way.

Versioning lives in the serializer, not the URL

I’ve been on both sides of the /v1, /v2 debate and I keep landing in the same place: route-based versioning is fine for the obvious breaking changes (a totally new resource shape, an auth scheme switch). For the rest, which is most of what actually changes, serialize against a header-driven version selector and keep the URL stable.

A serialization-driven version is just another group, plus a couple of @Transform calls when the field name itself changes.

import { Transform } from 'class-transformer';

@Exclude()
export class CreatorSerialized {
  @Expose({ groups: ['public'] })
  id!: string;

  @Expose({ groups: ['public', 'v1'] })
  @Transform(({ obj }) => obj.handle, { groups: ['v1'] })
  username!: string;

  @Expose({ groups: ['public', 'v2'] })
  handle!: string;
}

The interceptor reads Accept-Version (or a custom X-API-Version header), maps it to a group, and adds it alongside the actor group. Clients pinned to v1 keep getting username. Clients on v2 get handle. The entity has both, the service knows neither.

When the cache and the serializer disagree

Back to the OG-preview incident. The actual fix lived in two places. The edge worker started including locale and an og_version token in its cache key, which is the half people remember. The half I remember is the serializer. The payload had been rendered with locale-derived copy, but the serializer’s output didn’t carry locale forward in a way the cache layer could see. Once I started treating the serialized response as the unit of cacheability, the key shape became obvious: it’s whatever the serializer’s group context was. Locale, version, role. If those three change, the cache entry is a different object. The rollback was instant. The redeploy took a day because we wrote a CI check that diffs cache-key composition against the previously deployed worker and refuses the deploy when the key changes without an explicit flag. About 40 minutes of mis-shared previews, a handful of public screenshots, and a lesson that’s followed me into every NestJS API since.

The duplicate subscription that taught me about response shape

On the branded mobile apps product at the creator economy platform I worked at, a creator opened a ticket: every customer was charged twice and the app showed them as having two active subscriptions. The renewal notification from Apple had been retried after our endpoint returned a 200 OK slightly past the 30 second deadline. We had no idempotency check on the renewal handler. A few thousand customers across dozens of branded apps ended up with duplicate creator_subscriptions rows.

The first patch went to the frontend: show only the latest subscription per customer. Rows were still duplicated, Apple had still billed every card, and within a day a creator escalated to legal. The visible fix was treating a serialization choice as if it were a data fix.

The real fix was three things: a unique constraint on (apple_original_transaction_id, notification_uuid), an async handler that returned 200 OK within 5 seconds and did the work behind the queue, and a serializer that returned the canonical subscription with previousAttempts as a separate field. Refunds took about four days through Apple’s developer support API.

The serializer wasn’t the cause. The serializer was where the lie was easiest to tell.

Takeaways

  • One serialized class per entity, @Exclude() at the class level, @Expose({ groups }) per field. Don’t fork DTOs per audience.
  • The interceptor picks groups from the authenticated actor. Never trust a query param or header for exposure.
  • @Type() on every nested field. Asymmetric exposure on back-pointers. No infinite recursion.
  • Pagination envelope at the interceptor. Cursor first, count opt-in.
  • Versioning belongs in groups and @Transform, not in the URL, for everything except the genuinely breaking changes.
  • Cache identity equals serializer group context. If locale, role, or version are part of the response, they’re part of the key.

Thanks for reading. If you’ve got thoughts, send them my way.

© 2026 Akin Gundogdu. All Rights Reserved.