ThiagoSoares
Voltar para o blog
Arquitetura12 min

SRP - Single Responsibility Principle

Como separar responsabilidades em serviços Node.js com TypeScript sem criar camadas e abstrações desnecessárias.

Compartilhar artigo

Uma classe pode ter poucos métodos e ainda concentrar responsabilidades demais. O problema não está no número de linhas, mas na quantidade de pessoas, regras ou eventos capazes de exigir uma mudança nela.

O Single Responsibility Principle, ou SRP, ajuda a reconhecer esses limites. Seu objetivo é evitar que decisões independentes sejam alteradas no mesmo módulo e testadas como se fossem uma coisa só.

O problema que o princípio resolve

Imagine um fechamento mensal de faturas. O valor precisa ser calculado, o documento precisa ser montado, o arquivo deve ser persistido e o cliente deve receber uma notificação.

Essas etapas pertencem ao mesmo processo, mas mudam por razões diferentes:

  • o financeiro altera juros e descontos;
  • a identidade visual altera o documento;
  • a infraestrutura troca o local de armazenamento;
  • o time de comunicação revisa a mensagem enviada.

Quando todas vivem na mesma classe, qualquer alteração obriga a revisar e testar o fluxo inteiro. Dependências externas também contaminam testes de uma regra que poderia ser pura.

Uma razão para mudar

SRP costuma ser resumido como “uma classe deve fazer uma coisa”. Essa frase é útil, mas imprecisa. Uma classe pode executar várias operações relacionadas à mesma responsabilidade.

Uma definição melhor é: um módulo deve ter uma razão principal para mudar. Essa razão geralmente está ligada a um ator do negócio ou a uma política específica.

Responsabilidade também não significa etapa técnica. Criar uma classe para validar cada campo de uma fatura fragmentaria uma regra coesa. A separação deve acompanhar fronteiras de mudança, não cada verbo do código.

Exemplo ruim

Este serviço coordena o fechamento, mas também implementa todas as etapas:

code
import { mkdir, writeFile } from "node:fs/promises";
 
type InvoiceItem = {
  description: string;
  quantity: number;
  unitPriceInCents: number;
};
 
type Invoice = {
  id: string;
  customerEmail: string;
  items: InvoiceItem[];
};
 
class InvoiceClosingService {
  async close(invoice: Invoice): Promise<void> {
    const subtotal = invoice.items.reduce(
      (total, item) => total + item.quantity * item.unitPriceInCents,
      0
    );
    const total = subtotal > 100_000 ? Math.round(subtotal * 0.95) : subtotal;
 
    const document = [
      `FATURA ${invoice.id}`,
      ...invoice.items.map(
        (item) => `${item.quantity}x ${item.description}: ${item.unitPriceInCents}`
      ),
      `TOTAL: ${total}`
    ].join("\n");
 
    await mkdir("invoices", { recursive: true });
    await writeFile(`invoices/${invoice.id}.txt`, document, "utf8");
 
    await fetch("https://notifications.internal/messages", {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({
        recipient: invoice.customerEmail,
        subject: "Sua fatura está disponível",
        content: document
      })
    });
  }
}

Por que o exemplo é ruim?

A classe conhece a política de desconto, o formato textual, o sistema de arquivos e o contrato HTTP de outro serviço. Essas decisões evoluem em ritmos diferentes.

Testar o desconto exige lidar com escrita em disco e rede. Reaproveitar o documento em outro canal exige extrair comportamento da classe. Um erro no envio também deixa ambíguo se a fatura foi fechada ou não.

O método close esconde quatro resultados importantes em um retorno void. Isso torna difícil observar cada etapa e definir políticas de falha.

Refatorando pelas fronteiras de mudança

Podemos separar cálculo, renderização e portas de saída. Cada contrato representa uma capacidade necessária ao processo:

code
type CalculatedInvoice = Invoice & {
  totalInCents: number;
};
 
interface InvoiceDocumentRenderer {
  render(invoice: CalculatedInvoice): string;
}
 
