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

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

```plaintext
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:

```ts
@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:

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

Every cart endpoint needs a `ShopperContext`:

```ts
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:

```plaintext
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

```typescript
@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

```typescript
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

```ts
@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:

```ts
@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:

```ts
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:

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

**After** — server-resolved, one line:

```typescript
@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.
