Ir para o conteúdo
Backend

Feature Flags sem Vendor Lock-in: Implementação Própria com TypeScript e Zero Dependência de SaaS

Marcos Soares
Atualizado em 
14 minutos de leitura
Ilustracao 3D de toggle switch translucido com cabos adaptadores intercambiaveis representando feature flags independentes
Ouça este artigo
0:00Feature Flags sem Vendor Lock-in: Implementação Própria com TypeScript e Zero Dependência de SaaS--:--

Conteúdo técnico toda semana

Receba artigos sobre arquitetura, padrões de projeto e engenharia de software. Direto no seu e-mail, sem enrolação.

Sem spam. Cancele a qualquer momento com 1 clique.

Neste artigo

O problema real: sua release depende de um SDK que você não controla

Feature flags resolvem um problema concreto: separar deploy de release. Você faz merge na main, o código vai para produção, mas a funcionalidade só aparece para quem você decide. O problema começa quando a implementação amarra o código inteiro a um provider específico.

LaunchDarkly cobra por seat e por flag evaluation. Unleash tem versão open source, mas o hosted exige plano pago para funcionalidades como métricas e audit log. Split, Flagsmith, ConfigCat: cada um com SDK próprio, formato de configuração próprio e API própria. Trocar de provider significa reescrever cada ponto do código onde uma flag é avaliada.

A solução é uma camada de abstração fina entre o código de negócio e o mecanismo de avaliação. Não é over-engineering: são menos de 200 linhas de código que eliminam a dependência direta de qualquer vendor.

Arquitetura: porta e adaptador para flags

O padrão é simples. Uma interface define o contrato. Adaptadores implementam essa interface para cada backend (JSON local, banco de dados, Redis, variáveis de ambiente, ou qualquer SaaS). O código de negócio depende só da interface.

TypeScript
// src/flags/types.ts
 
// FlagValue aceita os tipos reais que flags assumem em produção.
// Booleano cobre toggle simples. String cobre variantes (A/B test).
// Number cobre rollout percentual.
export type FlagValue = boolean | string | number;
 
export interface FlagContext {
  userId?: string;
  email?: string;
  country?: string;
  plan?: "free" | "pro" | "enterprise";
  [key: string]: unknown;
}
 
export interface FlagProvider {
  get(flagName: string, context?: FlagContext): Promise<FlagValue>;
  getAllFlags(): Promise<Record<string, FlagValue>>;
  dispose(): Promise<void>;
}

A interface FlagProvider tem três métodos. get avalia uma flag para um contexto. getAllFlags retorna o estado completo (útil para debug e dashboards internos). dispose libera conexões, algo que providers baseados em Redis ou WebSocket precisam.

Adaptador 1: JSON local para desenvolvimento

O adaptador mais simples lê de um arquivo JSON. Serve para desenvolvimento local e para testes de integração onde você quer controle total sobre o estado das flags.

TypeScript
// src/flags/providers/json-provider.ts
import { readFile } from "node:fs/promises";
import type { FlagProvider, FlagValue, FlagContext } from "../types.js";
 
export class JsonFlagProvider implements FlagProvider {
  private flags: Record<string, FlagValue> = {};
  private loaded = false;
 
  constructor(private readonly filePath: string) {}
 
  private async load(): Promise<void> {
    if (this.loaded) return;
    const raw = await readFile(this.filePath, "utf-8");
    this.flags = JSON.parse(raw);
    this.loaded = true;
  }
 
  async get(flagName: string, _context?: FlagContext): Promise<FlagValue> {
    await this.load();
    // Retorna false para flags inexistentes em vez de undefined.
    // Flag inexistente = funcionalidade desligada. Fail-safe.
    return this.flags[flagName] ?? false;
  }
 
  async getAllFlags(): Promise<Record<string, FlagValue>> {
    await this.load();
    return { ...this.flags };
  }
 
  async dispose(): Promise<void> {
    this.flags = {};
    this.loaded = false;
  }
}

O arquivo JSON correspondente:

JSON
{
  "new_checkout_flow": true,
  "dark_mode": false,
  "max_upload_size_mb": 50,
  "pricing_variant": "control"
}

