Skip to main content

Command Palette

Search for a command to run...

NestJS Custom Decorators and Pipes: Stop Sending Context in Query Params

A senior pattern for resolving request-scoped data server-side with createParamDecorator, async pipes, and one composed decorator

Updated
9 min readView as Markdown
NestJS Custom Decorators and Pipes: Stop Sending Context in Query Params
E

Developing web/mobile applications, from the server-side to the client-side. With 6 years of experience in software development. I am skilled in using a range of data mining language and technologies working as Big Data Engineer for 4 years.

Every request in our e-commerce API used to look like this:

GET /api/cart/items?storeKey=store-fr&country=FR&currency=EUR

The frontend computed storeKey, country, and currency from the logged-in user's profile and attached them to every single request. Cart, checkout, orders, wishlist — same three query params, everywhere.

This is a quiet design smell, and one day we admitted it had real costs:

  1. Trust. Query params are user input. Nothing stops a client from sending currency=XXX and seeing prices in a market they don't belong to. We were re-validating on the backend what the backend already knew.

  2. Duplication. Every frontend hook re-implemented the same "resolve store from user profile" logic.

  3. Leaky ownership. The backend owns pricing and stock, but the frontend decided which store and currency to query.

The fix: resolve that context server-side, and expose it to route handlers with one decorator:

@Get('cart')
getCart(@ShopperContext() ctx: ShopperContext) {
  // ctx.storeKey, ctx.country, ctx.currency — resolved server-side
}

One clarification before we go further, because it's a common misconception:

The JWT is the identity, not the data source. The only thing we take from the token is the user's email (the sub claim), extracted by the auth guard during validation. Everything else — store, country, currency, locale — is fetched from our own storage (Redis cache, PostgreSQL fallback) using that email. You should never stuff preferences into the JWT itself: they'd go stale until the token expires, and they bloat every request's header.

This post explains the pattern, why it's built from two NestJS building blocks instead of one, and the pitfalls we hit so you don't have to.

The scenario

Our fictional shop, Shopland, is a NestJS API. Accounts are stored in PostgreSQL (home store, locale, country) and profiles are cached in Redis. A JWT AuthGuard already validates the token and does the classic:

request['user'] = { email: payload.sub, roles: [...] };

Every cart endpoint needs a ShopperContext:

export interface ShopperContext {
  accountId: string;
  storeKey: string;   // derived: `store-${country.toLowerCase()}`
  country: string;
  currency: string;   // derived from country
  locale: string;
  homeStoreId: string | null;
}

The requirements: resolve it once per request, from cache-first storage, and make it impossible to misuse by the ten developers who'll add endpoints next quarter.

The NestJS request lifecycle is the key

Where you hook into the pipeline decides what you're allowed to do:

Middleware → Guards → Interceptors (pre) → Pipes → Handler → Interceptors (post) → Filters

Two lifecycle facts drive the whole design:

  • Pipes run after guards. When our pipe executes, AuthGuard has already populated request.user. Ordering is free.

  • Pipes transform handler arguments. Per the official docs, pipes exist for exactly two things: transformation and validation of route-handler parameters.

Why not a guard, interceptor, or middleware?

I see these misused constantly, so let's be precise about what the docs say each block is for:

