O problema: desligar uma capacidade sem desligar o produto

Imagine uma aplicação com um fluxo gratuito e um módulo financeiro. Uma cobrança pode começar em uma requisição HTTP ou em um worker. Precisamos desabilitar o financeiro e continuar atendendo o fluxo gratuito. A condição observável é precisa: nenhuma chamada ao gateway financeiro deve ocorrer nos caminhos exercitados enquanto a capacidade estiver desabilitada.

Um guard no controller cobre o HTTP. O worker tem outro caminho. Se ambos podem construir o adapter de pagamento, a proteção depende de cada pessoa lembrar de repetir a mesma verificação.

Este é o primeiro 2B Engineering Lab: um experimento reduzido com hipótese, controles, evidências e uma decisão arquitetural. O exemplo foi escrito para publicação a partir de um estudo interno, com nomes genéricos, dados fictícios e um gateway em memória. Ele não representa resultados nem código de produção.

Por que um diretório não cria uma fronteira

Um Modular Monolith reúne módulos em um mesmo processo. A separação por pastas ajuda a organizar o código, mas não impede que um módulo importe o repository ou adapter de outro. TypeScript também não torna uma pasta privada por convenção.

Vamos combinar duas proteções. Em runtime, os entry points recebem uma capability com a implementação selecionada no composition root. Na validação do projeto, uma regra inspeciona dependências e reprova acessos aos internos financeiros. Essa segunda proteção precisa ser um gate obrigatório no CI. O comando tsc sozinho não a aplica.

Neste laboratório, “o código não consegue ignorar” significa que os caminhos testados respeitam a capability e que as violações de import previstas fazem o gate falhar. Não é uma garantia contra código arbitrário ou malicioso rodando no mesmo processo.

Alternativas e hipótese

Há três caminhos plausíveis. Repetir a flag em cada controller e worker exige pouca estrutura inicial, mas distribui a política. Colocar o guard no gateway concentra um último bloqueio de efeitos, porém ainda permite que outros módulos conheçam infraestrutura. Expor uma capability pública e escolher sua implementação na composição concentra o contrato e reduz esse acoplamento; exige uma regra que proteja os imports.

A hipótese escolhida é: se HTTP e worker recebem apenas FinancialCapability, e o projeto rejeita imports financeiros internos fora do módulo, desligar a implementação financeira produz zero efeitos no gateway, mantendo o fluxo gratuito disponível.

A escolha não impede um guard adicional no adapter real. Controles de autorização e de capacidade no limite do efeito podem complementar a API do módulo.

Preparar e reproduzir o laboratório

Baixe o projeto completo com lockfile. O pacote contém todos os arquivos, o checker e seis violações intencionais. Use Node.js 22 ou superior. Os scripts de flag usam shell POSIX; no Windows, execute pelo WSL.

bash
unzip engineering-lab-01.zip
cd engineering-lab-01
npm ci
npm run verify

O pacote fixa Fastify 5.6.2, ts-morph 27.0.2, TypeScript 5.9.3 e tsx 4.20.6. O lockfile conserva as dependências transitivas. Não há banco, fila externa ou provedor financeiro: queremos observar a fronteira com o mínimo de variáveis.

O núcleo do projeto tem esta forma:

Código
src/
  app/create-app.ts
  jobs/retry-payment.ts
  modules/
    financial/
      public-api.ts
      module.ts
      application/capabilities.ts
      ports/payment-gateway.ts
      infrastructure/in-memory-payment-gateway.ts
    social/free-challenge.ts
  architecture/check-boundaries.ts
  architecture/verify-negative.ts
  lab.ts
architecture-fixtures/

Separe o contrato consumido pelos entry points da port usada internamente. A capability descreve a ação disponível no produto. A port descreve o recurso que a implementação financeira precisa para executar essa ação.

Uma API pública que não entrega o gateway

Em src/modules/financial/public-api.ts, exponha pedido, resultado e capability. Um resultado discriminado obriga o consumidor a lidar com a capacidade desabilitada sem tratá-la como cobrança bem-sucedida.

typescript
export interface ChargeRequest {
  readonly orderId: string;
  readonly amountInCents: number;
}

export type ChargeResult =
  | { readonly status: 'charged'; readonly transactionId: string }
  | { readonly status: 'disabled' }
  | { readonly status: 'rejected'; readonly reason: string };

export interface FinancialCapability {
  charge(request: ChargeRequest): Promise<ChargeResult>;
}

O contrato não entrega credenciais, adapter, repository ou cliente de banco. Em ports/payment-gateway.ts, a port permanece interna:

typescript
export interface PaymentGateway {
  charge(request: {
    readonly orderId: string;
    readonly amountInCents: number;
  }): Promise<{ readonly transactionId: string }>;
}

O adapter em memória incrementa um contador sempre que charge é chamado. O contador mede chamadas ao adapter de teste; não mede dinheiro movimentado nem transações reais. É a nossa sonda para este experimento.

Selecionar a implementação na composição

