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.
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:
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.
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:
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.
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:
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:
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.
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:
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 é:
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.
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.
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.