# Guía de Implementación: Feature Flags **Versión:** 1.0.0 **Tiempo estimado:** 1-2 horas **Complejidad:** Media --- ## Pre-requisitos - [ ] Proyecto NestJS existente - [ ] TypeORM configurado - [ ] PostgreSQL como base de datos --- ## Paso 1: Crear Estructura de Directorios ```bash mkdir -p src/modules/feature-flags/entities mkdir -p src/modules/feature-flags/services mkdir -p src/modules/feature-flags/controllers mkdir -p src/modules/feature-flags/dto mkdir -p src/modules/feature-flags/guards mkdir -p src/modules/feature-flags/decorators ``` --- ## Paso 2: Crear Entidad FeatureFlag ```typescript // src/modules/feature-flags/entities/feature-flag.entity.ts import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, Index, Check, } from 'typeorm'; @Entity({ schema: 'config', name: 'feature_flags' }) @Index('idx_feature_flags_key', ['featureKey']) @Index('idx_feature_flags_enabled', ['isEnabled'], { where: '"is_enabled" = true' }) @Check('"rollout_percentage" >= 0 AND "rollout_percentage" <= 100') export class FeatureFlag { @PrimaryGeneratedColumn('uuid') id: string; @Column({ name: 'tenant_id', type: 'uuid', nullable: true }) tenantId?: string; @Column({ name: 'feature_key', type: 'varchar', length: 100, unique: true }) featureKey: string; @Column({ name: 'feature_name', type: 'varchar', length: 255 }) featureName: string; @Column({ type: 'text', nullable: true }) description?: string; @Column({ name: 'is_enabled', type: 'boolean', default: false }) isEnabled: boolean; @Column({ name: 'rollout_percentage', type: 'integer', default: 0 }) rolloutPercentage: number; @Column({ name: 'target_users', type: 'uuid', array: true, nullable: true }) targetUsers?: string[]; @Column({ name: 'target_roles', type: 'varchar', array: true, nullable: true }) targetRoles?: string[]; @Column({ name: 'target_conditions', type: 'jsonb', default: {} }) targetConditions: Record; @Column({ name: 'starts_at', type: 'timestamp with time zone', nullable: true }) startsAt?: Date; @Column({ name: 'ends_at', type: 'timestamp with time zone', nullable: true }) endsAt?: Date; @Column({ type: 'jsonb', default: {} }) metadata: Record; @Column({ name: 'created_by', type: 'uuid', nullable: true }) createdBy?: string; @Column({ name: 'updated_by', type: 'uuid', nullable: true }) updatedBy?: string; @CreateDateColumn({ name: 'created_at' }) createdAt: Date; @UpdateDateColumn({ name: 'updated_at' }) updatedAt: Date; } ``` --- ## Paso 3: Crear DTOs ```typescript // src/modules/feature-flags/dto/create-feature-flag.dto.ts import { IsString, IsBoolean, IsOptional, IsInt, Min, Max, IsArray, IsObject, MaxLength, Matches, IsDateString, } from 'class-validator'; export class CreateFeatureFlagDto { @IsString() @MaxLength(100) @Matches(/^[a-z][a-z0-9_]*$/, { message: 'Key debe ser snake_case y comenzar con letra', }) key: string; @IsString() @MaxLength(255) name: string; @IsString() @IsOptional() description?: string; @IsBoolean() @IsOptional() isEnabled?: boolean; @IsInt() @Min(0) @Max(100) @IsOptional() rolloutPercentage?: number; @IsArray() @IsString({ each: true }) @IsOptional() targetUsers?: string[]; @IsArray() @IsString({ each: true }) @IsOptional() targetRoles?: string[]; @IsObject() @IsOptional() targetConditions?: Record; @IsDateString() @IsOptional() startsAt?: string; @IsDateString() @IsOptional() endsAt?: string; @IsString() @IsOptional() category?: string; @IsObject() @IsOptional() metadata?: Record; } // src/modules/feature-flags/dto/update-feature-flag.dto.ts import { PartialType } from '@nestjs/mapped-types'; import { CreateFeatureFlagDto } from './create-feature-flag.dto'; export class UpdateFeatureFlagDto extends PartialType(CreateFeatureFlagDto) {} // src/modules/feature-flags/dto/feature-flag-query.dto.ts import { IsBoolean, IsOptional, IsString } from 'class-validator'; import { Transform } from 'class-transformer'; export class FeatureFlagQueryDto { @IsBoolean() @IsOptional() @Transform(({ value }) => value === 'true') isEnabled?: boolean; @IsString() @IsOptional() category?: string; } // src/modules/feature-flags/dto/check-result.dto.ts export class FeatureFlagCheckResultDto { enabled: boolean; reason: string; } // src/modules/feature-flags/dto/index.ts export * from './create-feature-flag.dto'; export * from './update-feature-flag.dto'; export * from './feature-flag-query.dto'; export * from './check-result.dto'; ``` --- ## Paso 4: Crear Servicio ```typescript // src/modules/feature-flags/services/feature-flags.service.ts import { Injectable, NotFoundException, ConflictException, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { createHash } from 'crypto'; import { FeatureFlag } from '../entities/feature-flag.entity'; import { CreateFeatureFlagDto, UpdateFeatureFlagDto, FeatureFlagQueryDto, FeatureFlagCheckResultDto, } from '../dto'; @Injectable() export class FeatureFlagsService { constructor( @InjectRepository(FeatureFlag) private readonly featureFlagRepo: Repository, ) {} /** * Obtener todas las feature flags con filtros opcionales */ async findAll(query?: FeatureFlagQueryDto): Promise { const qb = this.featureFlagRepo.createQueryBuilder('ff'); if (query?.isEnabled !== undefined) { qb.andWhere('ff.is_enabled = :isEnabled', { isEnabled: query.isEnabled }); } if (query?.category) { qb.andWhere("ff.metadata->>'category' = :category", { category: query.category, }); } return qb.orderBy('ff.feature_name', 'ASC').getMany(); } /** * Obtener una feature flag por su key */ async findOne(key: string): Promise { const flag = await this.featureFlagRepo.findOne({ where: { featureKey: key }, }); if (!flag) { throw new NotFoundException(`Feature flag "${key}" no encontrada`); } return flag; } /** * Crear una nueva feature flag */ async create(dto: CreateFeatureFlagDto, createdBy?: string): Promise { const existing = await this.featureFlagRepo.findOne({ where: { featureKey: dto.key }, }); if (existing) { throw new ConflictException(`Feature flag "${dto.key}" ya existe`); } const flag = this.featureFlagRepo.create({ featureKey: dto.key, featureName: dto.name, description: dto.description, isEnabled: dto.isEnabled ?? false, rolloutPercentage: dto.rolloutPercentage ?? 0, targetUsers: dto.targetUsers, targetRoles: dto.targetRoles, targetConditions: dto.targetConditions ?? {}, startsAt: dto.startsAt ? new Date(dto.startsAt) : undefined, endsAt: dto.endsAt ? new Date(dto.endsAt) : undefined, metadata: { ...dto.metadata, category: dto.category, }, createdBy, }); return this.featureFlagRepo.save(flag); } /** * Actualizar una feature flag existente */ async update( key: string, dto: UpdateFeatureFlagDto, updatedBy?: string, ): Promise { const flag = await this.findOne(key); if (dto.name !== undefined) flag.featureName = dto.name; if (dto.description !== undefined) flag.description = dto.description; if (dto.isEnabled !== undefined) flag.isEnabled = dto.isEnabled; if (dto.rolloutPercentage !== undefined) flag.rolloutPercentage = dto.rolloutPercentage; if (dto.targetUsers !== undefined) flag.targetUsers = dto.targetUsers; if (dto.targetRoles !== undefined) flag.targetRoles = dto.targetRoles; if (dto.targetConditions !== undefined) flag.targetConditions = dto.targetConditions; if (dto.startsAt !== undefined) flag.startsAt = new Date(dto.startsAt); if (dto.endsAt !== undefined) flag.endsAt = new Date(dto.endsAt); if (dto.metadata !== undefined || dto.category !== undefined) { flag.metadata = { ...flag.metadata, ...dto.metadata, ...(dto.category && { category: dto.category }), }; } flag.updatedBy = updatedBy; return this.featureFlagRepo.save(flag); } /** * Eliminar una feature flag */ async remove(key: string): Promise { const flag = await this.findOne(key); await this.featureFlagRepo.remove(flag); } /** * Verificar si una feature está habilitada para un usuario */ async isEnabled( key: string, userId?: string, userRoles?: string[], ): Promise { try { const flag = await this.findOne(key); // 1. Feature habilitada globalmente? if (!flag.isEnabled) { return { enabled: false, reason: 'Feature deshabilitada globalmente' }; } // 2. Verificar período de validez const now = new Date(); if (flag.startsAt && now < flag.startsAt) { return { enabled: false, reason: 'Feature no ha iniciado aún' }; } if (flag.endsAt && now > flag.endsAt) { return { enabled: false, reason: 'Feature ha expirado' }; } // 3. Usuario en target_users (early access)? if (userId && flag.targetUsers?.length > 0) { if (flag.targetUsers.includes(userId)) { return { enabled: true, reason: 'Usuario en lista de early access' }; } } // 4. Usuario tiene target_role? if (userRoles && flag.targetRoles?.length > 0) { const hasRole = userRoles.some((role) => flag.targetRoles?.includes(role)); if (hasRole) { return { enabled: true, reason: 'Usuario tiene rol objetivo' }; } } // 5. Rollout 100%? if (flag.rolloutPercentage === 100) { return { enabled: true, reason: 'Rollout al 100%' }; } // 6. Rollout 0%? if (flag.rolloutPercentage === 0) { return { enabled: false, reason: 'Rollout al 0%' }; } // 7. Hash del userId para rollout gradual if (userId) { const hash = this.hashUserId(userId, key); const isInRollout = hash < flag.rolloutPercentage; return { enabled: isInRollout, reason: isInRollout ? `Usuario en grupo de rollout ${flag.rolloutPercentage}%` : `Usuario fuera del grupo de rollout ${flag.rolloutPercentage}%`, }; } // Sin userId, usar probabilidad return { enabled: Math.random() * 100 < flag.rolloutPercentage, reason: `Selección aleatoria basada en ${flag.rolloutPercentage}%`, }; } catch (error) { if (error instanceof NotFoundException) { return { enabled: false, reason: 'Feature flag no encontrada' }; } throw error; } } /** * Hash consistente para rollout gradual * Retorna número entre 0 y 100 */ private hashUserId(userId: string, featureKey: string): number { const hash = createHash('sha256') .update(`${userId}:${featureKey}`) .digest('hex'); const hashInt = parseInt(hash.substring(0, 8), 16); return hashInt % 101; } /** * Habilitar feature flag */ async enable(key: string, updatedBy?: string): Promise { return this.update(key, { isEnabled: true }, updatedBy); } /** * Deshabilitar feature flag */ async disable(key: string, updatedBy?: string): Promise { return this.update(key, { isEnabled: false }, updatedBy); } /** * Actualizar porcentaje de rollout */ async updateRollout( key: string, percentage: number, updatedBy?: string, ): Promise { return this.update(key, { rolloutPercentage: percentage }, updatedBy); } } ``` --- ## Paso 5: Crear Guard ```typescript // src/modules/feature-flags/guards/feature-flag.guard.ts import { Injectable, CanActivate, ExecutionContext, ForbiddenException, } from '@nestjs/common'; import { Reflector } from '@nestjs/core'; import { FeatureFlagsService } from '../services/feature-flags.service'; import { FEATURE_FLAG_KEY } from '../decorators/feature-flag.decorator'; @Injectable() export class FeatureFlagGuard implements CanActivate { constructor( private reflector: Reflector, private featureFlagsService: FeatureFlagsService, ) {} async canActivate(context: ExecutionContext): Promise { const featureKey = this.reflector.getAllAndOverride( FEATURE_FLAG_KEY, [context.getHandler(), context.getClass()], ); if (!featureKey) { return true; // No feature flag requerida } const request = context.switchToHttp().getRequest(); const userId = request.user?.id; const userRoles = request.user?.roles || []; const result = await this.featureFlagsService.isEnabled( featureKey, userId, userRoles, ); if (!result.enabled) { throw new ForbiddenException( `Feature "${featureKey}" no disponible: ${result.reason}`, ); } return true; } } ``` --- ## Paso 6: Crear Decoradores ```typescript // src/modules/feature-flags/decorators/feature-flag.decorator.ts import { SetMetadata } from '@nestjs/common'; export const FEATURE_FLAG_KEY = 'feature_flag'; /** * Decorador para marcar rutas que requieren una feature flag * @param key - Clave de la feature flag */ export const FeatureFlag = (key: string) => SetMetadata(FEATURE_FLAG_KEY, key); // src/modules/feature-flags/decorators/check-feature.decorator.ts import { createParamDecorator, ExecutionContext } from '@nestjs/common'; import { FeatureFlagsService } from '../services/feature-flags.service'; /** * Decorador de parámetro para verificar feature inline * Nota: Este decorador requiere inyección manual del servicio */ export const CheckFeature = createParamDecorator( async (featureKey: string, ctx: ExecutionContext): Promise => { // Nota: Los decoradores de parámetro no pueden inyectar servicios directamente // Este decorador retorna un placeholder, la verificación real se hace en el servicio return featureKey; }, ); ``` --- ## Paso 7: Crear Controller ```typescript // src/modules/feature-flags/controllers/feature-flags.controller.ts import { Controller, Get, Post, Put, Delete, Body, Param, Query, UseGuards, Request, } from '@nestjs/common'; import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard'; import { AdminGuard } from '../../auth/guards/admin.guard'; import { FeatureFlagsService } from '../services/feature-flags.service'; import { CreateFeatureFlagDto, UpdateFeatureFlagDto, FeatureFlagQueryDto, FeatureFlagCheckResultDto, } from '../dto'; import { FeatureFlag } from '../entities/feature-flag.entity'; @Controller('admin/feature-flags') @UseGuards(JwtAuthGuard, AdminGuard) export class FeatureFlagsController { constructor(private readonly featureFlagsService: FeatureFlagsService) {} @Get() async findAll(@Query() query: FeatureFlagQueryDto): Promise { return this.featureFlagsService.findAll(query); } @Get(':key') async findOne(@Param('key') key: string): Promise { return this.featureFlagsService.findOne(key); } @Post() async create( @Body() dto: CreateFeatureFlagDto, @Request() req: any, ): Promise { return this.featureFlagsService.create(dto, req.user?.id); } @Put(':key') async update( @Param('key') key: string, @Body() dto: UpdateFeatureFlagDto, @Request() req: any, ): Promise { return this.featureFlagsService.update(key, dto, req.user?.id); } @Delete(':key') async remove(@Param('key') key: string): Promise { return this.featureFlagsService.remove(key); } @Post(':key/enable') async enable( @Param('key') key: string, @Request() req: any, ): Promise { return this.featureFlagsService.enable(key, req.user?.id); } @Post(':key/disable') async disable( @Param('key') key: string, @Request() req: any, ): Promise { return this.featureFlagsService.disable(key, req.user?.id); } @Put(':key/rollout') async updateRollout( @Param('key') key: string, @Body('percentage') percentage: number, @Request() req: any, ): Promise { return this.featureFlagsService.updateRollout(key, percentage, req.user?.id); } } // Controller público para verificar features @Controller('feature-flags') export class FeatureFlagsPublicController { constructor(private readonly featureFlagsService: FeatureFlagsService) {} @Post(':key/check') @UseGuards(JwtAuthGuard) async checkFeature( @Param('key') key: string, @Request() req: any, ): Promise { return this.featureFlagsService.isEnabled( key, req.user?.id, req.user?.roles, ); } } ``` --- ## Paso 8: Crear Módulo ```typescript // src/modules/feature-flags/feature-flags.module.ts import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { FeatureFlag } from './entities/feature-flag.entity'; import { FeatureFlagsService } from './services/feature-flags.service'; import { FeatureFlagsController, FeatureFlagsPublicController, } from './controllers/feature-flags.controller'; import { FeatureFlagGuard } from './guards/feature-flag.guard'; @Module({ imports: [TypeOrmModule.forFeature([FeatureFlag])], controllers: [FeatureFlagsController, FeatureFlagsPublicController], providers: [FeatureFlagsService, FeatureFlagGuard], exports: [FeatureFlagsService, FeatureFlagGuard], }) export class FeatureFlagsModule {} ``` --- ## Paso 9: Registrar en AppModule ```typescript // src/app.module.ts import { Module } from '@nestjs/common'; import { FeatureFlagsModule } from './modules/feature-flags/feature-flags.module'; @Module({ imports: [ // ... otros módulos FeatureFlagsModule, ], }) export class AppModule {} ``` --- ## Paso 10: Migraciones SQL ```sql -- migrations/001_create_feature_flags.sql CREATE SCHEMA IF NOT EXISTS config; CREATE TABLE config.feature_flags ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID, feature_key VARCHAR(100) UNIQUE NOT NULL, feature_name VARCHAR(255) NOT NULL, description TEXT, is_enabled BOOLEAN DEFAULT false, rollout_percentage INTEGER DEFAULT 0 CHECK (rollout_percentage >= 0 AND rollout_percentage <= 100), target_users UUID[], target_roles VARCHAR(50)[], target_conditions JSONB DEFAULT '{}', starts_at TIMESTAMP WITH TIME ZONE, ends_at TIMESTAMP WITH TIME ZONE, metadata JSONB DEFAULT '{}', created_by UUID, updated_by UUID, created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP ); -- Índices CREATE INDEX idx_feature_flags_key ON config.feature_flags(feature_key); CREATE INDEX idx_feature_flags_enabled ON config.feature_flags(is_enabled) WHERE is_enabled = true; CREATE INDEX idx_feature_flags_dates ON config.feature_flags(starts_at, ends_at) WHERE is_enabled = true; -- Trigger para updated_at CREATE OR REPLACE FUNCTION update_updated_at_column() RETURNS TRIGGER AS $$ BEGIN NEW.updated_at = CURRENT_TIMESTAMP; RETURN NEW; END; $$ language 'plpgsql'; CREATE TRIGGER update_feature_flags_updated_at BEFORE UPDATE ON config.feature_flags FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); ``` --- ## Paso 11: Uso en Código ### Verificación programática ```typescript // En cualquier servicio @Injectable() export class CheckoutService { constructor( private readonly featureFlagsService: FeatureFlagsService, ) {} async processCheckout(userId: string, cart: Cart) { const useNewCheckout = await this.featureFlagsService.isEnabled( 'new_checkout_flow', userId, ); if (useNewCheckout.enabled) { return this.processNewCheckout(cart); } return this.processLegacyCheckout(cart); } } ``` ### Proteger rutas con Guard ```typescript @Controller('experiments') @UseGuards(JwtAuthGuard) export class ExperimentsController { @Get('new-dashboard') @UseGuards(FeatureFlagGuard) @FeatureFlag('new_dashboard') async getNewDashboard() { return { message: 'Bienvenido al nuevo dashboard!' }; } } ``` ### Helper para frontend ```typescript // src/modules/feature-flags/feature-flags.helper.ts import { FeatureFlagsService } from './services/feature-flags.service'; export async function getFeatureFlagsForUser( service: FeatureFlagsService, userId: string, userRoles: string[], featureKeys: string[], ): Promise> { const results: Record = {}; await Promise.all( featureKeys.map(async (key) => { const result = await service.isEnabled(key, userId, userRoles); results[key] = result.enabled; }), ); return results; } // Uso: endpoint que retorna todas las features del usuario @Get('my-features') @UseGuards(JwtAuthGuard) async getMyFeatures(@Request() req: any) { const keys = ['new_checkout', 'dark_mode', 'beta_features']; return getFeatureFlagsForUser( this.featureFlagsService, req.user.id, req.user.roles, keys, ); } ``` --- ## Variables de Entorno ```env # Feature Flags (opcional) FEATURE_FLAGS_CACHE_TTL=300 FEATURE_FLAGS_DEFAULT=false ``` --- ## Checklist de Implementación - [ ] Entidad FeatureFlag creada - [ ] DTOs de validación creados - [ ] FeatureFlagsService implementado con hash consistente - [ ] FeatureFlagGuard creado - [ ] Decorador @FeatureFlag creado - [ ] Controllers (admin y público) implementados - [ ] Módulo registrado en AppModule - [ ] Migración SQL ejecutada - [ ] Build pasa sin errores - [ ] Test: crear flag y verificar isEnabled --- ## Verificar Funcionamiento ```bash # 1. Crear feature flag curl -X POST http://localhost:3000/api/admin/feature-flags \ -H "Authorization: Bearer $ADMIN_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "key": "test_feature", "name": "Test Feature", "isEnabled": true, "rolloutPercentage": 50 }' # 2. Verificar si está habilitada para un usuario curl -X POST http://localhost:3000/api/feature-flags/test_feature/check \ -H "Authorization: Bearer $USER_TOKEN" # 3. Actualizar rollout curl -X PUT http://localhost:3000/api/admin/feature-flags/test_feature/rollout \ -H "Authorization: Bearer $ADMIN_TOKEN" \ -H "Content-Type: application/json" \ -d '{"percentage": 100}' # 4. Deshabilitar curl -X POST http://localhost:3000/api/admin/feature-flags/test_feature/disable \ -H "Authorization: Bearer $ADMIN_TOKEN" ``` --- ## Troubleshooting ### Feature siempre retorna false - Verificar que `is_enabled = true` - Verificar que `rollout_percentage > 0` o usuario en `target_users` - Verificar período de validez (`starts_at`, `ends_at`) ### Rollout no es consistente - Verificar que se pase el mismo `userId` - El hash depende de `userId + featureKey` ### Guard no funciona - Verificar que `FeatureFlagGuard` esté registrado en providers - Verificar que `@FeatureFlag('key')` esté en el handler --- **Versión:** 1.0.0 **Sistema:** SIMCO Catálogo