Types
Escopo: idioma C# / .NET moderno. Decisões de arquitetura entre tipos (quando criar contratos, quando herdar, quando compor) estão em
shared/architecture/architecture.mdeshared/architecture/patterns.md; este documento cobre as ferramentas do idioma.
O C# oferece cinco formas de declarar um tipo: interface, abstract class, class, record e struct. Cada uma atende bem a um caso específico. Escolher a forma errada compila e roda, então o erro passa despercebido no começo. O custo aparece quando você precisa guardar uma regra em algum lugar e o tipo escolhido não tem onde guardá-la, e a regra acaba repetida em vários pontos do código.
Conceitos fundamentais
| Conceito | O que é |
|---|---|
| interface (contrato sem estado) | Descreve capacidade; suporta múltipla implementação; sem campos nem implementação obrigatória |
| abstract class (classe abstrata) | Identidade parcial: estado e comportamento compartilhados, completados pelas filhas |
| class (tipo de referência) | Tipo padrão: identidade por referência, estado pode ser alterado, alocado no heap |
| record (tipo com igualdade por valor) | Tipo que não muda após criação, com igualdade estrutural; ideal para DTOs e value objects |
| struct (tipo de valor) | Tipo alocado em pilha, igualdade por valor; para dados pequenos e sem identidade |
| sealed (fechada para herança) | Modificador que impede herança; comunica que a classe não foi pensada para ser estendida |
| generic (tipo genérico) | Parâmetro de tipo (Result<T>); reaproveita o contrato sem perder verificação |
| value object (objeto de valor) | Tipo cuja igualdade é definida pelos campos, não pela referência (record cobre o caso) |
Quando usar interface e quando usar abstract class
A pergunta que decide é se as implementações vão compartilhar código. Se compartilham, use abstract class: ela guarda o estado comum (o logger, por exemplo) e o método que fixa a sequência dos passos, deixando as filhas preencherem os buracos. Se compartilham só a assinatura, use interface, que várias classes podem implementar ao mesmo tempo e que não carrega campo nenhum.
O C# 8 permite escrever corpo de método dentro da interface. Isso tenta a gente a usar interface para reaproveitar código, e aí uma classe que implementa duas interfaces com o mesmo método herda duas implementações concorrentes do mesmo nome. Deixe o código compartilhado na abstract class.
❌ Ruim: interface usada para compartilhar código entre implementações
public interface OrderProcessor
{
// C# 8+ aceita corpo de método na interface; quem implementar duas
// interfaces com este mesmo método herda duas versões concorrentes dele
Task<Result> ProcessAsync(Order order)
{
var validation = Validate(order);
if (validation.IsFailure) return validation;
return ExecuteAsync(order);
}
Result Validate(Order order);
Task<Result> ExecuteAsync(Order order);
}
✅ Bom: classe abstrata guarda o estado comum e fixa a sequência dos passos
public abstract class OrderProcessor(ILogger logger)
{
protected readonly ILogger _logger = logger;
public async Task<Result> ProcessAsync(Order order)
{
var validation = Validate(order);
if (validation.IsFailure)
{
_logger.LogWarning("Order validation failed: {Reason}", validation.Error);
return validation;
}
var execution = await ExecuteAsync(order);
return execution;
}
protected abstract Result Validate(Order order);
protected abstract Task<Result> ExecuteAsync(Order order);
}
public sealed class StandardOrderProcessor(ILogger logger) : OrderProcessor(logger)
{
protected override Result Validate(Order order) { /* ... */ }
protected override Task<Result> ExecuteAsync(Order order) { /* ... */ }
}
✅ Bom: interface quando só o contrato importa
public interface IOrderRepository
{
Task<Order?> FindByIdAsync(Guid id, CancellationToken ct);
Task SaveAsync(Order order, CancellationToken ct);
}
public sealed class SqlOrderRepository(AppDbContext db) : IOrderRepository { /* ... */ }
public sealed class InMemoryOrderRepository : IOrderRepository { /* testes */ }
Toda classe concreta nasce sealed
sealed impede que alguém herde da classe. Vale invertê-lo em relação ao default da linguagem: marque sealed sempre, e tire o modificador só quando a herança for uma decisão de projeto. Uma classe aberta promete que dá para estendê-la com segurança, e essa promessa foi feita sem ninguém decidir: quem herdar vai sobrescrever um método e pode quebrar uma garantia que a classe original mantinha em silêncio.
❌ Ruim: classe concreta sem sealed, extensibilidade acidental
public class OrderService(IOrderRepository orderRepository)
{
public async Task<Result<Order>> CreateOrderAsync(OrderRequest request) { /* ... */ }
}
// em outro assembly, alguém estende sem conhecimento do autor
public class CustomOrderService : OrderService
{
// override acidental quebra invariantes esperadas pelo OrderService original
}
✅ Bom: sealed por padrão, extensibilidade exige decisão
public sealed class OrderService(IOrderRepository orderRepository)
{
public async Task<Result<Order>> CreateOrderAsync(OrderRequest request) { /* ... */ }
}
Duas exceções valem: o tipo desenhado para herança (a abstract class que fixa a sequência dos passos) e o tipo publicado numa biblioteca que documenta como estendê-lo.
record para dados, class para objetos com identidade
Use record quando o tipo existe para carregar valores: DTO, value object, resposta de API (Application Programming Interface · Interface de Programação de Aplicações), resultado de domínio. Ele entrega comparação por valor, um ToString() legível e a palavra with para criar cópias com campos trocados, sem escrever nada disso à mão.
Use class quando o objeto tem identidade própria e continua o mesmo depois de mudar de estado. Um pedido com o mesmo Id é o mesmo pedido, mesmo depois de o total mudar, e a comparação por valor do record daria a resposta errada nesse caso.
❌ Ruim: class com setters para dados que não mudam
public class OrderResponse
{
public Guid Id { get; set; }
public string ProductId { get; set; }
public decimal Total { get; set; }
// igualdade por referência: duas OrderResponse com os mesmos valores comparam desiguais
}
✅ Bom: record para dados, com a comparação por valor pronta
public record OrderResponse
{
public required Guid Id { get; init; }
public required string ProductId { get; init; }
public required decimal Total { get; init; }
}
var updated = orderResponse with { Total = newTotal };
Existe ainda o record struct, para quando o dado é pequeno e vale evitar a alocação no heap. Ele resolve um problema de desempenho, então adote com medição na mão.
Tipos de referência anuláveis
Ligar <Nullable>enable</Nullable> no .csproj muda o significado das assinaturas do projeto inteiro: string passa a prometer que nunca é nulo, e string? avisa que pode ser. O compilador avisa em todo ponto onde um string? é usado sem verificação, e aquele NullReferenceException que aparecia em produção vira aviso durante o build.
❌ Ruim: nullable desligado, null silencioso no contrato
public class OrderService
{
public Order FindById(Guid id)
{
// o retorno aceita nulo sem marcar isso na assinatura,
// então quem chama não recebe nenhum aviso do compilador
return _orderRepository.FindById(id);
}
}
✅ Bom: nullable habilitado, contrato explícito
public sealed class OrderService(IOrderRepository orderRepository)
{
public Order? FindById(Guid id)
{
var order = orderRepository.FindById(id);
return order;
}
}
// o "?" no retorno obriga quem chama a tratar o caso nulo
var order = orderService.FindById(id);
if (order is null)
return NotFound();
// depois do if acima, o compilador já sabe que order não é nulo
var total = order.Total;
O operador ! (null-forgiving) manda o compilador confiar em você e parar de avisar. Ele cabe quando uma garantia externa ao código assegura o valor e o compilador não tem como enxergar isso. Ele não cabe para silenciar um aviso legítimo, porque o aviso volta como exceção em produção. Detalhes em null-safety.md.
Pattern matching
O pattern matching (correspondência de padrões) troca cadeias de if/else por is, switch expressions e padrões que leem propriedades do objeto. Ele faz o narrowing (estreitamento do tipo) sozinho: dentro do bloco onde o teste passou, a variável já vem com o tipo específico. Quando o conjunto de variantes é fechado, o compilador ainda acusa a variante que ficou sem tratamento.
❌ Ruim: cadeia de if com cast explícito
public string DescribePayment(IPayment payment)
{
if (payment is CreditCard)
{
var creditCard = (CreditCard)payment;
return $"Credit card ending in {creditCard.LastFour}";
}
if (payment is Pix)
{
var pix = (Pix)payment;
return $"Pix to {pix.Key}";
}
return "Unknown payment";
}
✅ Bom: o próprio `is` já entrega a variável com o tipo certo, sem cast
public string DescribePayment(IPayment payment)
{
if (payment is CreditCard creditCard)
{
var creditCardDescription = $"Credit card ending in {creditCard.LastFour}";
return creditCardDescription;
}
if (payment is Pix pix)
{
var pixDescription = $"Pix to {pix.Key}";
return pixDescription;
}
var fallback = "Unknown payment";
return fallback;
}
Quando o domínio tem um conjunto fechado de variantes, declare cada uma como um tipo. O resultado do pagamento deixa de ser um campo de texto que alguém precisa comparar com a string certa, e passa a ser PaymentResult.Success ou PaymentResult.Failure, cada um carregando os campos que só fazem sentido no seu caso.
✅ Bom: sucesso e falha são tipos distintos, e o `is` escolhe o caminho
public abstract record PaymentResult
{
public sealed record Success(string TransactionId) : PaymentResult;
public sealed record Failure(string ErrorCode, string ErrorMessage) : PaymentResult;
}
public IActionResult HandlePayment(PaymentResult result)
{
if (result is PaymentResult.Success success)
{
var successBody = new { success.TransactionId };
var okResponse = Ok(successBody);
return okResponse;
}
if (result is PaymentResult.Failure failure)
{
var failureBody = new { failure.ErrorCode, failure.ErrorMessage };
var badRequest = BadRequest(failureBody);
return badRequest;
}
var serverError = StatusCode(500);
return serverError;
}
Constraint declara o que o tipo genérico precisa ter
Um genérico sem constraint (restrição de tipo) aceita qualquer tipo, e por isso o método não consegue chamar nada nele. A saída costuma ser descobrir em tempo de execução se o tipo tem o que o método precisa, usando reflection (inspeção do tipo em tempo de execução), e lançar exceção quando ele não tem. A constraint (where T : IEntity, where T : struct, where T : new()) escreve esse requisito na assinatura: o método passa a acessar Id direto, e o tipo que não atende nem compila.
❌ Ruim: sem restrição, o método só descobre em tempo de execução se o tipo serve
public T? Find<T>(Guid id) where T : class
{
// método precisa lançar exceção se T não for uma entidade
var entityType = typeof(T);
var idProperty = entityType.GetProperty("Id");
if (idProperty is null)
throw new InvalidOperationException($"{entityType.Name} is not an entity.");
// ...
}
✅ Bom: a restrição escreve o requisito na assinatura, e o compilador confere
public interface IEntity
{
Guid Id { get; }
}
public T? Find<T>(Guid id) where T : class, IEntity
{
var entity = _context.Set<T>().FirstOrDefault(candidate => candidate.Id == id);
return entity;
}
Evitar dynamic
dynamic desliga a verificação de tipos naquela variável. O compilador para de conferir os nomes que você acessa, e um erro de digitação como TimoutSeconds compila sem reclamar e explode como RuntimeBinderException quando o código roda.
Dois casos justificam o uso: interoperar com COM e Office, e ler dados cujo formato muda de verdade a cada resposta. Mesmo no segundo caso, JsonElement ou JsonNode resolvem com verificação.
❌ Ruim: dynamic para conveniência
public void ProcessConfig(dynamic config)
{
var endpoint = config.Api.Endpoint; // erro de digitação vira exception em runtime
var timeout = config.Api.TimoutSeconds; // typo silencioso
}
✅ Bom: tipo concreto ou JsonElement
public sealed record ApiConfig
{
public required string Endpoint { get; init; }
public required int TimeoutSeconds { get; init; }
}
public void ProcessConfig(ApiConfig config)
{
var endpoint = config.Endpoint;
var timeout = config.TimeoutSeconds;
}
DoDocs v3.7.0 · Desenvolvido por @thiagocajadev · Baseado no trabalho de pmndrs/docs · Poimandres.