DEV Community

Cover image for Object Calisthenics
Felipe Dumont De Sa Costa
Felipe Dumont De Sa Costa

Posted on

Object Calisthenics

Object Calisthenics na prática — PHP, Laravel e Vue.js

9 regras para escrever código orientado a objetos mais limpo, com exemplos reais de domínio. Sem teoria de livro didático.

Object Calisthenics é um conjunto de 9 restrições criadas por Jeff Bay, publicadas no livro ThoughtWorks Anthology (2008). A ideia é simples: aplicar essas regras como exercício disciplinado força você a produzir código com alta coesão, baixo acoplamento e responsabilidade única — mesmo sem pensar conscientemente em SOLID.

O nome vem de calistenia — exercício usando o próprio corpo, sem equipamento. Aqui, o "equipamento" são as abstrações desnecessárias. Você se exercita com restrições para desenvolver o instinto certo de design.

Os exemplos abaixo usam um domínio real de gestão de contratos de locação — análise de crédito, garantias, cobranças, notificações. Nada de Foo e Bar.


Os 9 princípios

# Regra
01 Um nível de indentação por método
02 Não use else
03 Envolva primitivos e strings
04 Coleções de primeira classe
05 Um ponto por linha
06 Não abrevia nomes
07 Mantenha entidades pequenas
08 No máximo 2 variáveis de instância
09 Sem getters e setters

01 — Um nível de indentação por método

Se um método tem um for dentro de um if, ele já está fazendo coisas demais. Extraia o conteúdo de cada bloco para um método privado com nome que descreva a intenção.

❌ Ruim