Adaptador 2: variáveis de ambiente para containers

Em ambientes containerizados, variáveis de ambiente são o mecanismo de configuração mais portável. Funciona com Docker, Kubernetes ConfigMaps, Cloudflare Workers e qualquer plataforma serverless.

TypeScript
// src/flags/providers/env-provider.ts
import type { FlagProvider, FlagValue, FlagContext } from "../types.js";
 
// Prefixo evita colisão com variáveis do sistema.
const FLAG_PREFIX = "FF_";
 
function parseValue(raw: string): FlagValue {
  if (raw === "true") return true;
  if (raw === "false") return false;
  const num = Number(raw);
  if (!Number.isNaN(num) && raw.trim() !== "") return num;
  return raw;
}
 
export class EnvFlagProvider implements FlagProvider {
  async get(flagName: string, _context?: FlagContext): Promise<FlagValue> {
    const envKey = `${FLAG_PREFIX}${flagName.toUpperCase()}`;
    const raw = process.env[envKey];
    if (raw === undefined) return false;
    return parseValue(raw);
  }
 
  async getAllFlags(): Promise<Record<string, FlagValue>> {
    const result: Record<string, FlagValue> = {};
    for (const [key, value] of Object.entries(process.env)) {
      if (key.startsWith(FLAG_PREFIX) && value !== undefined) {
        const flagName = key.slice(FLAG_PREFIX.length).toLowerCase();
        result[flagName] = parseValue(value);
      }
    }
    return result;
  }
 
  async dispose(): Promise<void> {
    // Nada para liberar. Variáveis de ambiente são do processo.
  }
}

Para usar: FF_NEW_CHECKOUT_FLOW=true FF_MAX_UPLOAD_SIZE_MB=50 node server.js.

Adaptador 3: banco de dados para flags dinâmicas

Flags que mudam em runtime sem redeploy precisam de um backend persistente. Um adaptador para Postgres com cache em memória resolve isso sem dependência de SaaS.

TypeScript
// src/flags/providers/postgres-provider.ts
import type { Pool } from "pg";
import type { FlagProvider, FlagValue, FlagContext } from "../types.js";
 
interface CacheEntry {
  value: FlagValue;
  expiresAt: number;
}
 
export class PostgresFlagProvider implements FlagProvider {
  private cache = new Map<string, CacheEntry>();
  // TTL de 30s equilibra freshness e carga no banco.
  // Para flags críticas (kill switch), use TTL menor ou polling.
  private readonly ttlMs: number;
 
  constructor(
    private readonly pool: Pool,
    options?: { ttlMs?: number }
  ) {
    this.ttlMs = options?.ttlMs ?? 30_000;
  }
 
  async get(flagName: string, _context?: FlagContext): Promise<FlagValue> {
    const cached = this.cache.get(flagName);
    if (cached && cached.expiresAt > Date.now()) {
      return cached.value;
    }
 
    const { rows } = await this.pool.query(
      "SELECT value FROM feature_flags WHERE name = $1 AND enabled = true",
      [flagName]
    );
 
    const value: FlagValue = rows.length > 0 ? JSON.parse(rows[0].value) : false;
    this.cache.set(flagName, { value, expiresAt: Date.now() + this.ttlMs });
    return value;
  }
 
  async getAllFlags(): Promise<Record<string, FlagValue>> {
    const { rows } = await this.pool.query(
      "SELECT name, value FROM feature_flags WHERE enabled = true"
    );
    const result: Record<string, FlagValue> = {};
    for (const row of rows) {
      result[row.name] = JSON.parse(row.value);
      this.cache.set(row.name, {
        value: result[row.name],
        expiresAt: Date.now() + this.ttlMs,
      });
    }
    return result;
  }
 
  async dispose(): Promise<void> {
    this.cache.clear();
  }
}

A tabela correspondente:

