Serialization
@modularityjs/http-serialization shapes outbound HTTP responses. Handlers return raw domain entities; a Resource class decides what goes on the wire. Entities stay decorator-free — no @Expose/@Exclude annotations on domain classes, no reflection over entity fields. Serialization is explicit, plain code.
Resources
A resource is a stateless class extending Resource<T> with an explicit serialize() method. Subclasses must be constructible with bare new (no constructor parameters) — they are instantiated lazily once per @SerializeWith site and reused.
import { Resource } from '@modularityjs/http-serialization';
import type {
SerializationContext,
SerializedValue,
} from '@modularityjs/http-serialization';
class AddressResource extends Resource<Address> {
serialize(address: Address): SerializedValue {
return { city: address.city, country: address.country };
}
}
class UserResource extends Resource<User> {
serialize(user: User, context: SerializationContext): SerializedValue {
return {
id: user.id,
name: user.name,
// Nested resources — null-safe helpers
address: this.one(AddressResource, user.address, context),
orders: this.many(OrderResource, user.orders, context),
// Conditional fields — only when the 'admin' group is active
...this.whenGroup(context, 'admin', () => ({ email: user.email })),
};
}
}Nesting
this.one(ResourceClass, value, context)serializes a single nested value — returnsnullwhen the value isnull/undefined.this.many(ResourceClass, values, context)serializes a collection — returns[]when the collection isnull/undefined.
Conditional fields
this.whenGroup(context, group, fields) is a spreadable conditional: it returns {} unless the group is active, so spread it into the result object. Where the active groups come from is covered in Serialization groups below.
Undefined stripping
undefined values are stripped (shallowly) from serialized objects — spread-based conditionals naturally produce holes, and stripping keeps them off the wire.
@SerializeWith
@SerializeWith(ResourceClass, options?) is the opt-in. The handler returns the raw entity (or an array of them); the framework serializes it through the resource before the wire:
import { Controller, Get, Params } from '@modularityjs/http';
import { SerializeWith } from '@modularityjs/http-serialization';
@Controller('/users')
class UsersController {
@Get('/:id')
@SerializeWith(UserResource)
async show(@Params('id') id: string) {
return this.users.findById(id); // raw entity — serialized on the way out
}
@Get('/')
@SerializeWith(UserResource)
async list() {
return this.users.findAll(); // arrays map element-wise
}
@Get('/admin')
@SerializeWith(UserResource, { groups: ['admin'] }) // static groups for this route
async adminList() {
return this.users.findAll();
}
}Behavior:
- Arrays map element-wise — each element goes through the resource.
- Pass-through:
FileResponse,HtmlResponse,SseResponse,RedirectResponse, andnull/undefinedreturn values are untouched. - Composes with the rest of the pipeline:
@SerializeWithis implemented as a synthetic per-site interceptor on the existing@UseInterceptormetadata channel — method-level interceptors are outermost, so serialization is the last transformation on the way out.@StatusCode,@SetHeader, and error filters all work unchanged (a thrown exception bypasses serialization and goes through the error-filter chain).
Serialization groups
Groups gate conditional fields (whenGroup). Two sources, merged (deduplicated) at serialization time:
- Static —
@SerializeWith(UserResource, { groups: ['admin'] }): always active for that route. - Runtime —
addSerializationGroups(request, ...groups): call from a guard, middleware, or the handler itself to activate groups for the current request.
import { Injectable } from '@modularityjs/di';
import type { Guard, HttpRequest } from '@modularityjs/http';
import { addSerializationGroups } from '@modularityjs/http-serialization';
@Injectable()
class AdminGroupGuard implements Guard<HttpRequest> {
activate(request: HttpRequest): boolean {
if (this.isAdmin(request)) {
addSerializationGroups(request, 'admin');
}
return true; // never blocks — only widens the serialized shape
}
}Manual serialization
serializeResource() is the escape hatch for custom envelopes, pagination shapes, and non-HTTP use — skip @SerializeWith and call it yourself:
import { serializeResource } from '@modularityjs/http-serialization';
@Get('/')
async page(@Query('page') page: string) {
const { rows, total } = await this.users.page(Number(page));
return {
data: serializeResource(UserResource, rows),
meta: { total, page: Number(page) },
};
}Same semantics as the decorator: arrays map element-wise, null/undefined pass through unchanged, undefined fields are stripped. Pass groups via the third argument: serializeResource(UserResource, rows, { groups: ['admin'] }).
OpenAPI seam
Resource carries an optional static schema — the response Schema this resource produces. It is not read by the serializer; it exists so the resource can be the single source of truth for OpenAPI response documentation:
import { apiSchema, ApiResponse } from '@modularityjs/openapi';
class UserResource extends Resource<User> {
static override schema = apiSchema('User', userResponseSchema);
serialize(user: User): SerializedValue {
return { id: user.id, name: user.name };
}
}
@Get('/:id')
@SerializeWith(UserResource)
@ApiResponse(200, { schema: UserResource.schema })
async show(@Params('id') id: string) {
return this.users.findById(id);
}Module wiring
HttpSerializationModule imports HttpModule and nothing else — no providers, pools, preferences, or config (@SerializeWith writes metadata at import time and its synthetic interceptor is self-contained):
import { HttpModule } from '@modularityjs/http';
import { HttpFastifyModule } from '@modularityjs/http-fastify';
import { HttpSerializationModule } from '@modularityjs/http-serialization';
const modules = [
HttpModule.forRoot({ port: 3000 }),
HttpFastifyModule,
HttpSerializationModule,
];