public function processOverdueContracts(Collection $contracts): void
{
    foreach ($contracts as $contract) {
        if ($contract->isOverdue()) {
            foreach ($contract->payments() as $payment) {
                if ($payment->isPending()) {
                    $payment->markAsLate(); // 3 níveis de indentação
                }
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

✅ Bom

public function processOverdueContracts(Collection $contracts): void
{
    $contracts
        ->filter(fn($c) => $c->isOverdue())
        ->each($this->markPendingPaymentsAsLate(...));
}

private function markPendingPaymentsAsLate(Contract $contract): void
{
    $contract
        ->payments()
        ->filter(fn($p) => $p->isPending())
        ->each(fn($p) => $p->markAsLate());
}
Enter fullscreen mode Exit fullscreen mode

Por quê funciona: cada método tem uma responsabilidade isolada e pode ser testado individualmente. O nome markPendingPaymentsAsLate documenta a intenção sem necessitar de comentário.


02 — Não use else

Use early return: verifique os casos inválidos primeiro e retorne. O caminho feliz fica no final, sem aninhamento. Essa é provavelmente a regra de maior impacto imediato no dia a dia.

❌ Ruim

public function approve(Application $app): ApprovalResult
{
    if ($app->hasValidDocuments()) {
        if ($app->creditScore() >= 600) {
            if (!$app->hasActiveDenial()) {
                return ApprovalResult::approved();
            } else {
                return ApprovalResult::denied('Bloqueio ativo');
            }
        } else {
            return ApprovalResult::denied('Score insuficiente');
        }
    } else {
        return ApprovalResult::denied('Documentos inválidos');
    }
}
Enter fullscreen mode Exit fullscreen mode

✅ Bom

public function approve(Application $app): ApprovalResult
{
    if (!$app->hasValidDocuments()) {
        return ApprovalResult::denied('Documentos inválidos');
    }

    if ($app->creditScore() < 600) {
        return ApprovalResult::denied('Score insuficiente');
    }

    if ($app->hasActiveDenial()) {
        return ApprovalResult::denied('Bloqueio ativo');
    }

    return ApprovalResult::approved();
}
Enter fullscreen mode Exit fullscreen mode

Por quê funciona: lê-se como um checklist. Cada guarda elimina um caso inválido. O caminho feliz fica explícito no final. Zero aninhamento, fácil de adicionar novas validações.


03 — Envolva primitivos e strings

Strings, inteiros e floats soltos não carregam regras de negócio. Um Value Object garante que um CPF inválido nunca existirá no sistema — a validação fica no construtor, não espalhada em 20 lugares.

❌ Ruim

// Controller, Service, Job... cada um valida do seu jeito
$cpf  = $request->input('cpf');        // string qualquer
$rent = $request->input('rent_value'); // float sem garantia

if (strlen(preg_replace('/\D/', '', $cpf)) !== 11) {
    throw new InvalidArgumentException('CPF inválido');
}
if ($rent <= 0) {
    throw new InvalidArgumentException('Valor inválido');
}
Enter fullscreen mode Exit fullscreen mode

✅ Bom

final class Cpf
{
    private readonly string $value;

    public function __construct(string $raw)
    {
        $digits = preg_replace('/\D/', '', $raw);
        CpfValidator::ensureValid($digits); // lança exceção se inválido
        $this->value = $digits;
    }

    public function formatted(): string
    {
        return preg_replace(
            '/(\d{3})(\d{3})(\d{3})(\d{2})/',
            '$1.$2.$3-$4',
            $this->value
        );
    }
}

final class Money
{
    public function __construct(
        private readonly int $cents // sempre em centavos
    ) {
        if ($this->cents < 0) {
            throw new DomainException('Valor monetário não pode ser negativo');
        }
    }

    public function times(int $multiplier): self
    {
        return new self($this->cents * $multiplier);
    }

    public function isLessThan(self $other): bool
    {
        return $this->cents < $other->cents;
    }
}

// No controller: tipagem garante integridade
$cpf  = new Cpf($request->input('cpf'));
$rent = Money::fromFloat($request->input('rent_value'));
Enter fullscreen mode Exit fullscreen mode

Por quê funciona: impossível criar um Cpf inválido no sistema. A validação existe em um único lugar. O tipo já documenta a intenção — Money é muito mais expressivo que float.


04 — Coleções de primeira classe

Se você tem uma classe que contém uma coleção, ela não deve ter outros atributos. Encapsule arrays e collections em classes próprias com comportamento de domínio — em vez de espalhar a lógica de filtragem por todos os services.

❌ Ruim

// ReportService.php
$overdueTotal = Contract::all()
    ->filter(fn($c) => $c->status === 'overdue')
    ->sum('value');

// DashboardService.php — mesma lógica duplicada
$overdueByAgency = Contract::all()
    ->filter(fn($c) => $c->status === 'overdue')
    ->groupBy('agency_id');
Enter fullscreen mode Exit fullscreen mode

✅ Bom

final class ContractCollection
{
    public function __construct(
        private readonly Collection $items
    ) {}

    public function overdue(): self
    {
        return new self(
            $this->items->filter(fn($c) => $c->isOverdue())
        );
    }

    public function totalValue(): Money
    {
        return Money::fromFloat($this->items->sum('value'));
    }

    public function groupedByAgency(): Collection
    {
        return $this->items->groupBy('agency_id');
    }
}

// Uso — expressivo e reutilizável
$contracts = new ContractCollection(Contract::all());

$overdueTotal    = $contracts->overdue()->totalValue();
$overdueByAgency = $contracts->overdue()->groupedByAgency();
Enter fullscreen mode Exit fullscreen mode

Por quê funciona: a definição de "overdue" existe em um único lugar. Se a regra mudar, você altera o método overdue() e todos os consumidores ganham automaticamente.


05 — Um ponto por linha

A Lei de Deméter: um objeto só deve falar com seus vizinhos imediatos, não com os amigos dos seus amigos. Navegar pela estrutura interna de um objeto cria acoplamento invisível — se Contact muda, tudo que acessa $contract->tenant()->contact()->email() quebra.

❌ Ruim

// 4 dependências acopladas em 1 linha
$email = $contract
    ->tenant()
    ->contact()
    ->primaryEmail()
    ->address();

$this->mailer->to($email)->send(new DueDateMail());
Enter fullscreen mode Exit fullscreen mode

✅ Bom

// Contract.php — expõe apenas o que é necessário
class Contract
{
    public function tenantEmail(): string
    {
        return $this->tenant()->primaryEmail();
    }
}

// NotificationJob.php — apenas um ponto
$this->mailer
    ->to($contract->tenantEmail())
    ->send(new DueDateMail($contract));
Enter fullscreen mode Exit fullscreen mode

Exceção válida: o Laravel Query Builder e Collections foram projetados para chaining. Contract::query()->where(...)->with(...)->get() é Fluent Interface intencional, não navegação acidental. A regra se aplica a traversal de estruturas internas, não a builders.


06 — Não abrevia nomes

Código é lido muito mais vezes do que é escrito. Abreviações economizam segundos na digitação e custam minutos em cada leitura. Se você precisa de contexto para entender um nome, ele está errado.

❌ Ruim

public function upd(UpdCtrReq $req, int $id)
{
    $ctr = Ctr::findOrFail($id);
    $svc = new CtrSvc();
    $res = $svc->upd($ctr, $req->validated());
    return $res->toApiResp();
}
Enter fullscreen mode Exit fullscreen mode
// Vue — ilegível sem contexto
const hdlSubm = async () => {
    const r = await postCtrUpd(frm.val);
    updCtrLst(r.d);
}
Enter fullscreen mode Exit fullscreen mode

✅ Bom

public function update(UpdateContractRequest $request, int $id)
{
    $contract = Contract::findOrFail($id);
    $service  = new ContractService();
    $result   = $service->update($contract, $request->validated());
    return $result->toApiResponse();
}
Enter fullscreen mode Exit fullscreen mode
// Vue — autoexplicativo
const handleFormSubmit = async () => {
    const updatedContract = await updateContract(form.value);
    refreshContractList(updatedContract);
}
Enter fullscreen mode Exit fullscreen mode

Teste do contexto: mostre o nome isolado para alguém do time. Se precisar explicar o que significa, o nome falhou.


07 — Mantenha entidades pequenas

Classes com menos de 50 linhas, métodos com menos de 5 linhas, pacotes com menos de 10 arquivos. Esses números são restritivos por design — o objetivo é forçar a pergunta: "esta responsabilidade pertence aqui?"

❌ Ruim

class ContractService // 800+ linhas
{
    public function approve() { ... }
    public function generateBoleto() { ... }
    public function sendWhatsAppReminder() { ... }
    public function cancel() { ... }
    public function generateNfe() { ... }
    public function calculateCommission() { ... }
    public function buildDashboardReport() { ... }
    // + 20 métodos...
}
Enter fullscreen mode Exit fullscreen mode

✅ Bom

// App/Services/Contract/
ContractApprovalService     // aprova, nega, audita
ContractBillingService      // boleto, PIX, Asaas
ContractNotificationService // WhatsApp, e-mail
ContractCancellationService // cancela + estorno
ContractInvoiceService      // NFe.io

// Ou Actions (single-action, mais Laravel-idiomatic):
ApproveContractAction
CancelContractAction
GenerateContractInvoiceAction

// Binding no AppServiceProvider:
$this->app->bind(
    ContractApprovalService::class,
    SerasaContractApprovalService::class
);
Enter fullscreen mode Exit fullscreen mode

Sinal de alerta: se você precisa de Ctrl+F para achar um método dentro da própria classe, ela está grande demais.


08 — No máximo 2 variáveis de instância

Essa é a regra mais radical — e a mais reveladora. Quando você tenta limitar para 2 variáveis, percebe que o que parecia uma classe coesa é, na verdade, três conceitos distintos vivendo no mesmo endereço.

❌ Ruim

class TenantAnalysis
{
    public function __construct(
        private string $name,
        private string $cpf,
        private float  $income,
        private int    $serasaScore,
        private float  $totalDebt,
        private string $analysisStatus,
        private Carbon $analysisDate,
    ) {}
}
Enter fullscreen mode Exit fullscreen mode

✅ Bom

final class TenantProfile   // identidade do inquilino
{
    public function __construct(
        private readonly TenantName $name,
        private readonly Cpf        $cpf,
    ) {}
}

final class CreditSnapshot  // snapshot de crédito
{
    public function __construct(
        private readonly SerasaScore $score,
        private readonly Money       $totalDebt,
    ) {}

    public function isEligibleFor(Money $rentValue): bool
    {
        return $this->score->isAboveMinimum()
            && $this->totalDebt->isLessThan($rentValue->times(3));
    }
}

final class TenantAnalysis  // une os dois conceitos
{
    public function __construct(
        private readonly TenantProfile  $profile,
        private readonly CreditSnapshot $credit,
    ) {}
}
Enter fullscreen mode Exit fullscreen mode

Efeito colateral positivo: o método isEligibleFor() surgiu naturalmente na decomposição — o comportamento encontrou o dado certo para viver junto.


09 — Sem getters e setters

"Tell, Don't Ask": em vez de buscar os dados de um objeto para decidir por ele, diga ao objeto o que fazer. Getters e setters transformam objetos em DTOs glorificados — a lógica de negócio acaba vivendo fora de onde o dado está.

❌ Ruim

// Service busca os dados e decide por fora
$status    = $contract->getStatus();
$startDate = $contract->getStartDate();

if ($status === 'active' && $startDate->diffInDays(now()) < 30) {
    $contract->setStatus('cancelled');
    $contract->setCancelledAt(now());
    $contract->save();
}
Enter fullscreen mode Exit fullscreen mode

✅ Bom

// Contract.php — regra encapsulada no objeto
class Contract
{
    public function cancelWithinCoolingPeriod(): void
    {
        if (!$this->isWithinCoolingPeriod()) {
            throw new CancellationNotAllowedException(
                'Período de cancelamento encerrado'
            );
        }

        $this->status      = ContractStatus::CANCELLED;
        $this->cancelledAt = now();
    }

    private function isWithinCoolingPeriod(): bool
    {
        return $this->isActive()
            && $this->startedAt->diffInDays(now()) < 30;
    }
}

// No Service: uma linha, sem acoplamento
$contract->cancelWithinCoolingPeriod();
$contract->save();
Enter fullscreen mode Exit fullscreen mode

Por quê funciona: a regra dos "30 dias" existe em um lugar. Se o prazo mudar, você altera isWithinCoolingPeriod() e todos os consumers são atualizados automaticamente.


Bônus — Os mesmos princípios no Vue.js

Composables são o equivalente de services no frontend. As mesmas regras se aplicam — especialmente as de tamanho, nomeação e responsabilidade única.

❌ Ruim — composable que faz tudo

// useContract.ts
export function useContract() {
  const contracts    = ref([])
  const filter       = ref('all')
  const isModalOpen  = ref(false)
  const isApproving  = ref(false)
  const errorMessage = ref('')

  async function fetchAll() { ... }
  async function approve(id) { ... }
  function       openModal() { ... }
  function       filterByStatus() { ... }
  // + 10 outros...
}
Enter fullscreen mode Exit fullscreen mode

✅ Bom — composables focados

// useContractList.ts — lista e filtro
export function useContractList() {
  const contracts    = ref([])
  const activeFilter = ref('all')

  const filteredContracts = computed(() =>
    contracts.value.filter(c => {
      if (activeFilter.value === 'all') return true
      return c.status === activeFilter.value
    })
  )

  return { contracts, activeFilter, filteredContracts }
}

// useContractApproval.ts — só aprovação
export function useContractApproval() {
  const isApproving   = ref(false)
  const approvalError = ref('')

  async function approve(contractId: number) {
    isApproving.value = true
    try {
      await approveContract(contractId)
    } catch {
      approvalError.value = 'Falha ao aprovar contrato'
    } finally {
      isApproving.value = false
    }
  }

  return { isApproving, approvalError, approve }
}

// No componente: combina apenas o que precisa
const { filteredContracts, activeFilter } = useContractList()
const { isApproving, approve }            = useContractApproval()
Enter fullscreen mode Exit fullscreen mode

Quando aplicar — e quando não

Object Calisthenics não é lei sagrada. É um termômetro. Quando uma regra parece errada, pergunte: "estou com preguiça, ou realmente não faz sentido aqui?"

Aplique quando:

  • Código de domínio crítico (aprovação, cobrança, análise de crédito)
  • Código que vai crescer e ser mantido por vários devs
  • Lógica que precisa ser testada com confiança
  • Onboarding — código como documentação viva
  • Refatorando God Classes e métodos longos

Cuidado com:

  • Scripts descartáveis — custo não compensa
  • Migrations e seeders — linearidade é mais clara
  • CRUD simples sem regra de negócio
  • Hotfix urgente em produção — refatore depois
  • Time sem contexto dos princípios — alinhe antes

Código limpo não é perfeccionismo — é respeito pelo próximo dev.


Tags: php laravel vue clean-code object-calisthenics solid boas-praticas

Top comments (0)