interface InvoiceStorage {
  save(invoiceId: string, content: string): Promise<string>;
}
 
interface InvoiceNotifier {
  send(input: {
    recipient: string;
    invoiceId: string;
    documentPath: string;
  }): Promise<void>;
}
 
class InvoiceCalculator {
  calculate(invoice: Invoice): CalculatedInvoice {
    const subtotal = invoice.items.reduce(
      (total, item) => total + item.quantity * item.unitPriceInCents,
      0
    );
 
    return {
      ...invoice,
      totalInCents:
        subtotal > 100_000 ? Math.round(subtotal * 0.95) : subtotal
    };
  }
}

O cálculo agora é determinístico. Ele recebe dados e devolve um resultado, sem conhecer arquivos ou notificações.

Exemplo bom

O serviço principal continua responsável por orquestrar o caso de uso, mas delega políticas independentes:

code
class CloseInvoice {
  constructor(
    private readonly calculator: InvoiceCalculator,
    private readonly renderer: InvoiceDocumentRenderer,
    private readonly storage: InvoiceStorage,
    private readonly notifier: InvoiceNotifier
  ) {}
 
  async execute(invoice: Invoice): Promise<CalculatedInvoice> {
    const calculatedInvoice = this.calculator.calculate(invoice);
    const document = this.renderer.render(calculatedInvoice);
    const documentPath = await this.storage.save(invoice.id, document);
 
    await this.notifier.send({
      recipient: invoice.customerEmail,
      invoiceId: invoice.id,
      documentPath
    });
 
    return calculatedInvoice;
  }
}

Uma implementação de armazenamento pode usar apenas APIs do Node.js:

code
import { mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
 
class LocalInvoiceStorage implements InvoiceStorage {
  constructor(private readonly directory: string) {}
 
  async save(invoiceId: string, content: string): Promise<string> {
    await mkdir(this.directory, { recursive: true });
 
    const filePath = join(this.directory, `${invoiceId}.txt`);
    await writeFile(filePath, content, "utf8");
 
    return filePath;
  }
}

Agora a regra financeira muda em InvoiceCalculator. O layout muda no renderer. A persistência muda em InvoiceStorage, e o canal de entrega muda no notifier.

Testes ficam mais específicos

Separar responsabilidades permite testar a regra de desconto sem I/O:

code
import assert from "node:assert/strict";
import test from "node:test";
 
test("aplica desconto em faturas acima de mil reais", () => {
  const calculator = new InvoiceCalculator();
  const invoice = {
    id: "inv-2027-001",
    customerEmail: "financeiro@empresa.test",
    items: [
      {
        description: "Licença anual",
        quantity: 1,
        unitPriceInCents: 120_000
      }
    ]
  };
 
  assert.equal(calculator.calculate(invoice).totalInCents, 114_000);
});

O teste não precisa criar diretórios nem simular fetch. Se a política financeira quebrar, a falha aponta diretamente para sua responsável.

Benefícios

Uma separação guiada por razões de mudança oferece:

  • testes menores e mais rápidos;
  • menor impacto por alteração;
  • regras de negócio mais visíveis;
  • falhas associadas a uma etapa clara;
  • substituição de infraestrutura sem mexer no cálculo;
  • revisão de código por contexto.

O serviço orquestrador ainda conhece a ordem do processo. Isso é uma responsabilidade válida: coordenar o fechamento da fatura.

Quando aplicar?

Sempre pergunte: "Esta classe tem mais de uma razão para mudar?" Se a resposta for "sim", é hora de aplicar o SRP.

Fechamento

Single Responsibility Principle é um princípio para alinhar código e mudança. O ponto central não é reduzir o tamanho dos arquivos, mas impedir que decisões independentes fiquem presas umas às outras.

Quando cada módulo possui uma razão clara para mudar, o sistema fica mais fácil de testar, revisar e evoluir. A próxima abstração deve nascer de uma fronteira observada, não do desejo de antecipar todas as possibilidades.

Série SOLID

Este artigo faz parte de uma série de artigos: