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

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¤cy=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:
Trust. Query params are user input. Nothing stops a client from sending
currency=XXXand seeing prices in a market they don't belong to. We were re-validating on the backend what the backend already knew.Duplication. Every frontend hook re-implemented the same "resolve store from user profile" logic.
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
subclaim), 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,
AuthGuardhas already populatedrequest.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.
createParamDecoratorcan see the request, but it can't call yourShopperProfileService.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)
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@UseGuardsclasses.You can't remove the
_dataparameter from the decorator factory. Nest always calls it as(data, ctx)— the first slot is positional. Delete it and yourctxreceives the decorator argument instead. Runtime crash, every request.applyDecorators()does not work for parameter decorators. It composes class/method decorators only. The function wrapper above is the correct mechanism.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 → ShopperContextis 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.REQUESTprovider, 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.
createParamDecoratorextracts (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.



