Orientação a objetos: Classes Seladas

Classes seladas são classes marcadas com o modificador sealed, que impede qualquer outra classe de herdar delas, e essa decisão de design muda a forma como o seu código é consumido. Neste artigo, você vai entender como elas funcionam no C#, ver dois casos reais em que o modificador evitou problemas e descobrir quando faz sentido selar uma classe.
O que são classes seladas no C#
Na orientação a objetos, a herança permite que uma classe reaproveite e estenda o comportamento de outra. No entanto, nem toda classe nasceu para servir de base, e é exatamente aí que entram as classes seladas.
Quando você aplica o sealed a uma classe, o compilador passa a recusar qualquer tentativa de derivação. Em outras palavras, a classe continua instanciável e utilizável, mas ninguém consegue criar uma subclasse a partir dela.
Esse recurso existe em várias linguagens orientadas a objetos. Por exemplo, o Java e o Kotlin usam a palavra final para o mesmo propósito, enquanto o Visual Basic usa NotInheritable.
A referência oficial do sealed resume a decisão em dois pontos. Primeiro, o benefício que as classes derivadas teriam ao customizar a sua classe; depois, o risco de uma derivação alterá-la a ponto de ela deixar de funcionar como esperado.
Por isso, criar classes seladas é uma decisão de contrato, e não apenas de sintaxe. Você está dizendo a quem consome o código que aquele comportamento é definitivo.
Além disso, algumas construções da linguagem já nascem seladas. As structs, por exemplo, são implicitamente seladas e não aceitam herança, e as classes estáticas também não podem servir de base.
Como criar classes seladas
A sintaxe é simples, pois basta adicionar a palavra-chave antes de class. O exemplo abaixo mostra uma versão simplificada da classe de notificação que vamos discutir mais adiante.
public sealed class Notification
{
public Notification(string key, string message)
{
Key = key;
Message = message;
}
public string Key { get; }
public string Message { get; }
}
Com isso, a classe Notification continua funcionando normalmente em qualquer parte da aplicação. A única diferença está na impossibilidade de usá-la como classe base.
O que acontece ao herdar de classes seladas
Agora, imagine que alguém tente criar uma notificação personalizada a partir da classe anterior. O código seria parecido com este.
// Esta derivação não compila
public class CustomNotification : Notification
{
public CustomNotification(string key, string message)
: base(key, message) { }
}
Nesse caso, o compilador interrompe o build com o erro CS0509. A mensagem deixa claro que CustomNotification não pode derivar do tipo selado Notification.
error CS0509: 'CustomNotification': cannot derive from sealed type 'Notification'
Dessa forma, o erro aparece em tempo de compilação, antes de qualquer teste ou deploy. Ou seja, a proteção das classes seladas não depende de revisão de código nem de documentação.
Selando métodos e propriedades com sealed override
Além de classes inteiras, o sealed também se aplica a membros que sobrescrevem um método ou propriedade virtual. Nesse cenário, a classe continua aberta para herança, mas aquele membro específico deixa de aceitar novas sobrescritas.
public class Entity
{
public virtual string Describe() => GetType().Name;
}
public class Customer : Entity
{
public string Name { get; init; } = string.Empty;
// Nenhuma classe derivada de Customer pode sobrescrever Describe
public sealed override string Describe() => $"Customer: {Name}";
}
Repare que o modificador sempre aparece junto com override. Afinal, só faz sentido selar um membro que já participa de uma cadeia de sobrescrita, e uma nova tentativa de sobrescrevê-lo gera o erro CS0239.
Por outro lado, o sealed não pode aparecer junto com abstract na mesma classe. Enquanto uma classe abstrata exige herança para funcionar, as classes seladas proíbem qualquer derivação.
Classes seladas no Flunt: o caso da Notification
O Flunt é um pacote que criei para aplicar validações usando o padrão Domain Notification. Com o tempo, ele ganhou popularidade e até versões para outras linguagens, como Java e JavaScript.
Durante muito tempo, eu me perguntei onde usaria classes seladas em um cenário real. Então, em 2017, a resposta apareceu dentro do próprio Flunt, que você conhece melhor no artigo sobre o Flunt.
Antes dessa decisão, a classe Notification não estava selada. Com isso, qualquer projeto podia criar classes derivadas e adicionar o que quisesse a elas.
O problema das notificações customizadas
Durante uma consultoria, encontrei um cliente que havia criado várias especializações da notificação. O código era parecido com este.
// Classe base do pacote, ainda sem sealed
public class Notification
{
public string Key { get; set; } = string.Empty;
public string Message { get; set; } = string.Empty;
}
// Derivações criadas no projeto do cliente
public class ErrorNotification : Notification
{
public int Code { get; set; }
}
public class WarningNotification : Notification
{
public string Severity { get; set; } = "low";
}
public class SuccessNotification : Notification
{
public int Id { get; set; }
public string Type { get; set; } = "success";
}
Cada derivação trazia propriedades e métodos próprios. Além disso, graças ao upcast, todas elas podiam ocupar o lugar da Notification em qualquer método que esperasse a classe base.
À primeira vista, isso parece inofensivo e até elegante. Contudo, algumas classes não devem permitir customização, porque o valor delas está justamente na padronização.
Por que padronizar o contrato da API
A ideia por trás das notificações é garantir um formato único nas respostas da API. Por exemplo, todo erro 400 (Bad Request) devolve uma lista de notificações com chave e mensagem.
{
"status": 400,
"message": "Request failed",
"data": null,
"notifications": [
{ "key": "Name", "message": "Invalid name" },
{ "key": "Document", "message": "Invalid document" }
]
}
Assim, qualquer aplicação que consome a API se prepara para um único formato. O frontend cria uma tipagem para o retorno e trata todas as respostas da mesma maneira.
Entretanto, com as derivações no cliente, o nó notifications passou a variar conforme o tipo da notificação. Veja dois retornos possíveis do mesmo endpoint.
{
"status": 200,
"message": "Customer created",
"data": null,
"notifications": [
{ "id": 1, "type": "success", "key": "Customer", "message": "Created" }
]
}
{
"status": 400,
"message": "Request failed",
"data": null,
"notifications": [
{ "code": 42, "key": "Document", "message": "Invalid document" }
]
}
Na prática, cada consumidor precisaria de um switch para descobrir o tipo de notificação e tratá-la de forma diferente. Vale lembrar também que o consumidor não se limita à web, pois apps em Flutter, .NET MAUI e outras tecnologias dependem do mesmo contrato.
Foi desse cenário que nasceu a decisão de selar a classe Notification. No histórico público do repositório, a classe já aparece como public sealed class Notification no commit de outubro de 2017 em que o pacote foi portado do FluentValidator.
Vale registrar, porém, que a versão 2 do Flunt, iniciada em 2021, voltou a declarar a Notification sem o modificador. Portanto, o exemplo vale como estudo de caso da linha 1.x, e não como descrição do pacote atual.
Se você trabalha com modelagem de erros de domínio, vale conhecer também a comparação entre exceptions e Domain Notification. Os union types do C# 15 seguem outro caminho, mas partem da mesma preocupação com contratos previsíveis.
Classes seladas para proteger recursos: o DbSession com Dapper
O segundo caso surgiu anos depois, em pontos críticos de leitura das aplicações do balta. Nessas rotas, eu uso o Dapper para acessar os dados com o mínimo de sobrecarga.
O Dapper é um micro ORM que apenas estende o IDbConnection. Portanto, o gerenciamento da conexão com o banco fica inteiramente por conta de quem escreve o código, como mostra o artigo sobre CRUD com Dapper.
Por esse motivo, eu sempre crio uma classe responsável por abrir a conexão e garantir que ela seja encerrada. Para isso, basta implementar IDisposable e liberar a conexão no método Dispose.
using System.Data;
using Microsoft.Data.SqlClient;
public sealed class DbSession : IDisposable
{
public DbSession(string connectionString)
{
Connection = new SqlConnection(connectionString);
Connection.Open();
}
public IDbConnection Connection { get; }
public IDbTransaction? Transaction { get; set; }
public void Dispose()
{
// Encerra a transação pendente e devolve a conexão ao pool
Transaction?.Dispose();
Connection.Dispose();
}
}
Em seguida, o container de injeção de dependência registra a sessão com tempo de vida por requisição. Assim, o próprio ASP.NET Core chama o Dispose ao final de cada request.
var connectionString = builder.Configuration.GetConnectionString("Default")!;
builder.Services.AddScoped(_ => new DbSession(connectionString));
O risco de uma sessão derivada
Você provavelmente reparou que a classe está selada. De fato, poucas classes da aplicação são tão sensíveis quanto essa, porque um erro nela deixa conexões abertas no SQL Server.
Sem o sealed, alguém poderia criar uma versão derivada e reimplementar a interface IDisposable. O exemplo abaixo compila normalmente quando a classe base não está selada.
// Sem sealed na classe base, este código compila
public class CustomDbSession : DbSession, IDisposable
{
public CustomDbSession(string connectionString)
: base(connectionString) { }
// Reimplementa a interface e esquece de fechar a conexão
public new void Dispose()
{
Console.WriteLine("Session finished");
}
}
Nesse cenário, o container chamaria o Dispose da classe derivada, porque a reimplementação da interface tem prioridade. Com isso, a conexão nunca voltaria ao pool, e o problema só apareceria em produção, sob carga.
Por isso, as classes seladas funcionam aqui como uma trava de segurança. A classe que cuida do ciclo de vida da conexão permanece única, e ninguém consegue trocá-la por uma variação mal implementada.
Se você quiser entender melhor o que acontece com objetos que não liberam recursos, o artigo sobre o garbage collector em C# explica por que o GC não substitui o Dispose.
Ganhos de performance com classes seladas
Além do design, as classes seladas também trazem benefícios de desempenho. A especificação da linguagem explica que, como uma classe selada nunca terá derivadas, o runtime pode transformar chamadas virtuais em chamadas não virtuais.
Esse processo recebe o nome de desvirtualização. Quando o JIT sabe que não existe outra implementação possível, ele chama o método diretamente e ainda pode fazer inlining.
Da mesma forma, verificações de tipo com is e conversões com as tendem a ficar mais baratas. Afinal, o runtime só precisa comparar um tipo exato, sem percorrer uma hierarquia de herança.
Na maioria das aplicações, o ganho individual é pequeno. No entanto, em código executado milhões de vezes, como serializadores, parsers e caminhos críticos de APIs, a soma faz diferença.
O exemplo abaixo mostra uma situação comum em que o JIT tira proveito do tipo selado. Como Notification não admite derivadas, a verificação de tipo se resume a uma comparação simples.
public static int CountNotifications(IEnumerable<object> items)
{
var total = 0;
foreach (var item in items)
{
// Com sealed, o runtime compara apenas o tipo exato
if (item is Notification)
total++;
}
return total;
}
Ainda assim, trate a performance como um bônus, e não como o motivo principal. Se a classe precisa continuar extensível por razões de design, o ganho de alguns nanossegundos não compensa a perda de flexibilidade.
A regra CA1852 para tipos internos
O próprio .NET incentiva essa prática com a regra de análise CA1852. Ela aponta tipos internos que não possuem nenhuma subclasse dentro do assembly e sugere transformá-los em classes seladas.
A regra surgiu no .NET 7 e pertence à categoria de performance. No .NET 10, ela continua desabilitada por padrão, então você precisa ativá-la manualmente.
# .editorconfig
[*.cs]
dotnet_diagnostic.CA1852.severity = warning
Como o tipo é interno, selar a classe não quebra nenhum consumidor externo. Portanto, essa é a forma mais segura de começar a usar o modificador em projetos existentes.
Há apenas um detalhe. Se o assembly usar InternalsVisibleTo, a regra deixa de sinalizar os tipos por padrão, a menos que você configure a opção ignore_internalsvisibleto.
Quando não criar classes seladas
Apesar dos benefícios, selar tudo por reflexo também causa problemas. As diretrizes de design de frameworks da Microsoft recomendam não selar classes sem um bom motivo.
Segundo essas diretrizes, não conseguir imaginar um cenário de extensão não justifica, por si só, selar uma classe. Muitas vezes, quem consome uma biblioteca herda de uma classe apenas para adicionar métodos de conveniência.
Outro ponto importante envolve bibliotecas públicas. Remover o sealed de uma classe não quebra ninguém, mas adicioná-lo depois quebra todo consumidor que já herdava dela.
Por isso, em pacotes distribuídos via NuGet, a escolha precisa acontecer no início do projeto. Caso contrário, a mudança exigirá uma nova versão major e um aviso claro aos usuários.
Também é preciso pensar nos testes. Bibliotecas de mock como o Moq geram proxies por herança, então uma classe selada sem interface não pode ser substituída por um dublê.
Nesse caso, a solução costuma ser expor uma interface e fazer a classe selada implementá-la. Assim, os testes trabalham com a abstração, enquanto a implementação concreta continua protegida.
Por fim, existe uma diferença importante entre aplicações e bibliotecas. Dentro de uma aplicação, você controla todo o código e pode remover o modificador a qualquer momento, sem impacto para terceiros.
Já em uma biblioteca, cada decisão vira parte do contrato com pessoas que você não conhece. Por isso, nesse contexto, vale documentar o motivo de cada uma das classes seladas, para que ninguém interprete a restrição como descuido.
Boas práticas para decidir sobre classes seladas
Depois dos dois casos reais, fica mais fácil transformar a experiência em um critério. Em geral, eu considero criar classes seladas quando pelo menos uma das situações abaixo acontece.
- A classe representa um contrato que precisa permanecer idêntico em todos os sistemas, como a
Notificationdo Flunt. - A classe controla um recurso crítico, como conexões, transações, arquivos ou credenciais.
- O tipo é interno ao assembly e nenhuma classe herda dele, como aponta a regra CA1852.
- A classe sobrescreve muitos membros virtuais e selar cada um deles seria mais trabalhoso do que selar a classe.
- O tipo é um atributo consultado com frequência em tempo de execução.
Por outro lado, mantenho a classe aberta quando ela faz parte da API pública de uma biblioteca e a extensão é um caso de uso legítimo. Também evito selar classes que outros times precisam estender sem acesso ao código-fonte.
Vale ainda uma regra complementar das diretrizes oficiais. Nas classes seladas, não declare membros protected nem virtual, porque nenhuma derivada poderá usá-los.
Na prática, isso afeta inclusive o padrão de Dispose. Em uma classe selada como o DbSession, você não precisa do método protected virtual void Dispose(bool disposing), já que nenhuma subclasse vai sobrescrevê-lo.
Por fim, records também aceitam o modificador. Com sealed record, você ganha imutabilidade e ainda impede que o contrato do record mude por herança.
Conclusão
Na correria do dia a dia, a tendência é criar uma nova versão de uma classe em vez de melhorar a existente. As classes seladas agem exatamente nesse ponto, porque travam o que não deve ser estendido e forçam o código a seguir um padrão.
Nos dois casos deste artigo, o modificador resolveu problemas diferentes. No Flunt, ele garantiu um contrato único de notificações, enquanto no DbSession ele protegeu o ciclo de vida da conexão com o banco.
Além disso, o modificador ainda abre espaço para otimizações do runtime, especialmente em tipos internos. Ainda assim, isso não significa que você deve sair selando todas as classes do sistema só porque aprendeu algo novo hoje.
Em resumo, use classes seladas como uma decisão consciente de design. Sele o que precisa ser único ou protegido, e deixe aberto o que nasceu para ser estendido.
FAQ
O que significa uma classe sealed no C#?
É uma classe que não aceita herança. Você ainda pode instanciá-la e usá-la normalmente, mas o compilador gera o erro CS0509 se alguém tentar derivar dela.
Qual a diferença entre sealed e abstract?
São opostos. A classe abstrata existe para ser herdada e não pode ser instanciada, enquanto a classe selada pode ser instanciada e não pode ser herdada.
Classes sealed são mais rápidas?
Podem ser, porque o JIT consegue desvirtualizar chamadas e simplificar verificações de tipo. Na maioria dos casos o ganho é pequeno, mas ele se acumula em código muito executado.
Posso selar apenas um método?
Sim, desde que o método sobrescreva um membro virtual. Nesse caso, você usa sealed override e a classe continua aberta para herança.
Como testar uma classe selada com mocks?
Faça a classe implementar uma interface e dependa dessa interface no restante do código. Dessa forma, o mock substitui a abstração sem precisar herdar da classe concreta.
Adicionar sealed em uma biblioteca pública é uma breaking change?
Sim, porque qualquer consumidor que herdava da classe deixa de compilar. Já remover o sealed de uma classe não quebra ninguém.