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
}
}
}
}
}
✅ 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());
}
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');
}
}
✅ 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();
}
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');
}
✅ 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'));
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');
✅ 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();
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());
✅ 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));
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();
}
// Vue — ilegível sem contexto
const hdlSubm = async () => {
const r = await postCtrUpd(frm.val);
updCtrLst(r.d);
}
✅ Bom
public function update(UpdateContractRequest $request, int $id)
{
$contract = Contract::findOrFail($id);
$service = new ContractService();
$result = $service->update($contract, $request->validated());
return $result->toApiResponse();
}
// Vue — autoexplicativo
const handleFormSubmit = async () => {
const updatedContract = await updateContract(form.value);
refreshContractList(updatedContract);
}
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...
}
✅ 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
);
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,
) {}
}
✅ 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,
) {}
}
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();
}
✅ 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();
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...
}
✅ 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()
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)