SQL
CREATE TABLE feature_flags (
  name    TEXT PRIMARY KEY,
  value   JSONB NOT NULL DEFAULT 'false',
  enabled BOOLEAN NOT NULL DEFAULT true,
  updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
 
-- Índice parcial: só consulta flags ativas
CREATE INDEX idx_flags_enabled ON feature_flags (name) WHERE enabled = true;
 
INSERT INTO feature_flags (name, value) VALUES
  ('new_checkout_flow', 'true'),
  ('max_upload_size_mb', '50'),
  ('pricing_variant', '"variant_b"');

O orquestrador: composição com fallback

O ponto central do sistema é um orquestrador que tenta providers em ordem. Se o Postgres cai, o sistema usa variáveis de ambiente como fallback em vez de retornar erro para o usuário.

TypeScript
// src/flags/flag-service.ts
import type { FlagProvider, FlagValue, FlagContext } from "./types.js";
 
export class FlagService implements FlagProvider {
  // Providers são tentados em ordem. O primeiro que responder sem erro vence.
  // Ordem típica: Postgres (dinâmico) -> ENV (estático) -> JSON (defaults).
  constructor(private readonly providers: FlagProvider[]) {
    if (providers.length === 0) {
      throw new Error("FlagService precisa de pelo menos um provider");
    }
  }
 
  async get(flagName: string, context?: FlagContext): Promise<FlagValue> {
    for (const provider of this.providers) {
      try {
        return await provider.get(flagName, context);
      } catch {
        // Provider falhou. Tenta o próximo.
        // Em produção, emita log ou métrica aqui.
        continue;
      }
    }
    // Todos os providers falharam. Fail-safe: funcionalidade desligada.
    return false;
  }
 
  async getAllFlags(): Promise<Record<string, FlagValue>> {
    for (const provider of this.providers) {
      try {
        return await provider.getAllFlags();
      } catch {
        continue;
      }
    }
    return {};
  }
 
  async dispose(): Promise<void> {
    await Promise.allSettled(
      this.providers.map((p) => p.dispose())
    );
  }
}

A composição na inicialização da aplicação:

TypeScript
// src/flags/setup.ts
import { Pool } from "pg";
import { FlagService } from "./flag-service.js";
import { PostgresFlagProvider } from "./providers/postgres-provider.js";
import { EnvFlagProvider } from "./providers/env-provider.js";
import { JsonFlagProvider } from "./providers/json-provider.js";
 
export function createFlagService(): FlagService {
  const pool = new Pool({ connectionString: process.env.DATABASE_URL });
 
  return new FlagService([
    new PostgresFlagProvider(pool, { ttlMs: 15_000 }),
    new EnvFlagProvider(),
    new JsonFlagProvider("./flags.default.json"),
  ]);
}
 
// Uso no código de negócio:
// const flags = createFlagService();
// if (await flags.get("new_checkout_flow", { userId: user.id })) { ... }

Comparação: build próprio vs. SaaS vs. open source self-hosted

CritérioBuild próprio (este post)LaunchDarkly / SplitUnleash self-hosted
Custo mensal (time de 10 devs)Zero (infra existente)USD 200-1000+Zero (hosting próprio)
Tempo para primeiro flag2-4 horas30 minutos1-2 horas
Dashboard de gerenciamentoPrecisa construirInclusoIncluso
Targeting por usuárioImplementação manualIncluso com regras visuaisIncluso com strategies
Audit logImplementação manualInclusoIncluso (versão paga)
Vendor lock-inZeroAlto (SDK acoplado)Baixo (API aberta)
Manutenção contínuaResponsabilidade do timeResponsabilidade do vendorResponsabilidade do time

Se o time tem menos de 5 devs e precisa de targeting complexo (rollout por percentual, por região, por plano) com dashboard visual em menos de uma semana: use Unleash self-hosted. Se o time precisa de controle total sobre o formato de dados, latência de avaliação e não quer depender de nenhuma API externa: build próprio. LaunchDarkly faz sentido quando o custo do SaaS é irrelevante e o time não quer manter infraestrutura de flags.

Anti-patterns: o que NÃO fazer com feature flags

Erro 1: avaliar flags dentro de loops sem cache

TypeScript
// ERRADO: cada iteração dispara query ou chamada de rede
async function processOrders(orders: Order[]) {
  for (const order of orders) {
    const useNewPricing = await flags.get("new_pricing", {
      userId: order.userId,
    });
    if (useNewPricing) {
      await applyNewPricing(order);
    }
  }
}

Com 1000 pedidos, são 1000 avaliações. Mesmo com cache de 30 segundos no provider, a primeira execução faz 1000 chamadas se o cache está frio. A correção é avaliar antes do loop:

TypeScript
// CORRETO: avaliação única antes do loop
async function processOrders(orders: Order[], flags: FlagService) {
  // Para flags booleanas sem targeting por usuário,
  // uma avaliação basta.
  const useNewPricing = await flags.get("new_pricing");
 
  for (const order of orders) {
    if (useNewPricing) {
      await applyNewPricing(order);
    } else {
      await applyCurrentPricing(order);
    }
  }
}

Se a flag precisa de targeting por usuário dentro do loop, pré-carregue os valores em um Map antes de iterar.

Erro 2: flags que nunca são removidas

Flags são dívida técnica por definição. Cada flag é um branch condicional que duplica caminhos de execução e dificulta debugging. O anti-pattern clássico é acumular dezenas de flags "temporárias" que ficam ligadas para sempre.

Regra operacional: toda flag deve ter uma data de expiração. Quando a flag está ligada para 100% dos usuários há mais de 30 dias, o código do branch antigo deve ser removido. Inclua a data de criação na tabela e rode um check semanal:

SQL
-- Flags ligadas há mais de 30 dias: candidatas a remoção de código
SELECT name, updated_at
FROM feature_flags
WHERE enabled = true
  AND value = 'true'
  AND updated_at < NOW() - INTERVAL '30 days';

Erro 3: usar flags para configuração permanente

Feature flags controlam rollout temporário. Configuração permanente (limites de upload, URLs de serviços, timeouts) pertence a variáveis de ambiente ou config files. Misturar os dois transforma o sistema de flags em um config server improvisado, sem versionamento, sem validação de schema e sem rollback.

Testando flags sem dependência externa

Testes unitários não devem depender de banco, rede ou arquivo. Um provider in-memory resolve isso:

TypeScript
// src/flags/providers/memory-provider.ts
import type { FlagProvider, FlagValue, FlagContext } from "../types.js";
 
export class MemoryFlagProvider implements FlagProvider {
  constructor(private flags: Record<string, FlagValue> = {}) {}
 
  async get(flagName: string, _context?: FlagContext): Promise<FlagValue> {
    return this.flags[flagName] ?? false;
  }
 
  async getAllFlags(): Promise<Record<string, FlagValue>> {
    return { ...this.flags };
  }
 
  async dispose(): Promise<void> {
    this.flags = {};
  }
 
  // Método exclusivo para testes: muda o estado de uma flag em runtime
  set(flagName: string, value: FlagValue): void {
    this.flags[flagName] = value;
  }
}

No teste:

TypeScript
// src/flags/__tests__/flag-service.test.ts
import { describe, it, expect } from "vitest";
import { FlagService } from "../flag-service.js";
import { MemoryFlagProvider } from "../providers/memory-provider.js";
 
describe("FlagService", () => {
  it("retorna valor do primeiro provider disponível", async () => {
    const primary = new MemoryFlagProvider({ dark_mode: true });
    const fallback = new MemoryFlagProvider({ dark_mode: false });
    const service = new FlagService([primary, fallback]);
 
    expect(await service.get("dark_mode")).toBe(true);
  });
 
  it("usa fallback quando provider primário falha", async () => {
    const broken: MemoryFlagProvider = new MemoryFlagProvider();
    // Simula falha forçando o método get a lançar erro
    broken.get = async () => { throw new Error("connection refused"); };
 
    const fallback = new MemoryFlagProvider({ dark_mode: true });
    const service = new FlagService([broken, fallback]);
 
    expect(await service.get("dark_mode")).toBe(true);
  });
 
  it("retorna false quando todos os providers falham", async () => {
    const broken = new MemoryFlagProvider();
    broken.get = async () => { throw new Error("timeout"); };
 
    const service = new FlagService([broken]);
    expect(await service.get("qualquer_flag")).toBe(false);
  });
});

Esse padrão de teste funciona com qualquer runner. Se o projeto já usa o setup descrito em Node.js Backend 2026, o MemoryFlagProvider se encaixa sem adaptação.

Quando essa abordagem não basta

O build próprio descrito aqui cobre toggles booleanos, variantes de string e rollout manual. Ele não cobre, sem código adicional:

  • Rollout percentual automático: exige hashing consistente do userId para distribuir tráfego. Implementável, mas são mais 50-100 linhas de lógica de bucketing.
  • Targeting por regras compostas ("plano pro E país BR E criou conta há menos de 30 dias"): exige um mini-motor de regras. Nesse ponto, Unleash self-hosted entrega isso pronto.
  • Métricas de exposição (quantos usuários viram cada variante): exige integração com sistema de analytics ou tabela de eventos. Para quem já tem camadas de API bem estruturadas, adicionar um evento de tracking por avaliação de flag é direto, mas é trabalho extra.

A decisão se resume a: quantas flags ativas o time mantém simultaneamente? Menos de 20 flags com targeting simples: build próprio. Mais de 20 com regras compostas e necessidade de dashboard não-técnico: Unleash. Orçamento ilimitado e zero apetite para manter infra: LaunchDarkly.

FAQ

Feature flags adicionam latência perceptível às requisições?

Com o adaptador de variáveis de ambiente, a latência é zero (leitura de memória do processo). Com Postgres e cache de 30 segundos, a primeira avaliação após expiração do cache adiciona o tempo de uma query simples por primary key, algo entre 1-5ms em rede local. Para aplicações com requisitos de resiliência no client HTTP, o cache do provider absorve a latência na maioria das avaliações.

Dá para usar essa arquitetura com Deno ou Bun?

O código usa apenas node:fs/promises no adaptador JSON e process.env no adaptador ENV. O adaptador JSON funciona em Deno com node: compat. O adaptador ENV funciona em Bun sem alteração. Para Deno Deploy, substitua o adaptador Postgres por um adaptador Deno KV com a mesma interface FlagProvider.

Como evitar que flags obsoletas se acumulem no código?

Crie uma convenção de time: toda flag recebe um comentário // FLAG_EXPIRY: 2025-09-01 no ponto de uso. Um script de CI (pode ser um grep com data parsing) falha o build quando a data de expiração passa. Isso força a remoção do código morto.

Feature flags substituem variáveis de ambiente para configuração?

Não. Flags controlam comportamento temporário de funcionalidades. Configuração permanente (connection strings, timeouts, limites de rate limiting) pertence a variáveis de ambiente ou config de infraestrutura. Misturar os dois domínios cria um sistema onde ninguém sabe se um valor pode ser removido com segurança.

Preciso de um dashboard para gerenciar flags?

Para times pequenos (até 5 devs), um endpoint admin protegido por auth que lista e altera flags na tabela Postgres é suficiente. Para times maiores ou quando produto/PM precisa controlar flags sem deploy, um dashboard dedicado justifica o investimento. Unleash entrega isso pronto. Construir um CRUD com ferramentas que o time já usa leva 1-2 dias.

A posição que defendo

Feature flags são infraestrutura de release, não produto. Tratar flags como produto leva a dashboards elaborados, SDKs pesados e contas mensais que crescem com o número de devs. A implementação deste post tem menos de 200 linhas de código real, zero dependência externa, fallback automático e portabilidade total entre providers. Quando o time precisar de targeting sofisticado, o contrato FlagProvider aceita um adaptador para Unleash ou LaunchDarkly sem reescrever uma linha do código de negócio. Esse é o ponto: não é sobre nunca usar SaaS, é sobre poder trocar de SaaS em uma tarde.

Marcos Soares

Escrito por

Marcos Soares

Fullstack Developer · CEO da Agência Poti

Fullstack Developer e CEO da Agência Poti. Mais de 20 anos construindo arquiteturas cloud-native com React, Next.js e sistemas distribuídos. Parceiro comercial do estúdio iellou design. Fundador do Vivo de Código.

Comentários

Participe da discussão

Seja o primeiro a comentar!

Continue Aprofundando

Conteúdo técnico toda semana

Receba artigos sobre arquitetura, padrões de projeto e engenharia de software. Direto no seu e-mail, sem enrolação.

Sem spam. Cancele a qualquer momento com 1 clique.