POO não é assunto velho: quatro ideias por trás de todo sistema que aguenta mudança
Encapsulamento, abstração, herança e polimorfismo como decisões de engenharia em C#: o que cada um custa, o que compra e onde ele segura ou derruba um sistema.
Os problemas caros em produção quase nunca são de sintaxe. Também quase nunca são de framework. Um await esquecido ou uma chave de configuração errada aparece no code review, nos testes ou nos primeiros minutos depois do deploy. Os que custam semanas são problemas de fronteira: um status que mudou quando não podia, uma regra que existe em três lugares e discorda de si mesma, uma mudança pequena num módulo que quebra outro que ninguém tocou.
Tudo isso se resume a uma pergunta: quem pode mudar o quê? É disso que trata a programação orientada a objetos. Não é sobre diagrama de classes, nem sobre Animal e Cachorro. POO é um conjunto de ferramentas para desenhar fronteiras no código e fazer essas fronteiras se sustentarem.
Encapsulamento, abstração, herança e polimorfismo costumam aparecer como definição para repetir em entrevista. Eu acho mais útil olhar para eles como decisões. Cada um custa alguma coisa, compra alguma coisa, e mostra o valor de verdade no dia em que o requisito muda. Os exemplos estão em C#, mas nada aqui depende da linguagem.
Encapsulamento: o objeto protege as próprias regras
Olha um pedido que qualquer parte do sistema pode editar:
1// Before: anyone can put the order in an invalid state
2public class Order
3{
4 public List<OrderItem> Items { get; set; } = new();
5 public decimal Total { get; set; }
6 public OrderStatus Status { get; set; }
7}
8
9// In some handler, far away from the class
10order.Items.Add(item);
11order.Total += item.Price * item.Quantity;Nada nessa classe sabe o que é um pedido válido. A regra "não dá para adicionar item em pedido cancelado" precisa morar em algum lugar, então ela vai parar num handler, depois num segundo handler, depois num job em background escrito por alguém que nunca viu os dois primeiros. Cada cópia vai divergindo um pouco. Cedo ou tarde, um pedido cancelado com um item novo e um total desatualizado chega no estoque.
Agora o mesmo pedido, cuidando de si:
1public class Order
2{
3 private readonly List<OrderItem> _items = new();
4 public IReadOnlyList<OrderItem> Items => _items.AsReadOnly();
5 public OrderStatus Status { get; private set; } = OrderStatus.Open;
6 public decimal Total => _items.Sum(i => i.Price * i.Quantity);
7
8 public void AddItem(OrderItem item)
9 {
10 if (Status != OrderStatus.Open)
11 throw new InvalidOperationException("Only open orders accept items.");
12 if (item.Quantity <= 0)
13 throw new ArgumentOutOfRangeException(nameof(item), "Quantity must be positive.");
14 _items.Add(item);
15 }
16
17 public void Cancel()
18 {
19 if (Status == OrderStatus.Shipped)
20 throw new InvalidOperationException("A shipped order cannot be cancelled.");
21 Status = OrderStatus.Cancelled;
22 }
23}1public static class Program
2{
3 public static void Main()
4 {
5 var order = new Order();
6 order.AddItem(new OrderItem("Keyboard", 250m, 1));
7 order.AddItem(new OrderItem("Mouse", 80m, 2));
8 Console.WriteLine($"Items: {order.Items.Count}, total: {order.Total:0.00}");
9
10 order.Cancel();
11 Console.WriteLine($"Status: {order.Status}");
12
13 try
14 {
15 order.AddItem(new OrderItem("Monitor", 1200m, 1));
16 }
17 catch (InvalidOperationException ex)
18 {
19 Console.WriteLine($"AddItem refused: {ex.Message}");
20 }
21
22 // order.Status = OrderStatus.Open; // does not compile
23 Console.WriteLine($"Total is still {order.Total:0.00}");
24 }
25}$ dotnet runOs setters públicos sumiram. Agora só dá para mudar um pedido pelas operações que o negócio tem de verdade: adicionar item, cancelar. Cada uma confere as próprias pré-condições. O Total é calculado, então não tem como ficar desatualizado.
O que custa: mais métodos e menos liberdade. Não dá mais para setar um campo direto num teste ou num script de migração; você entra pela porta da frente como todo mundo. Alguns ORMs e serializadores pedem um pouco de configuração para lidar com setter privado.
O que compra: a regra mora num lugar só. Quando ela muda, você altera um método e todo chamador ganha o comportamento novo. O code review também fica mais fácil, porque estado inválido deixa de ser algo que você precisa caçar em cada handler. A própria classe impede.
Esse é o pilar que eu defenderia com mais força. Na minha experiência, a maioria dos erros do tipo "isso nunca deveria acontecer" vem de um objeto que deixou outra parte do código editar o estado dele.
Abstração: dependa do quê, não do como
O checkout precisa cobrar o cliente. O jeito direto:
1// Before: checkout knows every detail of one vendor
2public class CheckoutService
3{
4 public async Task<bool> PayAsync(Order order)
5 {
6 var client = new VendorPayClient(Environment.GetEnvironmentVariable("VENDOR_KEY"));
7 var response = await client.ChargeAsync(order.Total, "BRL");
8 return response.Status == "approved";
9 }
10}Funciona até acontecer uma de três coisas: o provedor muda o SDK, o negócio adiciona um segundo provedor para outra região, ou alguém tenta escrever um teste. O checkout conhece a classe cliente do fornecedor, a chave, a string da moeda e o texto exato do campo de status. Tudo isso é o "como". O checkout só se importa com o "quê": cobre este pedido e me diga se deu certo.
1public interface IPaymentGateway
2{
3 Task<PaymentResult> ChargeAsync(Order order, CancellationToken ct);
4}
5
6public class CheckoutService(IPaymentGateway gateway)
7{
8 public Task<PaymentResult> PayAsync(Order order, CancellationToken ct) =>
9 gateway.ChargeAsync(order, ct);
10}
11
12// In tests: no network, no sandbox account, full control
13public class FakePaymentGateway(PaymentResult result) : IPaymentGateway
14{
15 public Task<PaymentResult> ChargeAsync(Order order, CancellationToken ct) =>
16 Task.FromResult(result);
17}1public static class Program
2{
3 public static void Main()
4 {
5 var order = new Order { Total = 410m };
6
7 var yes = new FakePaymentGateway(new PaymentResult(true, "Approved"));
8 var approved = new CheckoutService(yes).PayAsync(order, default).Result;
9 Console.WriteLine($"Approved: {approved.Approved}, {approved.Message}");
10
11 var no = new FakePaymentGateway(new PaymentResult(false, "Card declined"));
12 var declined = new CheckoutService(no).PayAsync(order, default).Result;
13 Console.WriteLine($"Approved: {declined.Approved}, {declined.Message}");
14 }
15}$ dotnet runO que custa: uma interface e uma classe adaptadora por provedor. Custa também uma decisão sobre o que a interface deve dizer. Uma abstração preguiçosa, que copia o SDK do fornecedor método por método, não compra nada; o acoplamento só mudou de arquivo. Escreva a interface com as palavras do seu domínio (Order, PaymentResult), não com as do provedor.
O que compra: trocar ou adicionar um provedor vira um adaptador novo, não uma mudança no checkout. Os testes deixam de precisar de rede, de conta de sandbox e de retry para resposta instável. Com o fake acima dá para cobrir o caminho de pagamento recusado, o de erro e o caminho feliz em milissegundos. Na prática, essa costuma ser a diferença entre esses caminhos terem teste ou não terem teste nenhum.
Herança: o pilar mais poderoso e o mais mal usado
Herança é o único pilar que amarra o filho à implementação do pai, e não só ao contrato. É daí que vem o poder dela, e é daí também que vem o problema. Desconto é o exemplo clássico:
1// Before: every new combination is a new subclass
2public class Discount
3{
4 public virtual decimal Apply(Order order) => order.Total;
5}
6
7public class SeasonalDiscount : Discount
8{
9 public override decimal Apply(Order order) => base.Apply(order) * 0.9m;
10}
11
12public class SeasonalVipDiscount : SeasonalDiscount
13{
14 public override decimal Apply(Order order) => base.Apply(order) - 20m;
15}
16
17// Next request: a coupon that stacks with seasonal but not with VIP?Cada classe, sozinha, parece razoável. A árvore não. SeasonalVipDiscount depende exatamente de como o pai faz a conta. Se alguém muda SeasonalDiscount para arredondar o resultado, todo pedido VIP muda de preço, e nada na classe VIP mostra isso. Aí o negócio pede um cupom que acumula com o desconto sazonal mas não com o VIP, e a hierarquia não tem lugar para ele. Ou você duplica um galho, ou coloca na classe base uma flag que metade dos filhos ignora.
Esse é o problema da classe base frágil. A saída costuma ser composição: objetos pequenos, cada um fazendo uma coisa, combinados em tempo de execução.
1public interface IDiscount { decimal Apply(decimal amount, Order order); }
2
3public record PercentOff(decimal Percent) : IDiscount
4{
5 public decimal Apply(decimal amount, Order order) => amount * (1 - Percent / 100);
6}
7
8public record FixedOff(decimal Value) : IDiscount
9{
10 public decimal Apply(decimal amount, Order order) => Math.Max(0, amount - Value);
11}
12
13public class DiscountPolicy(IEnumerable<IDiscount> discounts)
14{
15 public decimal Apply(Order order) =>
16 discounts.Aggregate(order.Total, (amount, d) => d.Apply(amount, order));
17}
18
19// Seasonal VIP: new DiscountPolicy([new PercentOff(10), new FixedOff(20)])1public static class Program
2{
3 public static void Main()
4 {
5 var order = new Order { Total = 200m };
6
7 var inherited = new SeasonalVipDiscount();
8 Console.WriteLine($"Inheritance: {inherited.Apply(order):0.00}");
9
10 var seasonalVip = new DiscountPolicy([new PercentOff(10), new FixedOff(20)]);
11 Console.WriteLine($"Seasonal + VIP: {seasonalVip.Apply(order):0.00}");
12
13 // A combination the class tree had no place for
14 var withCoupon = new DiscountPolicy([new PercentOff(10), new PercentOff(5)]);
15 Console.WriteLine($"Seasonal + coupon: {withCoupon.Apply(order):0.00}");
16 }
17}$ dotnet runO que custa: um pouco mais de montagem. A combinação agora mora numa configuração ou numa factory, então é ali que você olha para saber quais descontos valem para um pedido.
O que compra: cada regra é testada sozinha. Combinação nova vira dado, não classe nova. E a dependência fica à vista: a política lista os descontos que usa, em vez de herdá-los por um base.Apply que ninguém lê.
Eu continuo usando herança. Ela funciona quando o filho é de fato uma especialização que cumpre tudo o que o pai promete, e quando a hierarquia é rasa e é de um time só. Classes base de framework, como ControllerBase ou BackgroundService, são bons exemplos. Quando eu me pego usando herança só para reaproveitar meia dúzia de linhas, tomo isso como sinal de que o que eu quero é composição.
Polimorfismo: troque o switch de tipo por objetos
Custo de frete é outra regra que cresce com o tempo. Geralmente começa assim:
1// Before: every new method means editing this switch (and its copies)
2public decimal ShippingCost(Order order) =>
3 order.ShippingMethod switch
4 {
5 ShippingMethod.Flat => 15m,
6 ShippingMethod.Express => 15m + order.Weight * 2.5m,
7 ShippingMethod.Pickup => 0m,
8 _ => throw new NotSupportedException(order.ShippingMethod.ToString())
9 };Um switch sozinho não é problema. O problema é que ele nunca fica sozinho. A mesma checagem de ShippingMethod aparece na estimativa de entrega, na impressão da etiqueta, na tela de checkout. Adicionar uma opção nova significa achar todos eles, e com um _ de fallback em cada um, o compilador não te ajuda.
1public interface IShippingRule
2{
3 decimal CostFor(Order order);
4}
5
6public class FlatRateRule : IShippingRule
7{
8 public decimal CostFor(Order order) => 15m;
9}
10
11public class ExpressRule : IShippingRule
12{
13 public decimal CostFor(Order order) => 15m + order.Weight * 2.5m;
14}
15
16// A new PickupRule is a new class, not an edit here
17public decimal ShippingCost(Order order, IShippingRule rule) => rule.CostFor(order);1public static class Program
2{
3 // The one place that maps a method to its rule (like a DI registration)
4 private static readonly Dictionary<ShippingMethod, IShippingRule> Rules = new()
5 {
6 [ShippingMethod.Flat] = new FlatRateRule(),
7 [ShippingMethod.Express] = new ExpressRule(),
8 [ShippingMethod.Pickup] = new PickupRule(),
9 };
10
11 public static void Main()
12 {
13 foreach (var method in Rules.Keys)
14 {
15 var order = new Order { ShippingMethod = method, Weight = 4m };
16 var cost = Shipping.ShippingCost(order, Rules[method]);
17 Console.WriteLine($"{method,-8} {cost:0.00}");
18 }
19 }
20}$ dotnet runCada forma de envio agora é um objeto que sabe o próprio custo. Adicionar retirada na loja significa escrever um PickupRule e registrar. Alguém ainda precisa mapear a forma de envio do pedido para uma regra, mas isso vira um único lookup (um dicionário ou um registro no DI) em vez de um switch em cada funcionalidade. Quando a estimativa de entrega precisar da mesma distinção, ela vira mais um método na regra, não mais um switch. O código que calcula o frete fica intocado. Esse é o ganho: regra nova não encosta em código antigo, então as regras que já funcionam não têm motivo para quebrar.
O que custa: mais tipos, e a lógica fica espalhada em vários arquivos em vez de estar num switch que você lê de cima a baixo. Para um conjunto fechado de dois ou três casos que não mudam, o switch é mais claro, e eu manteria. Eu passo para objetos quando o conjunto continua crescendo, ou quando o mesmo switch aparece num segundo lugar.
Da POO ao design
Nenhuma dessas quatro ideias é nova. Elas continuam centrais porque quase tudo o que a gente chama de bom design é construído diretamente em cima delas.
- SOLID. O O (aberto/fechado) é polimorfismo:
IShippingRuleestá aberto para regras novas e fechado para edição. O L (substituição de Liskov) é o que mantém a herança honesta: o filho tem que cumprir tudo o que o pai promete. O D (inversão de dependência) é abstração: o checkout depende deIPaymentGateway, não de um fornecedor. - Strategy.
IShippingRuleeIDiscountsão o padrão Strategy. É polimorfismo com nome. - Injeção de dependência. Um container de DI existe para entregar um
IPaymentGatewaya uma classe que não deveria se importar com qual recebeu. Sem a abstração, não há o que injetar. - Agregados do DDD. Um agregado é encapsulamento no nível do domínio. O
Orderprotegendo os itens e o status é uma pequena raiz de agregado: a única porta de entrada para uma fronteira de consistência.
Por isso eu não trato POO como assunto velho. Frameworks e estilos de arquitetura mudam a cada poucos anos. A pergunta por baixo continua a mesma: onde essa regra mora, e quem pode mudá-la? Os quatro pilares ainda são a resposta mais direta que a gente tem no código.
Então fica a pergunta para você: no código em que você trabalha hoje, qual dos quatro está faltando justamente onde mais dói?