Design e Compatibilidade de API Pública
Quando Usar Esta Skill
Use esta skill quando:
- Projetando APIs públicas para pacotes NuGet ou bibliotecas
- Fazendo mudanças em APIs públicas existentes
- Planejando mudanças no formato de wire para sistemas distribuídos
- Implementando estratégias de versionamento
- Revisando pull requests para mudanças que quebram
Os Três Tipos de Compatibilidade
| Tipo | Definição | Escopo |
|---|---|---|
| API/Source | Código compila contra a versão mais nova | Assinaturas de métodos públicos, tipos |
| Binária | Código compilado executa contra a versão mais nova | Layout de assembly, tokens de método |
| Wire | Dados serializados legíveis por outras versões | Protocolos de rede, formatos de persistência |
Quebrar qualquer um destes cria atrito de upgrade para os usuários.
Design Apenas por Extensão
A base de APIs estáveis: nunca remova ou modifique, apenas estenda.
Três Pilares
- Funcionalidade anterior é imutável - Uma vez lançada, o comportamento e assinaturas estão bloqueados
- Nova funcionalidade através de novos construtos - Adicione sobrecargas, novos tipos, funcionalidades opt-in
- Remoção apenas após período de depreciação - Anos, não versões
Benefícios
- Código antigo continua funcionando em novas versões
- Caminhos novos e antigos coexistem
- Upgrades são não-quebrantes por padrão
- Usuários atualizam em seu próprio cronograma
Recursos:
Diretrizes para Mudanças na API
Mudanças Seguras (Qualquer Versão)
// ADICIONAR novas sobrecargas com parâmetros padrão
public void Process(Order order, CancellationToken ct = default);
// ADICIONAR novos parâmetros opcionais a métodos existentes
public void Send(Message msg, Priority priority = Priority.Normal);
// ADICIONAR novos tipos, interfaces, enums
public interface IOrderValidator { }
public enum OrderStatus { Pending, Complete, Cancelled }
// ADICIONAR novos membros a tipos existentes
public class Order
{
public DateTimeOffset? ShippedAt { get; init; } // NOVO
}
Mudanças Inseguras (Nunca ou Apenas Versão Principal)
// REMOVER ou RENOMEAR membros públicos
public void ProcessOrder(Order order); // Era: Process()
// MUDAR tipos de parâmetros ou ordem
public void Process(int orderId); // Era: Process(Order order)
// MUDAR tipos de retorno
public Order? GetOrder(string id); // Era: public Order GetOrder()
// MUDAR modificadores de acesso
internal class OrderProcessor { } // Era: public
// ADICIONAR parâmetros obrigatórios sem padrões
public void Process(Order order, ILogger logger); // Quebra chamadores!
Padrão de Depreciação
// Passo 1: Marcar como obsoleto com versão (qualquer versão)
[Obsolete("Obsoleto desde v1.5.0. Use ProcessAsync em vez disso.")]
public void Process(Order order) { }
// Passo 2: Adicionar nova API recomendada (mesma versão)
public Task ProcessAsync(Order order, CancellationToken ct = default);
// Passo 3: Remover na próxima versão principal (v2.0+)
// Apenas depois que os usuários tiveram tempo para migrar
Teste de Aprovação de API
Evite mudanças acidentais que quebram com testes automatizados de superfície da API.
Usando ApiApprover + Verify
dotnet add package PublicApiGenerator
dotnet add package Verify.Xunit
[Fact]
public Task ApprovePublicApi()
{
var api = typeof(MyLibrary.PublicClass).Assembly.GeneratePublicApi();
return Verify(api);
}
Cria ApprovePublicApi.verified.txt:
namespace MyLibrary
{
public class OrderProcessor
{
public OrderProcessor() { }
public void Process(Order order) { }
public Task ProcessAsync(Order order, CancellationToken ct = default) { }
}
}
Qualquer mudança na API falha o teste - o revisor deve aprovar explicitamente as mudanças.
Processo de Revisão de PR
- O PR inclui mudanças nos arquivos
*.verified.txt - Revisores veem a superfície exata da API alterada no diff
- Mudanças que quebram são imediatamente visíveis
- Decisão consciente necessária para aprovar
Compatibilidade de Wire
Para sistemas distribuídos, dados serializados devem ser legíveis entre versões.
| Direção | Requisito |
|---|---|
| Retroativa | Escritores antigos → Leitores novos (versão atual lê dados antigos) |
| Avançada | Escritores novos → Leitores antigos (versão antiga lê dados novos) |
Ambos são necessários para upgrades contínuos sem downtime.
Evoluindo Formatos de Wire com Segurança
Fase 1: Adicionar suporte de leitura (opt-in)
// Novo tipo de mensagem - leitores implantados primeiro
public sealed record HeartbeatV2(
Address From,
long SequenceNr,
long CreationTimeMs); // NOVO campo
// Desserializador lida com ambos antigo e novo
public object Deserialize(byte[] data, string manifest) => manifest switch
{
"Heartbeat" => DeserializeHeartbeatV1(data), // Formato antigo
"HeartbeatV2" => DeserializeHeartbeatV2(data), // Formato novo
_ => throw new NotSupportedException()
};
Fase 2: Ativar lado de escrita (opt-out, próxima versão menor)
// Config para habilitar novo formato (desligado por padrão inicialmente)
akka.cluster.use-heartbeat-v2 = on
Fase 3: Tornar padrão (versão futura)
Após a base instalada ter absorvido o código de leitura.
Serialização Baseada em Esquema
Prefira formatos baseados em esquema em vez de baseados em reflexão:
| Formato | Tipo | Compatibilidade de Wire |
|---|---|---|
| Protocol Buffers | Baseado em esquema | Excelente - números de campo explícitos |
| MessagePack | Baseado em esquema | Bom - com contratos |
| System.Text.Json | Baseado em esquema (com source gen) | Bom - propriedades explícitas |
| Newtonsoft.Json | Baseado em reflexão | Ruim - nomes de tipo no payload |
| BinaryFormatter | Baseado em reflexão | Terrível - nunca use |
Veja a skill dotnet/serialization para detalhes.
Padrões de Encapsulamento
APIs Internas
Marque APIs não-públicas explicitamente:
// Atributo para documentação
[InternalApi]
public class ActorSystemImpl { }
// Convenção de namespace
namespace MyLibrary.Internal
{
public class InternalHelper { } // Público para extensibilidade, não para usuários
}
Documente claramente:
Tipos em namespaces
.Internalou marcados com[InternalApi]podem mudar entre qualquer versão sem aviso.
Selagem de Classes
// FAÇA: Sele classes não projetadas para herança
public sealed class OrderProcessor { }
// NÃO FAÇA: Deixar sem selo por acidente
public class OrderProcessor { } // Usuários podem herdar, bloqueando mudanças
Segregação de Interfaces
// FAÇA: Interfaces pequenas e focadas
public interface IOrderReader
{
Order? GetById(OrderId id);
}
public interface IOrderWriter
{
Task SaveAsync(Order order);
}
// NÃO FAÇA: Interfaces monolíticas (não pode adicionar métodos sem quebrar)
public interface IOrderRepository
{
Order? GetById(OrderId id);
Task SaveAsync(Order order);
// Adicionar novos métodos quebra todas as implementações!
}
Estratégia de Versionamento
Versionamento Semântico (Prático)
| Versão | Mudanças Permitidas |
|---|---|
| Patch (1.0.x) | Correções de bugs, patches de segurança |
| Menor (1.x.0) | Novas funcionalidades, depreciações, remoção de obsoletos |
| Principal (x.0.0) | Mudanças que quebram, remoção de API antiga |
Princípios Chave
- Sem quebras surpresa - Mesmo versões principais devem ser anunciadas e planejadas
- Extensões a qualquer momento - Novas APIs podem ser lançadas em qualquer versão
- Depreciar antes de remover -
[Obsolete]por pelo menos uma versão menor - Comunicar prazos - Usuários precisam planejar upgrades
A Cerca de Chesterton
Antes de remover ou mudar algo, entenda por que existe.
Assuma que toda API pública é usada por alguém. Se quiser mudá-la:
- Socialize a proposta no GitHub
- Documente o caminho de migração
- Forneça período de depreciação
- Lance em uma versão planejada
Checklist de Pull Request
Ao revisar PRs que tocam em APIs públicas:
- Nenhum membro público removido (use
[Obsolete]em vez disso) - Nenhuma assinatura alterada (adicione sobrecargas em vez disso)
- Nenhum novo parâmetro obrigatório (use padrões)
- Teste de aprovação de API atualizado (mudanças em
.verified.txtrevisadas) - Mudanças no formato de wire são opt-in (lado de leitura primeiro)
- Mudanças que quebram documentadas (notas de versão, guia de migração)
Anti-Padrões
Mudanças que Quebram Disfarçadas de Correções
// "Correção de bug" que quebra usuários
public async Task<Order> GetOrderAsync(OrderId id) // Era síncrono!
{
// "Corrigido" para ser assíncrono - mas quebra todos os chamadores
}
// Correto: Adicionar novo método, depreciar antigo
[Obsolete("Use GetOrderAsync em vez disso")]
public Order GetOrder(OrderId id) => GetOrderAsync(id).Result;
public async Task<Order> GetOrderAsync(OrderId id) { }
Mudanças Silenciosas de Comportamento
// Mudar padrões quebra usuários que confiavam no comportamento antigo
public void Configure(bool enableCaching = true) // Era: false!
// Correto: Novo parâmetro com novo nome
public void Configure(
bool enableCaching = false, // Padrão original preservado
bool enableNewCaching = true) // Novo comportamento opt-in
Serialização Polimórfica
// EVITAR: Nomes de tipo no formato de wire
{ "$type": "MyApp.Order, MyApp", "Id": 123 }
// Renomear classe Order = quebra de wire!
// PREFERIR: Discriminadores explícitos
{ "type": "order", "id": 123 }