Serialization Wiring
Recipes are working wiring examples with the sharp edges annotated. This page and the
serialization-wiringrecipe served to coding agents by the MCP server share one source (@modularityjs/manifest), so they never drift apart.
One extension package on top of HTTP:
@modularityjs/http-serialization—HttpSerializationModule(no config, no pools —@SerializeWithwrites metadata at import time). Surface: the abstractResource<T>class (serialize(value, context) → SerializedValueplus protected helpersone(resource, value, context),many(resource, values, context),whenGroup(context, group, fields)), the@SerializeWith(ResourceClass, { groups? })method decorator,addSerializationGroups(request, ...groups)for runtime group activation, andserializeResource(resource, value, context?)as the manual escape hatch (custom envelopes, pagination shapes, non-HTTP use).
A Resource is an explicit, stateless serializer for one domain shape — no decorators on entities, no reflection; serialize() is plain code, and fields are an allowlist by construction (a secret column never leaks because it was never listed). The handler returns the raw entity (or an array — arrays map element-wise); the framework serializes it through the resource as the last transformation on the way out. Special return types (FileResponse, HtmlResponse, SseResponse, RedirectResponse) and null/undefined pass through untouched.
import { Injectable } from '@modularityjs/di';
import { inversify } from '@modularityjs/di-inversify';
import {
Controller,
Get,
HttpControllersPool,
HttpModule,
UseGuard,
type Guard,
type HttpRequest,
} from '@modularityjs/http';
import { HttpFastifyModule } from '@modularityjs/http-fastify';
import {
addSerializationGroups,
HttpSerializationModule,
Resource,
SerializeWith,
type SerializationContext,
type SerializedValue,
} from '@modularityjs/http-serialization';
import { createApp, Module, ModularityModule } from '@modularityjs/modularity';
interface User {
id: string;
name: string;
email: string;
passwordHash: string;
}
class UserResource extends Resource<User> {
serialize(user: User, context: SerializationContext): SerializedValue {
return {
id: user.id,
displayName: user.name,
// passwordHash is never listed — omitted by construction.
...this.whenGroup(context, 'admin', () => ({ email: user.email })),
};
}
}
@Injectable()
class AdminGroupGuard implements Guard<HttpRequest> {
activate(request: HttpRequest): boolean {
addSerializationGroups(request, 'admin'); // runtime group activation
return true;
}
}
@Controller('/users')
class UsersController {
@Get('/')
@SerializeWith(UserResource)
async list(): Promise<User[]> {
return [
{ id: 'u1', name: 'Alice', email: 'a@example.com', passwordHash: 'x' },
];
}
@Get('/admin')
@UseGuard(AdminGroupGuard)
@SerializeWith(UserResource)
async adminList(): Promise<User[]> {
return this.list();
}
}
@Module({
name: 'users',
imports: [HttpModule, HttpSerializationModule],
providers: [AdminGroupGuard, UsersController],
pools: [
{ pool: HttpControllersPool, key: 'users', useClass: UsersController },
],
})
class AppModule {}
const app = await createApp({
di: inversify,
modules: [
ModularityModule,
HttpModule.forRoot({ port: 3000 }),
HttpFastifyModule,
HttpSerializationModule,
AppModule,
],
});
await app.start();Groups are the conditional-field primitive, with two activation channels. Static: @SerializeWith(UserResource, { groups: ['admin'] }) — always active on that route. Runtime: addSerializationGroups(request, 'admin') from a guard, middleware, or the handler itself — active for this request only. Both are merged and deduped at serialization time, and whenGroup spreads {} unless the group is active. undefined values are stripped shallowly from serialized objects, so spread-based conditionals naturally produce holes, not nulls.
Resources must be constructible with bare new. They are instantiated lazily once per @SerializeWith site and reused — no constructor parameters, no per-request state. Nest via one / many (both null-safe: one yields null, many yields []), never by calling new OtherResource().serialize(...) by hand. For OpenAPI, the optional static schema on a Resource is the seam for @ApiResponse(200, { schema: UserResource.schema }).