Building block Documented purpose Async + DI? Verdict for our case
Guard "Determine whether a given request will be handled" — authorization Off-label. It can attach data (the docs' own auth example does request['user'] = payload), but fetching a profile isn't an allow/deny decision
Interceptor Bind logic before/after execution, transform the response, caching Works, but semantics are cross-cutting concerns, not argument resolution
Middleware Framework-level pre-processing limited ❌ Runs before guards — request.user doesn't exist yet
Pipe "Transformation: transform input data to the desired form" ✅ Textbook fit: transform the authenticated identity into a rich context object
REQUEST-scoped provider Lazy per-request injection Valid, but it makes every consumer request-scoped too (new instance graph per request — the docs explicitly warn about the perf cost)

Using a guard to fetch data "because the auth example does it" is the most common justification I hear. The auth example attaches the by-product of a decision it already made. Fetching a profile is not a decision — it's a transformation. Stay in your lane, and the codebase stays teachable.

The two-piece constraint

Here's the trap that forces the design:

  • Param decorator factories are synchronous and have no dependency injection. createParamDecorator can see the request, but it can't call your ShopperProfileService.

  • Pipes support async and DI, but they're blind: a pipe only receives whatever value the decorator extracted. It can't reach the request on its own.

So we chain them: decorator extracts → pipe transforms.

⚠️ You may read that async param-decorator factories "work" because Nest awaits the factory result internally. That's undocumented behavior — don't build on it.

Implementation

1. The service: cache-first profile lookup

@Injectable()
export class ShopperProfileService {
  constructor(
    @InjectRepository(Account) private readonly accounts: Repository<Account>,
    @Inject(CACHE_MANAGER) private readonly cache: Cache,
  ) {}

  /** Returns the cached ShopperProfile, or the Account entity on cache miss. */
  async findProfile(email: string): Promise<ShopperProfile | Account> {
    const key = `shopper:profile:${hash(email.toLowerCase().trim())}`;

    try {
      const userCached = await this.cache.get<ShopperProfile>(key);
      if (userCached) return userCached;
    } catch {
      // cache is an optimization — fall through to the database
    }

    const account = await this.accounts.findOne({
      where: { email: email.toLowerCase().trim() },
    });
    if (!account) throw new NotFoundException(`No profile for ${email}`);
    return account;
  }
}

Note it returns a union — cache gives you a plain object, TypeORM gives you an entity instance. The pipe handles both.

2. The param decorator: extract the raw request

export const RawRequest = createParamDecorator(
  // _data must stay: Nest always calls the factory as (data, ctx).
  // The placeholder keeps ctx in the second position.
  (_data: unknown, ctx: ExecutionContext): ShopperContextRequest =>
    ctx.switchToHttp().getRequest<ShopperContextRequest>(),
);

Why the whole request and not just the email? Because the pipe needs it twice: to read request.user.email, and to write back request.shopperContext at the end (so interceptors or downstream code can use it too).

3. The pipe: async transformation

@Injectable()
export class ShopperContextPipe implements PipeTransform<
  ShopperContextRequest,
  Promise<ShopperContext>
> {
  constructor(private readonly profiles: ShopperProfileService) {}

  async transform(request: ShopperContextRequest): Promise<ShopperContext> {
    const email = request.user?.email;
    if (!email) {
      throw new UnauthorizedException('Is AuthGuard applied?');
    }

    const source = await this.profiles.findProfile(email);

    const context =
      source instanceof Account            // entity instance → DB path
        ? this.buildFromAccount(source)
        : this.buildFromCache(source);     // plain object → cache path

    request.shopperContext = context;      // side effect: available downstream
    return context;
  }

  private buildFromAccount(account: Account): ShopperContext {
    const country = account.countryCode ?? 'US';
    return {
      accountId: account.id,
      storeKey: `store-${country.toLowerCase()}`,
      country,
      currency: currencyForCountry(country),
      locale: account.locale ?? 'en-US',
      homeStoreId: account.homeStoreId ?? null,
    };
  }
  // buildFromCache mirrors this for the cached shape...
}

instanceof Account is a clean discriminator: TypeORM returns real entity instances, your cache returns deserialized plain objects.

4. The wrapper: making the right thing effortless

You could stop here and write @RawRequest(ShopperContextPipe) at every call site. Don't. There's a foot-gun TypeScript cannot catch:

@Get('cart')
getCart(@RawRequest() ctx: ShopperContext) { ... }
//      ↑ forgot the pipe — compiles fine, and at runtime
//        ctx is the RAW REQUEST, not a ShopperContext 💥

There's zero type linkage between a custom decorator's factory output and the handler's declared parameter type. So bundle extractor + pipe into one named decorator:

export function ShopperContext(): ParameterDecorator {
  return RawRequest(ShopperContextPipe);
}

Now the correct usage is also the shortest usage. That's not cosmetic — it's API design: you made the mistake unrepresentable.

The result

Before — client-sent context, validated defensively:

@Get('cart')
getCart(
  @Query('storeKey') storeKey: string,     // who do you trust?
  @Query('currency') currency: string,
) { ... }

After — server-resolved, one line:

@UseGuards(AuthGuard) // validates JWT, sets request.user.email
@Get('cart')
getCart(@ShopperContext() ctx: ShopperContext) {
  return this.cartService.getCart(ctx);
}

The frontend deletes its context-building hooks. The backend owns country/currency resolution — as it should, since it owns pricing.

Pitfalls I hit (so you don't)

  1. Unit tests break in a surprising way. Nest instantiates param-pipe classes when the testing module compiles. A bare Test.createTestingModule({ controllers: [CartController] }) will fail with "can't resolve dependencies of ShopperContextPipe". Fix: register the dependency as a mock provider — { provide: ShopperProfileService, useValue: { findProfile: vi.fn() } }. Same behavior applies to @UseGuards classes.

  2. You can't remove the _data parameter from the decorator factory. Nest always calls it as (data, ctx) — the first slot is positional. Delete it and your ctx receives the decorator argument instead. Runtime crash, every request.

  3. applyDecorators() does not work for parameter decorators. It composes class/method decorators only. The function wrapper above is the correct mechanism.

  4. Ordering matters but is automatic: pipes always run after guards, so @UseGuards(AuthGuard) on the controller is enough. Just don't try the same trick in middleware — it runs before guards.

Why this respects NestJS guidelines

  • Pipes do what pipes are documented to do — transform input data into the desired form. email → ShopperContext is exactly that.

  • Guards keep their single responsibility (allow/deny). We didn't smuggle data-fetching into one.

  • Each block is testable in isolation: the service with a mocked repository, the pipe with a mocked service, the decorator by invoking its stored factory from ROUTE_ARGS_METADATA.

  • No Scope.REQUEST provider, avoiding the hidden per-request instantiation cost the docs warn about.

Key takeaways

  • Query params are user input. If the server already knows something, don't ask the client for it.

  • The JWT carries identity (email), your storage carries state (profile). Don't blur that line.

  • createParamDecorator extracts (sync, no DI) → pipes transform (async, DI) → a wrapper decorator fuses them so the pipe can't be forgotten.

  • Choose building blocks by their documented purpose, not by what you can technically force them to do. Future maintainers — including you in six months — will read semantics, not just code.

More from this blog

Ernesto — Backend Engineering & Beyond

7 posts

Backend Engineer with 12+ years building scalable systems in Node.js, NestJS, TypeScript, and GCP. I write about backend architecture, API design, data pipelines, DevOps, and the engineering decisions that matter in production. Expect practical posts grounded in real fintech and SaaS experience — SQL/NoSQL, distributed systems, cloud infrastructure, and occasionally the full stack. No filler, just engineering.