Em application/capabilities.ts, a implementação habilitada valida o pedido antes de chamar a port. A validação está dentro do módulo para alcançar tanto HTTP quanto worker. Valores não inteiros, não finitos, fora da faixa segura ou não positivos são rejeitados.

typescript
export class EnabledFinancialCapability implements FinancialCapability {
  constructor(private readonly gateway: PaymentGateway) {}

  async charge(request: ChargeRequest): Promise<ChargeResult> {
    if (!request.orderId.trim() ||
        !Number.isSafeInteger(request.amountInCents) ||
        request.amountInCents <= 0) {
      return { status: 'rejected', reason: 'invalid_charge' };
    }
    const result = await this.gateway.charge(request);
    return { status: 'charged', transactionId: result.transactionId };
  }
}

export class DisabledFinancialCapability implements FinancialCapability {
  async charge(_request: ChargeRequest): Promise<ChargeResult> {
    return { status: 'disabled' };
  }
}

Os imports completos estão no pacote. A implementação desabilitada não recebe o gateway; retorna um estado explícito. Em financial/module.ts, a factory escolhe uma implementação:

typescript
export function createFinancialModule(enabled: boolean): {
  capability: FinancialCapability;
  getEffectCount(): number;
} {
  const gateway = new InMemoryPaymentGateway();
  return {
    capability: enabled
      ? new EnabledFinancialCapability(gateway)
      : new DisabledFinancialCapability(),
    getEffectCount: () => gateway.getEffectCount(),
  };
}

A sonda existe para observar o lab. Em produção, a composição não deveria entregar um caminho de escrita alternativo junto com a capability. Também é preciso avaliar se apenas construir um adapter real inicializa conexões ou executa trabalho: o adapter deste exemplo tem construção sem efeitos.

O composition root create-app.ts é o único arquivo externo autorizado a importar financial/module.ts. Ele monta o módulo e entrega a mesma capability ao handler HTTP e ao worker. Essa exceção é específica para a factory; não libera imports arbitrários dos internos financeiros.

Dois entry points, uma política

O worker de jobs/retry-payment.ts recebe a capability e encaminha o pedido. Ele não lê a flag nem constrói um gateway:

typescript
import type {
  ChargeRequest, FinancialCapability
} from '../modules/financial/public-api.js';

export function createRetryPaymentJob(financial: FinancialCapability) {
  return {
    execute: (request: ChargeRequest) => financial.charge(request),
  };
}

O HTTP valida a estrutura do JSON e transforma o resultado em uma resposta: charged recebe 201, disabled recebe 503 e rejected recebe 422. Esses códigos são escolhas do lab, não uma exigência da arquitetura. O fluxo gratuito retorna 201 e não importa o módulo financeiro.

O nome do worker descreve um caso de uso; este exemplo não implementa uma fila nem uma política de retry. Uma chamada repetida quando a capacidade está ON incrementa o contador novamente. Idempotência precisa de um experimento próprio.

Failure injection e controle de funcionamento

src/lab.ts usa app.inject() para exercitar o HTTP sem abrir uma porta e chama o worker diretamente. O Fastify documenta esse mecanismo de injeção de requisições para testes.

O teste contém assertions. Imprimir um contador e olhar o terminal seria insuficiente: o comando deve falhar se o resultado mudar.

typescript
assert.equal(social.statusCode, 201);
assert.equal(payment.statusCode, enabled ? 201 : 503);
assert.equal(payment.json().status, enabled ? 'charged' : 'disabled');
assert.equal(worker.status, enabled ? 'charged' : 'disabled');
assert.equal(harness.getEffectCount(), enabled ? 2 : 0);

Execute os dois modos:

bash
npm run lab:disabled
npm run lab:enabled

O controle OFF verifica a ausência de efeitos em ambos os entry points. O controle ON verifica que a capacidade funciona quando selecionada. O fluxo gratuito continua disponível nos dois modos. Os valores inválidos exercitados no worker e o valor zero no HTTP não geram efeitos adicionais.

Transformar a fronteira em uma regra de CI

O próximo ataque é um import direto do adapter. Comparar o texto do import com includes('modules/financial') é frágil: um módulo vizinho pode escrever ../financial/infrastructure/... ou usar um alias.

O checker do pacote usa a AST do ts-morph para coletar referências e o resolver do TypeScript para encontrar o arquivo de destino. A documentação do ts-morph descreve a inspeção de imports; a política de quais arquivos são autorizados é nossa.

O núcleo da regra é:

typescript
const allowed =
  (inside(from) && from !== api) ||
  path === api ||
  (from === composition && path === factory);

if (!allowed) {
  violations.push(`${display(from)} -> ${display(path)}`);
}

Aplicamos essa regra apenas quando o destino é um arquivo financeiro. Internos podem depender de internos; externos podem acessar a API pública; o composition root pode acessar a factory. A própria API pública não pode importar internos financeiros, evitando um barrel que simplesmente reexporte o adapter.

