Skip to content

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.

typescript
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 — returns null when the value is null/undefined.
  • this.many(ResourceClass, values, context) serializes a collection — returns [] when the collection is null/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:

typescript
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, and null/undefined return values are untouched.
  • Composes with the rest of the pipeline: @SerializeWith is implemented as a synthetic per-site interceptor on the existing @UseInterceptor metadata 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:

  1. Static — @SerializeWith(UserResource, { groups: ['admin'] }): always active for that route.
  2. Runtime — addSerializationGroups(request, ...groups): call from a guard, middleware, or the handler itself to activate groups for the current request.
typescript
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:

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

typescript
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):

typescript
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,
];