Skip to content

Serialization Wiring

Recipes are working wiring examples with the sharp edges annotated. This page and the serialization-wiring recipe 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-serializationHttpSerializationModule (no config, no pools — @SerializeWith writes metadata at import time). Surface: the abstract Resource<T> class (serialize(value, context) → SerializedValue plus protected helpers one(resource, value, context), many(resource, values, context), whenGroup(context, group, fields)), the @SerializeWith(ResourceClass, { groups? }) method decorator, addSerializationGroups(request, ...groups) for runtime group activation, and serializeResource(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.

typescript
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 }).