O checker examina imports estáticos, aliases configurados, reexports, imports dinâmicos literais, require literal e import type. Imports dinâmicos calculados são recusados neste projeto. Referências locais que não resolvem também falham, para não sumirem silenciosamente da verificação.

bash
npm run arch:test
npm run arch:test:violation

O primeiro comando passa. O segundo deve terminar com código 1. As fixtures contêm seis violações: acesso relativo, alias, reexport, import dinâmico literal, import de tipo e import calculado. verify-negative.ts exige a falha e a presença de cada fixture na saída. Assim, o controle negativo não fica verde apenas porque o checker deixou de encontrar arquivos.

No CI, execute npm ci e npm run verify na pasta do exemplo. O checker deve acompanhar a evolução do projeto; acrescentar novos caminhos de carregamento exige rever sua cobertura.

Evidências do exemplo reproduzível

Estas são observações da execução local da versão pública 1.0.0, com gateway em memória. A fonte original descrevia resultados esperados; este artigo usa a execução desta adaptação como evidência.

Código
Financeiro OFF
  social HTTP: 201
  cobrança HTTP: 503
  worker: disabled
  chamadas ao gateway: 0

Financeiro ON
  social HTTP: 201
  cobrança HTTP: 201
  worker: charged
  chamadas ao gateway: 2

Valores inválidos: nenhum efeito adicional
Typecheck: passou
Architecture check normal: passou
Controle negativo: seis violações detectadas, exit code 1

Não medimos latência, throughput, disponibilidade ou comportamento de um provedor externo. Também não exercitamos webhook, cron ou agente de IA: são possíveis consumidores futuros da mesma capability, que exigirão seus próprios testes.

Trade-offs e limites da prova

Ganham-se um contrato menor, uma política comum aos entry points e um gate que torna determinadas violações visíveis antes do deploy. Pagamos com interfaces, factory, testes e manutenção do checker. A API pública precisa continuar pequena; reexportar implementações a faria perder sua função.

A flag é resolvida quando o harness é criado. Mudar uma variável de ambiente depois disso não troca a capability existente. Um kill switch imediato, flags por tenant e trabalho já em execução exigem política atual no limite do efeito, além de decisões sobre filas, cancelamento e credenciais.

O mesmo processo ainda pode acessar SQL, filesystem ou SDKs por outros caminhos. O gate também não constitui isolamento de segurança. Ownership de tabelas, restrição de dependências, autorização, auditoria e segregação de credenciais complementam a fronteira. Não há idempotência, transação, outbox ou persistência neste lab.

Decisão: ADR resumido

Contexto. Dois entry points podem produzir efeitos financeiros, e o fluxo gratuito deve continuar disponível quando essa capacidade está desligada.

Decisão. Expor FinancialCapability, selecionar a implementação na composição, manter port e adapter internos e executar uma fitness function como gate de CI. O composition root recebe uma exceção limitada à factory do módulo.

Consequências. HTTP e worker compartilham o resultado discriminado e a validação do módulo. Imports indevidos cobertos pela regra reprovam o projeto. O contrato e o checker passam a exigir manutenção conjunta.

Critério de aceite. OFF produz zero chamadas ao adapter nos caminhos exercitados; ON produz duas; o fluxo gratuito retorna 201; tipos e checker normal passam; as seis violações intencionais são detectadas.

Quando revisar essa decisão

Reavalie a implementação quando a flag precisar mudar durante a execução, quando houver tenants com políticas diferentes ou quando um novo entry point chegar. Reavalie o limite de isolamento quando outros módulos tiverem acesso a credenciais, dados ou interfaces que permitam produzir o mesmo efeito.

Extrair um serviço pode ser adequado se houver necessidade real de implantação, credenciais, escala ou ownership independentes. A extração não elimina idempotência, contratos e observabilidade; acrescenta as falhas da rede. A decisão deve acompanhar essas necessidades, não apenas o crescimento do número de pastas.

Ficha da série e origem

Série: 2B Engineering Lab. Número: 01. Versão: 1.0.0. Categoria: Engineering Lab / Architecture. Nível: avançado. Leitura estimada: 11 minutos; reserve cerca de 90 minutos para explorar o lab. Tags: Modular Monolith, TypeScript, Hexagonal Architecture, Architecture Fitness Functions.

created_at: 2026-10-07, data aproximada do estudo, autorizada pelo responsável; o histórico disponível não informa o timestamp individual do lab. Esta data não é uma comprovação do momento exato de criação. A data editorial e a data de publicação são registros distintos.

Origem: estudo interno de boundaries, Modular Monolith e Hexagonal Architecture da 2bSolutions, adaptado e anonimizado para esta série. O exemplo público usa apenas entidades genéricas e integrações em memória. A rastreabilidade da conversa de origem é mantida no índice editorial interno do Drive.

O próximo artigo planejado é Engineering Lab #02 — Backpressure em sistemas realtime: FIFO, bounded queues ou coalescing? Sua criação e publicação serão registradas quando ocorrerem.