Skip to content

NestJS Example Application ​

A complete, production-style NestJS application demonstrating every super-http integration pattern in a real context. Use it as a reference implementation or starting point for your own project.

Source: example/nestjs-app/


What it demonstrates ​

PatternWhere
SuperHttpModule.forRootAsync + useClasssrc/app.module.ts
SuperHttpOptionsFactory implementationsrc/config/super-http.config.ts
Default SuperHttpService injectionsrc/users/users.service.ts
Named clients with SuperHttpModule.forFeaturesrc/posts/posts.module.ts
@InjectSuperHttp('NAME') decoratorsrc/posts/posts.service.ts
Per-request policy on mutationssrc/posts/posts.service.ts
Live metrics health endpointsrc/health/health.controller.ts
Unit tests with mock providers**/*.spec.ts
Integration (e2e) teststest/app.e2e-spec.ts

Module dependency diagram ​

How the NestJS modules are wired together. SuperHttpModule is @Global() — the default SuperHttpService is available everywhere once registered in AppModule.


HTTP client topology ​

Each client has its own independent connection pool, circuit breaker, retry engine, and bulkhead. Failures in one client never affect the others.


Request lifecycle ​

GET /api/posts/:id/with-comments fetches post data and comments in parallel from two independent clients. Both requests run through their own resilience pipelines.


Circuit breaker state machine ​

The COMMENTS client uses a circuit breaker to avoid waiting for a down service. When it trips, requests fail immediately at ~1ms instead of waiting for a timeout.


Retry strategy ​

Both the default client and the COMMENTS client use exponential jitter retry. Each attempt waits a random delay within a growing window — this prevents thundering-herd storms when an upstream recovers.


Metrics flow ​

Every request updates the in-memory MetricsCollector. The GET /health endpoint exposes a live snapshot — no external monitoring required for basic observability.


Project structure ​

example/nestjs-app/
├── src/
│   ├── app.module.ts               ← forRootAsync + ConfigModule
│   ├── app.controller.ts           ← GET /
│   ├── config/
│   │   └── super-http.config.ts    ← SuperHttpOptionsFactory (env vars)
│   │
│   ├── users/                      ← default SuperHttpService client
│   │   ├── users.module.ts
│   │   ├── users.controller.ts     ← GET / POST / PUT / DELETE /users
│   │   ├── users.service.ts        ← injects SuperHttpService
│   │   ├── users.service.spec.ts   ← unit tests (mocked)
│   │   ├── users.controller.spec.ts
│   │   └── dto/
│   │       └── create-user.dto.ts  ← @IsString / @IsEmail decorators
│   │
│   ├── posts/                      ← two named clients (POSTS + COMMENTS)
│   │   ├── posts.module.ts         ← forFeature([POSTS, COMMENTS])
│   │   ├── posts.controller.ts     ← GET / POST /posts, with-comments
│   │   ├── posts.service.ts        ← @InjectSuperHttp('POSTS') + ('COMMENTS')
│   │   └── posts.service.spec.ts
│   │
│   └── health/
│       ├── health.module.ts
│       ├── health.controller.ts    ← GET /health (live metrics)
│       └── health.controller.spec.ts
│
├── test/
│   └── app.e2e-spec.ts             ← 14 integration tests vs JSONPlaceholder
│
├── package.json
├── tsconfig.json                   ← emitDecoratorMetadata: true (required)
└── README.md

Quick start ​

bash
cd example/nestjs-app

# Install dependencies
npm install

# Start in development mode
npm run start:dev

# Run unit tests (28 tests, no network)
npm test

# Run integration tests (14 tests, requires internet)
npm run test:e2e

Available endpoints ​

MethodPathDescription
GET/apiWelcome
GET/api/healthLive HTTP client metrics
GET/api/usersList all users
GET/api/users/:idGet user by ID
POST/api/usersCreate user
PUT/api/users/:idUpdate user
DELETE/api/users/:idDelete user
GET/api/postsList all posts
GET/api/posts/:idGet post by ID
GET/api/posts/:id/with-commentsPost + comments (parallel fetch)
POST/api/postsCreate post

Key patterns explained ​

1. forRootAsync + useClass — config from environment ​

The root module registers a global default client using a factory class that reads environment variables via ConfigService. This is the recommended pattern for production apps.

ts
// app.module.ts
SuperHttpModule.forRootAsync({
  imports:  [ConfigModule],   // ← make ConfigService available to the factory
  useClass: SuperHttpConfigService,
})
ts
// config/super-http.config.ts
@Injectable()
export class SuperHttpConfigService implements SuperHttpOptionsFactory {
  constructor(private readonly config: ConfigService) {}

  createSuperHttpOptions(): SuperHttpModuleOptions {
    return {
      baseURL: this.config.get('JSONPLACEHOLDER_URL', 'https://jsonplaceholder.typicode.com'),
      preset: 'resilient-api',
      headers: { 'X-App-Name': 'my-app' },
    }
  }
}

Why useClass over useFactory?

useClass is better for complex configs — the factory class can have its own injected dependencies and is trivially testable in isolation.


2. Named clients with forFeature ​

PostsModule needs two independent clients with different resilience profiles. forFeature creates both and provides them under named DI tokens.

ts
// posts.module.ts
SuperHttpModule.forFeature([
  {
    name: 'POSTS',
    baseURL: 'https://jsonplaceholder.typicode.com',
    preset: 'high-throughput',   // large pool, 1 fast retry
  },
  {
    name: 'COMMENTS',
    baseURL: 'https://jsonplaceholder.typicode.com',
    preset: 'resilient-api',     // CB + 3 retries + bulkhead
  },
])
ts
// posts.service.ts
constructor(
  @InjectSuperHttp('POSTS')    private readonly postsClient: HttpClient,
  @InjectSuperHttp('COMMENTS') private readonly commentsClient: HttpClient,
) {}

3. Parallel fetch across two clients ​

findWithComments fetches post and comments simultaneously from two independent clients. If comments fail, the circuit breaker trips, failing fast on subsequent calls without touching the posts client.

ts
async findWithComments(postId: number) {
  const [postRes, commentsRes] = await Promise.all([
    this.postsClient.get<Post>(`/posts/${postId}`),
    this.commentsClient.get<Comment[]>(`/posts/${postId}/comments`),
  ])
  return { ...postRes.data, comments: commentsRes.data }
}

4. Per-request policy on mutations ​

PostsService.create disables retry for POST to avoid creating duplicate resources on transient failures. The policy field is passed via .request().

ts
async create(dto: Omit<Post, 'id'>) {
  const { data } = await this.postsClient.request<Post>({
    method: 'POST',
    url: '/posts',
    data: dto,
    policy: { retry: false },  // ← non-idempotent: no retry
  })
  return data
}

5. Live metrics endpoint ​

HealthController reads the default client's MetricsSnapshot and computes a simple health status in real time.

ts
@Get()
check(): HealthStatus {
  const m = this.http.metrics()
  const total  = m.requests
  const errors = m.failed
  const rate   = total > 0 ? (((total - errors) / total) * 100).toFixed(1) : '100.0'

  return {
    status: total > 10 && parseFloat(rate) < 95 ? 'degraded' : 'ok',
    uptime: process.uptime(),
    http: {
      requests:    total,
      errors,
      successRate: `${rate}%`,
      p99:         m.p99Latency > 0 ? `${m.p99Latency.toFixed(1)}ms` : 'N/A',
    },
  }
}

6. Testing approach ​

TypeFileTechnique
Unit (service)users.service.spec.tsMock SuperHttpService with useValue
Unit (named client)posts.service.spec.tsMock via getSuperHttpClientToken('POSTS')
Unit (controller)users.controller.spec.tsMock the service class
Integration (e2e)test/app.e2e-spec.tsFull NestJS app + real JSONPlaceholder

Unit test — default client:

ts
{ provide: SuperHttpService, useValue: { get: jest.fn(), post: jest.fn() } }

Unit test — named client:

ts
import { getSuperHttpClientToken } from 'super-http/nestjs'

{ provide: getSuperHttpClientToken('POSTS'),    useValue: { get: jest.fn() } }
{ provide: getSuperHttpClientToken('COMMENTS'), useValue: { get: jest.fn() } }

Common pitfalls ​

import type breaks emitDecoratorMetadata ​

When you use import type { MyDto } in a controller, TypeScript erases the import at runtime. The compiler then emits Function instead of MyDto in the parameter metadata — ValidationPipe(transform: true) can't construct the DTO and passes the class constructor itself as the argument, causing Axios to fail serialising it.

ts
// ❌ type import erases the constructor at runtime
import type { CreateUserDto } from './dto/create-user.dto'

// ✅ value import preserves metadata for reflection
import { CreateUserDto } from './dto/create-user.dto'

Missing class-validator decorators ​

ValidationPipe({ whitelist: true }) strips all properties without a class-validator decorator. Always annotate DTO properties:

ts
export class CreateUserDto {
  @IsString() @MinLength(2) name: string
  @IsEmail()               email: string
  @IsString() @IsOptional() username?: string
}

ConfigModule not in forRootAsync imports ​

When using useClass, the factory is instantiated inside the SuperHttpModule context. Any injected dependency (e.g. ConfigService) must be explicitly imported:

ts
// ✅
SuperHttpModule.forRootAsync({
  imports:  [ConfigModule],   // ← required
  useClass: SuperHttpConfigService,
})

Always enable emitDecoratorMetadata ​

json
{
  "compilerOptions": {
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true
  }
}

CatalogModule — HTTP REST backed by gRPC ​

CatalogModule is the gRPC integration showcase in the example app. It exposes standard REST endpoints while calling a CatalogService gRPC backend internally via super-http's GrpcClient.

Architecture ​

Browser / curl
     │  GET /api/catalog           (HTTP REST)
     │  GET /api/catalog/:id
     │  GET /api/catalog/search?q=
     │  GET /api/catalog/metrics
     ▼
CatalogController  (NestJS @Controller)
     │
     ▼
CatalogService  (NestJS @Injectable)
     │  GrpcClient<CatalogServiceDef>  — injected via @InjectSuperHttp('CATALOG_GRPC')
     │  Resilience: circuit breaker + 2 retries + 5s timeout
     │
     │  HTTP/2 POST /CatalogService/getProduct      (unary)
     │  HTTP/2 POST /CatalogService/listProducts    (server stream)
     │  HTTP/2 POST /CatalogService/searchProducts  (server stream)
     ▼
CatalogService gRPC mock  (HTTP/2, :50053, started in main.ts)

Service definition ​

ts
// src/catalog/catalog-service.def.ts
import { defineService, unary, serverStream } from 'super-http/grpc'

export const CATALOG_GRPC_PORT = 50053

export interface Product {
  id: string; name: string; description: string
  price: number; stock: number; category: string; active: boolean
}

export const CatalogServiceDef = defineService('CatalogService', {
  getProduct:     unary<GetProductRequest, Product>(),
  listProducts:   serverStream<ListProductsRequest, Product>(),
  searchProducts: serverStream<SearchProductsRequest, Product>(),
})

Module registration ​

ts
// src/catalog/catalog.module.ts
import { SuperHttpModule } from 'super-http/nestjs'
import { CatalogServiceDef, CATALOG_GRPC_PORT } from './catalog-service.def'

@Module({
  imports: [
    SuperHttpModule.forFeature([{
      name:      'CATALOG_GRPC',
      grpc:      true,
      address:   `http://localhost:${CATALOG_GRPC_PORT}`,
      service:   CatalogServiceDef,
      preset:    'resilient-api',
      timeoutMs: 5_000,
      retries:   2,
    }]),
  ],
  controllers: [CatalogController],
  providers:   [CatalogService],
})
export class CatalogModule {}

Service — gRPC calls + error mapping ​

ts
// src/catalog/catalog.service.ts
@Injectable()
export class CatalogService {
  constructor(
    @InjectSuperHttp('CATALOG_GRPC')
    private readonly catalog: GrpcClient<typeof CatalogServiceDef>,
  ) {}

  async findOne(id: string): Promise<Product> {
    try {
      return await this.catalog.getProduct({ id })          // unary
    } catch (err) {
      if (err instanceof GrpcError && err.code === 'not_found')
        throw new NotFoundException(`Product "${id}" not found`)  // gRPC → HTTP 404
      throw err
    }
  }

  async findAll(options = {}): Promise<Product[]> {
    const products: Product[] = []
    for await (const p of this.catalog.listProducts(options))  // server stream
      products.push(p)
    return products
  }

  async search(query: string, limit = 10): Promise<Product[]> {
    const results: Product[] = []
    for await (const p of this.catalog.searchProducts({ query, limit }))
      results.push(p)
    return results
  }

  grpcMetrics() { return this.catalog.metrics() }
}

Controller — REST endpoints ​

MethodPathgRPC call
GET/api/cataloglistProducts (server stream → JSON array)
GET/api/catalog/search?q=searchProducts (server stream → JSON array)
GET/api/catalog/metricsmetrics() (local, no RPC)
GET/api/catalog/:idgetProduct (unary)
ts
@Controller('catalog')
export class CatalogController {
  constructor(private readonly catalogService: CatalogService) {}

  @Get()
  findAll(@Query('category') category?: string,
          @Query('inStock')  inStock?: string,
          @Query('limit')    limit?: string) {
    return this.catalogService.findAll({
      category,
      inStock: inStock !== undefined ? inStock === 'true' : undefined,
      limit:   limit   !== undefined ? Number(limit)     : undefined,
    })
  }

  @Get('search')
  search(@Query('q') query = '', @Query('limit') limit?: string) {
    return this.catalogService.search(query, limit ? Number(limit) : 10)
  }

  @Get('metrics')
  grpcMetrics() { return this.catalogService.grpcMetrics() }

  @Get(':id')
  findOne(@Param('id') id: string) { return this.catalogService.findOne(id) }
}

Mock gRPC server (development only) ​

The example ships an HTTP/2 mock server in src/catalog/mock/catalog-grpc-server.ts. It starts automatically in main.ts before NestFactory.create() so you can run npm run start without any external service:

ts
// src/main.ts (excerpt)
import { startCatalogGrpcServer } from './catalog/mock/catalog-grpc-server'

async function bootstrap() {
  const grpcServer = await startCatalogGrpcServer() // :50053

  const app = await NestFactory.create(AppModule)
  // …
  process.on('SIGTERM', async () => {
    const { GrpcChannelRegistry } = await import('super-http/grpc')
    await GrpcChannelRegistry.closeAll()
    grpcServer.close(() => process.exit(0))
  })
}

In production replace startCatalogGrpcServer() with your real gRPC backend and update the address in CatalogModule.forFeature.

Try it ​

bash
cd example/nestjs-app
npm install && npm run start

# List all products
curl http://localhost:3000/api/catalog

# Filter by category
curl http://localhost:3000/api/catalog?category=electronics

# Full-text search
curl "http://localhost:3000/api/catalog/search?q=keyboard"

# Single product
curl http://localhost:3000/api/catalog/prod-1

# gRPC client metrics (circuit breaker state, p99 latency, …)
curl http://localhost:3000/api/catalog/metrics

Released under the MIT License.