From gateway-controller
Scaffold a complete API Gateway feature for Lety 2.0 Backend: proto service + interface files, REST controller with Swagger/Permissions, gRPC gateway service with lastValueFrom, and NestJS module with ClientsModule.registerAsync. Triggered when the user needs to add a new endpoint or feature to the api-gateway.
How this skill is triggered — by the user, by Claude, or both
Slash command
/gateway-controller:gateway-controllerThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
You are scaffolding a new **API Gateway feature** for the Lety 2.0 Backend. The gateway bridges HTTP REST (Fastify) → gRPC to downstream microservices.
You are scaffolding a new API Gateway feature for the Lety 2.0 Backend. The gateway bridges HTTP REST (Fastify) → gRPC to downstream microservices.
Priority rule: Always follow official proto3, NestJS, and gRPC documentation and best practices. If existing code in the project deviates, generate the correct version and flag the discrepancy.
Fetch the relevant page when uncertain about any decorator, option, or proto syntax.
Ask the user for missing information. Required:
Domain name (singular PascalCase): e.g. Invoice, Lead, Conversation
Target microservice: tenant | platform | auth-service (default: tenant)
Endpoints: for each endpoint:
GET | POST | PATCH | DELETE/, /:id, /:id/activateActions.READ | WRITE | UPDATE | DELETE and which TenantResourceObjectEnum value@Throttle)@ApiKeyEnabled + TenantApiKeyGuard)Proto messages needed: for each RPC — request message name + fields, response message name + fields
commonTypes.GetById for single-ID lookups (already defined)paginationCommon.SearchablePagination for paginated lists (already defined)google.protobuf.Empty for no-input or no-output RPCsOptional:
UsersModule, ApiKeysModule)From domain name (e.g. Invoice):
domainPlural → invoicestableName → invoicepackageName → invoiceService (camelCase + Service)packageCommon → invoiceCommonSERVICE_NAME constant → INVOICES_SERVICE_NAMEPACKAGE_NAME constant → INVOICES_SERVICE_PACKAGE_NAMEFile paths:
proto/tenant/<domainPlural>-service.protoproto/tenant/<domainPlural>/<tableName>-interface.protoapps/api-gateway/src/tenant/<domainPlural>/<domainPlural>.controller.tsapps/api-gateway/src/tenant/<domainPlural>/<domainPlural>.service.tsapps/api-gateway/src/tenant/<domainPlural>/<domainPlural>.module.tsproto/tenant/<domainPlural>/<tableName>-interface.protoRules:
syntax = "proto3" always first linepackage <domainPlural>Common; (camelCase + Common)"common/common-requests.proto" → commonTypes.GetById, GetByEmail, FileUpload"common/pagination-interface.proto" → paginationCommon.SearchablePagination, PaginationMeta"google/protobuf/timestamp.proto" → google.protobuf.Timestamp for date fields"google/protobuf/empty.proto" → google.protobuf.Empty"google/protobuf/struct.proto" → google.protobuf.Struct for dynamic JSON objectssnake_case (proto convention) — TypeScript generated types will be camelCasestring for text, UUIDs, enums-as-stringint32 / int64 for integersdouble / float for decimalsbool for booleansbytes for binary datagoogle.protobuf.Timestamp for datesgoogle.protobuf.Struct for arbitrary JSON objectsrepeated <Type> for arraysoptional <Type> for nullable fields (proto3)created_at, updated_at, deleted_at<Domain>Data = the main response message (mirrors toResponseDto())Create<Domain>Request = input for create RPCUpdate<Domain>Request = input for update RPC (include id as field 1)Get<Domain>sResponse = paginated list response with items + metasyntax = "proto3";
package <domainPlural>Common;
import "common/common-requests.proto";
import "common/pagination-interface.proto";
import "google/protobuf/timestamp.proto";
import "google/protobuf/empty.proto";
message <Domain>Data {
string id = 1;
string name = 2;
// ... domain fields
google.protobuf.Timestamp created_at = 10;
google.protobuf.Timestamp updated_at = 11;
optional google.protobuf.Timestamp deleted_at = 12;
}
message Create<Domain>Request {
string name = 1;
// ... required fields (NO id, NO agencyId — those come from context)
}
message Update<Domain>Request {
string id = 1;
optional string name = 2;
// ... updatable fields (all optional except id)
}
message Get<Domain>sResponse {
repeated <Domain>Data items = 1;
paginationCommon.PaginationMeta meta = 2;
}
proto/tenant/<domainPlural>-service.protoRules:
package <domainSingular>Service;<Domain>Servicerpc GetById(commonTypes.GetById) returns (<domainPlural>Common.<Domain>Data);rpc Create(<domainPlural>Common.Create<Domain>Request) returns (<domainPlural>Common.<Domain>Data);rpc Update(<domainPlural>Common.Update<Domain>Request) returns (<domainPlural>Common.<Domain>Data);rpc FindAll(paginationCommon.SearchablePagination) returns (<domainPlural>Common.Get<Domain>sResponse);rpc Delete(commonTypes.GetById) returns (google.protobuf.Empty);syntax = "proto3";
package <domainSingular>Service;
import "common/common-requests.proto";
import "tenant/<domainPlural>/<tableName>-interface.proto";
import "google/protobuf/empty.proto";
import "common/pagination-interface.proto";
service <Domain>Service {
rpc GetById(commonTypes.GetById) returns (<domainPlural>Common.<Domain>Data);
rpc Create(<domainPlural>Common.Create<Domain>Request) returns (<domainPlural>Common.<Domain>Data);
rpc Update(<domainPlural>Common.Update<Domain>Request) returns (<domainPlural>Common.<Domain>Data);
rpc FindAll(paginationCommon.SearchablePagination) returns (<domainPlural>Common.Get<Domain>sResponse);
rpc Delete(commonTypes.GetById) returns (google.protobuf.Empty);
}
apps/api-gateway/src/tenant/<domainPlural>/<domainPlural>.controller.tsRules:
@Controller() — no route string (routes are defined in the module router)@HttpCode(HttpStatus.XXX) explicitly on every method@ApiOperation({ summary: '...', operationId: '<camelCaseUnique>' }) on every method
operationId must be globally unique across the gateway — use pattern <verb><Domain> e.g. createInvoice, getInvoiceById@ApiResponse(...) for every possible status code (200/201, 400, 404, 409, etc.)@Permissions({ action: Actions.XXX, resource: TenantResourceObjectEnum.XXX }) on every method@Param('id', ParseUUIDPipe) id: string@Param('name') name: string@Query() query: PaginationDto or domain-specific query DTO@ApiPaginatedResponse(EntityClass) instead of @ApiResponse@HttpCode(HttpStatus.NO_CONTENT) + return Promise<void>@UseInterceptors(FileInterceptor('fieldName')) + @UploadedFile() file?: File@UseInterceptors(FilesInterceptor('fieldName', MAX_COUNT, { fileFilter })) + @UploadedFiles() files: File[]@ApiConsumes('multipart/form-data') for file endpoints@Throttle({ default: { ttl: 60000, limit: 100 } })@UseGuards(TenantApiKeyGuard) + @ApiKeyEnabled() + @ApiSecurity(API_KEY_HEADER)import {
Body, Controller, Delete, Get, HttpCode, HttpStatus,
Param, ParseUUIDPipe, Patch, Post, Query,
} from '@nestjs/common';
import { ApiOperation, ApiResponse } from '@nestjs/swagger';
import { Permissions } from '@app/common/decorators/permisssions.decorator';
import { ApiPaginatedResponse } from '@app/common/decorators/responses.decorators';
import { Create<Domain>Dto } from '@app/common/dto/tenant/<domainPlural>/create-<tableName>.dto';
import { Update<Domain>Dto } from '@app/common/dto/tenant/<domainPlural>/update-<tableName>.dto';
import { PaginationDto } from '@app/common/dto/api-responses.dto';
import { <Domain>Entity } from '@app/common/entities/tenant/<domainPlural>/<tableName>.entity';
import { Actions } from '@app/common/enums/action.enum';
import { TenantResourceObjectEnum } from '@app/common/enums/object.enum';
import { <Domain>sService } from './<domainPlural>.service';
@Controller()
export class <Domain>sController {
constructor(private readonly <domainSingular>sService: <Domain>sService) {}
@Post()
@HttpCode(HttpStatus.CREATED)
@ApiOperation({ summary: 'Create a new <domain>', operationId: 'create<Domain>' })
@ApiResponse({ status: 201, description: '<Domain> created successfully', type: <Domain>Entity })
@ApiResponse({ status: 400, description: 'Bad request' })
@ApiResponse({ status: 409, description: '<Domain> already exists' })
@Permissions({ action: Actions.WRITE, resource: TenantResourceObjectEnum.<DOMAINS> })
async create(@Body() dto: Create<Domain>Dto) {
return this.<domainSingular>sService.create(dto);
}
@Get()
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Get all <domain>s', operationId: 'findAll<Domain>s' })
@ApiPaginatedResponse(<Domain>Entity)
@Permissions({ action: Actions.READ, resource: TenantResourceObjectEnum.<DOMAINS> })
async findAll(@Query() query: PaginationDto) {
return this.<domainSingular>sService.findAll(query);
}
@Get(':id')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Get a <domain> by ID', operationId: 'get<Domain>ById' })
@ApiResponse({ status: 200, description: '<Domain> found', type: <Domain>Entity })
@ApiResponse({ status: 404, description: '<Domain> not found' })
@Permissions({ action: Actions.READ, resource: TenantResourceObjectEnum.<DOMAINS> })
async findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.<domainSingular>sService.findOne({ id });
}
@Patch(':id')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Update a <domain>', operationId: 'update<Domain>ById' })
@ApiResponse({ status: 200, description: '<Domain> updated successfully', type: <Domain>Entity })
@ApiResponse({ status: 400, description: 'Bad request' })
@ApiResponse({ status: 404, description: '<Domain> not found' })
@Permissions({ action: Actions.UPDATE, resource: TenantResourceObjectEnum.<DOMAINS> })
async update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: Update<Domain>Dto) {
return this.<domainSingular>sService.update(id, dto);
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Delete a <domain>', operationId: 'delete<Domain>ById' })
@ApiResponse({ status: 204, description: '<Domain> deleted successfully' })
@ApiResponse({ status: 404, description: '<Domain> not found' })
@ApiResponse({ status: 403, description: 'Forbidden' })
@Permissions({ action: Actions.DELETE, resource: TenantResourceObjectEnum.<DOMAINS> })
async remove(@Param('id', ParseUUIDPipe) id: string): Promise<void> {
await this.<domainSingular>sService.delete(id);
}
}
apps/api-gateway/src/tenant/<domainPlural>/<domainPlural>.service.tsRules:
implements OnModuleInit@Inject(<DOMAIN>_SERVICE_NAME) private readonly grpcClient: ClientGrpcGrpcRequestInterceptor for metadata enrichment (context propagation)onModuleInit(): initialize service client with this.grpcClient.getService<ServiceClient>(SERVICE_NAME)const md = this.grpcRequestInterceptor.enrichMetadata();lastValueFrom(this.service.rpcMethod(request, md))import { Inject, Injectable, OnModuleInit } from '@nestjs/common';
import { ClientGrpc } from '@nestjs/microservices';
import { lastValueFrom } from 'rxjs';
import { Create<Domain>Dto } from '@app/common/dto/tenant/<domainPlural>/create-<tableName>.dto';
import { Update<Domain>Dto } from '@app/common/dto/tenant/<domainPlural>/update-<tableName>.dto';
import { PaginationDto } from '@app/common/dto/api-responses.dto';
import { GetById } from '@app/common/types/proto/common/common-requests';
import {
<DOMAIN>S_SERVICE_NAME,
<Domain>sServiceClient,
} from '@app/common/types/proto/tenant/<domainPlural>-service';
import { GrpcRequestInterceptor } from '@app/gateway/request-context/interceptors/grpc-request.interceptor';
@Injectable()
export class <Domain>sService implements OnModuleInit {
private <domainSingular>sService: <Domain>sServiceClient;
constructor(
@Inject(<DOMAIN>S_SERVICE_NAME) private readonly grpcClient: ClientGrpc,
private readonly grpcRequestInterceptor: GrpcRequestInterceptor,
) {}
onModuleInit() {
this.<domainSingular>sService = this.grpcClient.getService<<Domain>sServiceClient>(<DOMAIN>S_SERVICE_NAME);
}
async findOne(request: GetById) {
const md = this.grpcRequestInterceptor.enrichMetadata();
return lastValueFrom(this.<domainSingular>sService.getById(request, md));
}
async findAll(query: PaginationDto) {
const md = this.grpcRequestInterceptor.enrichMetadata();
return lastValueFrom(
this.<domainSingular>sService.findAll(
{ pagination: { page: query.page, limit: query.limit } },
md,
),
);
}
async create(dto: Create<Domain>Dto) {
const md = this.grpcRequestInterceptor.enrichMetadata();
return lastValueFrom(this.<domainSingular>sService.create(dto, md));
}
async update(id: string, dto: Update<Domain>Dto) {
const md = this.grpcRequestInterceptor.enrichMetadata();
return lastValueFrom(this.<domainSingular>sService.update({ id, ...dto }, md));
}
async delete(id: string) {
const md = this.grpcRequestInterceptor.enrichMetadata();
return lastValueFrom(this.<domainSingular>sService.delete({ id }, md));
}
}
apps/api-gateway/src/tenant/<domainPlural>/<domainPlural>.module.tsRules:
ClientsModule.registerAsync — never ClientsModule.register (config must come from ConfigService)join(__dirname, '../proto/tenant/<domainPlural>-service.proto') — relative to compiled outputloader: GRPC_LOADER_OPTIONS (shared loader config from @app/common/constants)config.get<string>(API_GATEWAY_CONFIG_KEY.TENANT_SERVICE_URL) (or PLATFORM_SERVICE_URL/AUTH_SERVICE_URL depending on target)import { join } from 'path';
import { Module } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { ClientsModule, Transport } from '@nestjs/microservices';
import { GRPC_LOADER_OPTIONS } from '@app/common/constants';
import {
<DOMAIN>S_SERVICE_NAME,
<DOMAIN>S_SERVICE_PACKAGE_NAME,
} from '@app/common/types/proto/tenant/<domainPlural>-service';
import { API_GATEWAY_CONFIG_KEY } from 'apps/api-gateway/config/key_mapping.const';
import { <Domain>sController } from './<domainPlural>.controller';
import { <Domain>sService } from './<domainPlural>.service';
@Module({
imports: [
ClientsModule.registerAsync([
{
name: <DOMAIN>S_SERVICE_NAME,
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
transport: Transport.GRPC,
options: {
package: <DOMAIN>S_SERVICE_PACKAGE_NAME,
url: config.get<string>(API_GATEWAY_CONFIG_KEY.TENANT_SERVICE_URL),
protoPath: join(__dirname, '../proto/tenant/<domainPlural>-service.proto'),
loader: GRPC_LOADER_OPTIONS,
maxReceiveMessageLength: 4 * 1024 * 1024,
maxSendMessageLength: 4 * 1024 * 1024,
},
}),
},
]),
],
controllers: [<Domain>sController],
providers: [<Domain>sService],
exports: [<Domain>sService],
})
export class <Domain>sModule {}
Present all 5 files with full paths. Ask:
"¿Todo correcto? ¿Quieres cambiar algo antes de crear los archivos?"
Wait for confirmation.
After writing, display:
✅ Gateway feature scaffolded: <Domain>
Files created:
proto/tenant/<domainPlural>-service.proto
proto/tenant/<domainPlural>/<tableName>-interface.proto
apps/api-gateway/src/tenant/<domainPlural>/<domainPlural>.controller.ts
apps/api-gateway/src/tenant/<domainPlural>/<domainPlural>.service.ts
apps/api-gateway/src/tenant/<domainPlural>/<domainPlural>.module.ts
Required next steps:
1. Run proto code generation:
pnpm generate:proto
This creates @app/common/types/proto/tenant/<domainPlural>-service.ts
with <DOMAIN>S_SERVICE_NAME, <DOMAIN>S_SERVICE_PACKAGE_NAME, and <Domain>sServiceClient
2. Register <Domain>sModule in the gateway TenantModule or AppModule
3. Add route prefix in the gateway routing config:
{ path: '<domainPlural>', module: <Domain>sModule }
4. Implement the gRPC service in apps/api or apps/platform:
/nest-module can scaffold the downstream microservice
lastValueFrom() on every gRPC call — never .toPromise() (deprecated)enrichMetadata() before every gRPC call — this propagates auth context between servicesClientsModule.registerAsync — never ClientsModule.registersnake_case — TypeScript generated types auto-convert to camelCaseoptional keyword for nullable proto3 fields — never use default value tricksgoogle.protobuf.Empty fields — it's a no-op responseoperationId in @ApiOperation must be globally unique across the entire gatewayParseUUIDPipevoid + HttpStatus.NO_CONTENTCreates, edits, and optimizes skills for Claude Code, including drafting, evaluating with test prompts, iterating on performance, and improving skill descriptions for better triggering accuracy.
npx claudepluginhub lety-ai/lety-skill-hub --plugin gateway-controller