Validação, ProblemDetails e OpenAPI: uma API que explica a si mesma

Validação, ProblemDetails e OpenAPI: uma API que explica a si mesma

Uma API que diz ao cliente o que ele errou: validação nativa do .NET 10 com DataAnnotations, atributo próprio de ISBN, IValidatableObject, ProblemDetails do RFC 9457, IExceptionHandler para exceções de domínio e OpenAPI 3.1 gerado do código, com Scalar e verificação no CI.
Linguagem C#

• • 18 min de leitura

Uma API madura diz o que o cliente errou, sempre no mesmo formato, e publica uma descrição de si mesma que outras ferramentas conseguem ler. Neste artigo a Livraria ganha essas três coisas com o que o próprio .NET 10 oferece.

Quatro tipos de falha

Nem todo erro é igual, e o código de status é a primeira informação que o cliente recebe:

Situação Exemplo Status
Requisição malformada JSON inválido, "preco": "abc" 400
Dados bem formados, mas inválidos título vazio, ISBN com dígito errado 400
Válida, mas conflita com o estado atual estoque insuficiente, ETag desatualizado 409 / 412
Falha do servidor banco fora do ar, bug 500 / 503

A regra que orienta tudo: 4xx significa "o cliente pode corrigir", 5xx significa "o cliente não pode fazer nada além de tentar de novo depois". Hoje a Livraria responde 500 para um título vazio, e isso leva o cliente a acreditar que o servidor está quebrado.

Validação de entrada no .NET 10

Até o .NET 9, as Minimal APIs não validavam nada sozinhas, e cada equipe improvisava com filtros ou com bibliotecas externas. O .NET 10 trouxe validação nativa baseada nos atributos de System.ComponentModel.DataAnnotations, implementada com um source generator (sem reflexão em tempo de execução e compatível com Native AOT). Basta um registro:

builder.Services.AddValidation();

Nas versões de pré-lançamento do .NET 10 também era preciso declarar uma propriedade InterceptorsNamespaces no .csproj. Na versão final basta o AddValidation(). Se um tutorial antigo mandar acrescentar essa propriedade, é resquício do preview.

Os DTOs de entrada ganham os atributos. Records com propriedades declaradas deixam o alvo de cada atributo sem ambiguidade:

using System.ComponentModel.DataAnnotations;

public sealed record NovoLivro
{
    [Required, StringLength(200, MinimumLength = 1)]
    public required string Titulo { get; init; }

    [Required, StringLength(120)]
    public required string Autor { get; init; }

    [Required, Isbn]
    public required string Isbn { get; init; }

    [Range(typeof(decimal), "0.01", "100000")]
    public decimal Preco { get; init; }

    [Range(1, 5_000)]
    public int PesoGramas { get; init; }

    [Range(0, 1_000_000)]
    public int Estoque { get; init; }

    [MaxLength(10)]
    public List<string>? Generos { get; init; }
}

O source generator encontra os tipos usados como parâmetros de endpoint e gera o código de validação. Quando um POST chega com título vazio e preço negativo, o endpoint nem chega a ser executado, e o cliente recebe:

{
  "type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
  "title": "One or more validation errors occurred.",
  "status": 400,
  "errors": {
    "Titulo": ["The Titulo field is required."],
    "Preco": ["The field Preco must be between 0.01 and 100000."]
  }
}

As mensagens padrão estão em inglês. Todo atributo aceita ErrorMessage = "O título é obrigatório.", o que resolve para uma API de um único idioma. Para várias línguas, os recursos de localização do ASP.NET Core resolvem, e o CultureInfo que vimos em "Operadores, Texto e Conversa com o Usuário" volta a aparecer.

A validação também desce por objetos aninhados e coleções: se NovoPedido.Itens for uma lista de ItemSolicitado com [Range(1, 100)] em Quantidade, cada item é validado, e o erro aparece como Itens[2].Quantidade.

Um atributo próprio: ISBN-13

Regras específicas do domínio viram atributos derivados de ValidationAttribute:

[AttributeUsage(AttributeTargets.Property | AttributeTargets.Parameter)]
public sealed class IsbnAttribute : ValidationAttribute
{
    protected override ValidationResult? IsValid(object? valor, ValidationContext contexto)
    {
        if (valor is not string texto)
            return ValidationResult.Success;          // [Required] cuida do nulo

        var digitos = texto.Where(char.IsAsciiDigit).ToArray();
        if (digitos.Length != 13)
            return new ValidationResult("O ISBN deve ter 13 dígitos.");

        var soma = 0;
        for (var i = 0; i < 12; i++)
            soma += (digitos[i] - '0') * (i % 2 == 0 ? 1 : 3);

        var verificador = (10 - soma % 10) % 10;

        return verificador == digitos[12] - '0'
            ? ValidationResult.Success
            : new ValidationResult("Dígito verificador do ISBN inválido.");
    }
}

Dois detalhes. char.IsAsciiDigit, e não char.IsDigit, porque este último aceita dígitos de qualquer escrita Unicode (árabe-índicos, devanágari...), e a conta digito - '0' daria lixo com eles. E o atributo devolve sucesso para nulo: cada atributo verifica uma coisa só, e a obrigatoriedade é problema do [Required].

Para regras que envolvem vários campos ao mesmo tempo, o DTO implementa IValidatableObject:

public sealed record NovoPedido : IValidatableObject
{
    [Required, MinLength(1), MaxLength(50)]
    public required List<ItemSolicitado> Itens { get; init; }

    public IEnumerable<ValidationResult> Validate(ValidationContext contexto)
    {
        var repetidos = Itens.GroupBy(i => i.ProdutoId).Where(g => g.Count() > 1);
        foreach (var grupo in repetidos)
            yield return new ValidationResult(
                $"O produto {grupo.Key} aparece mais de uma vez; some as quantidades.",
                [nameof(Itens)]);
    }
}

Um detalhe de ordem: o Validate só roda depois que os atributos passam. Um pedido com uma quantidade fora da faixa recebe só o erro de Itens[2].Quantidade; o aviso de produto repetido aparece na tentativa seguinte, já com as quantidades corrigidas (medido no .NET 10).

Validação de entrada não substitui invariantes

Com a validação na borda, é tentador tirar as verificações do construtor de LivroImpresso. Não faça isso. As duas camadas têm papéis diferentes:

  • A validação de entrada existe para dar ao cliente uma mensagem boa, com todos os erros de uma vez, apontando o campo. Ela conhece o contrato HTTP.
  • As invariantes do domínio existem para que um LivroImpresso inválido não possa existir, venha de onde vier: da API, do CLI, de uma migração de dados ou de um teste. Elas conhecem as regras do negócio.

A duplicação é pequena e proposital. Se as duas divergirem, o pior caso é o domínio recusar algo que a API aceitou, e o cliente receber um erro menos elegante, mas nunca um dado inválido gravado. A próxima seção cuida justamente desse caso.

Outras abordagens continuam válidas. A biblioteca FluentValidation expressa regras em código fluente (RuleFor(x => x.Titulo).NotEmpty().MaximumLength(200)), com mais poder para regras condicionais e assíncronas, e é plugada nas Minimal APIs por meio de um endpoint filter. Os endpoint filters, que envolvem a execução de um endpoint (grupo.AddEndpointFilter<MeuFiltro>()), são a versão local do middleware e servem para qualquer lógica transversal restrita a um grupo de rotas.

ProblemDetails: um formato único para erros

Hoje a Livraria responde erros de quatro jeitos diferentes: o JSON de validação acima, um 409 com uma string solta, um 412 sem corpo e um 500 com uma página de exceção. Um cliente precisaria de quatro parsers.

O RFC 9457 (Problem Details for HTTP APIs, que em 2023 substituiu o RFC 7807) define um formato único, com o tipo de mídia application/problem+json:

{
  "type": "https://livraria.exemplo/problemas/estoque-insuficiente",
  "title": "Estoque insuficiente",
  "status": 409,
  "detail": "Restam 1 exemplar(es) de 'Dom Casmurro'; foram solicitados 2.",
  "instance": "POST /pedidos",
  "traceId": "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"
}
  • type é uma URI que identifica a categoria do problema. Em tese aponta para uma página explicativa, e em qualquer caso é o campo que o cliente usa em um switch.
  • title é o resumo legível da categoria e não muda de ocorrência para ocorrência.
  • detail explica esta ocorrência.
  • Campos extras (extensions) são permitidos. O errors da validação é um deles, e o traceId que acrescentamos é outro.

No ASP.NET Core, três linhas ligam o formato em toda a aplicação:

using System.Diagnostics;

builder.Services.AddProblemDetails(opcoes =>
    opcoes.CustomizeProblemDetails = ctx =>
    {
        ctx.ProblemDetails.Instance =
            $"{ctx.HttpContext.Request.Method} {ctx.HttpContext.Request.Path}";
        ctx.ProblemDetails.Extensions["traceId"] =
            Activity.Current?.Id ?? ctx.HttpContext.TraceIdentifier;
    });

var app = builder.Build();

app.UseExceptionHandler();   // exceções não tratadas → 500 em ProblemDetails
app.UseStatusCodePages();    // respostas 4xx/5xx sem corpo → ProblemDetails

Com AddProblemDetails registrado:

  • UseExceptionHandler() sem argumentos transforma qualquer exceção não tratada num 500 em ProblemDetails. Em Production, o corpo não inclui a mensagem nem o stack trace da exceção, que podem revelar nomes de tabelas, caminhos e versões de bibliotecas.
  • UseStatusCodePages() preenche respostas de erro que saíram sem corpo, como o 412 e o 428 do artigo anterior e os 404 do roteamento.
  • O traceId liga a resposta que o cliente viu à linha de log correspondente no servidor. Quando alguém abrir um chamado com "deu erro", esse é o dado que se pede. Ele vem do Activity do .NET, a base do rastreamento distribuído que usaremos em "Produção: CORS, rate limiting, versionamento e OpenTelemetry".

Traduzindo exceções de domínio em um só lugar

Os try/catch espalhados pelos endpoints do artigo anterior dão lugar a um IExceptionHandler, a interface introduzida no .NET 8 para esse papel:

public sealed class TradutorDeExcecoesDeDominio(IProblemDetailsService problemas)
    : IExceptionHandler
{
    public async ValueTask<bool> TryHandleAsync(
        HttpContext contexto, Exception excecao, CancellationToken ct)
    {
        (int Status, string Tipo, string Titulo)? mapeado = excecao switch
        {
            EstoqueInsuficienteException =>
                (StatusCodes.Status409Conflict, "estoque-insuficiente", "Estoque insuficiente"),
            ConflitoDeConcorrenciaException =>
                (StatusCodes.Status412PreconditionFailed, "versao-desatualizada",
                 "O recurso foi alterado por outra requisição"),
            RegraDeNegocioException =>
                (StatusCodes.Status422UnprocessableEntity, "regra-de-negocio", "Operação não permitida"),
            _ => null
        };

        if (mapeado is not { } m)
            return false;                      // deixa para o handler padrão (500)

        contexto.Response.StatusCode = m.Status;

        return await problemas.TryWriteAsync(new ProblemDetailsContext
        {
            HttpContext = contexto,
            Exception = excecao,
            ProblemDetails = new ProblemDetails
            {
                Status = m.Status,
                Type = $"https://livraria.exemplo/problemas/{m.Tipo}",
                Title = m.Titulo,
                Detail = excecao.Message
            }
        });
    }
}

// Program.cs
builder.Services.AddExceptionHandler<TradutorDeExcecoesDeDominio>();

Com o handler registrado, os endpoints encolhem. O Registrar de pedidos volta a ter só o caminho feliz, e o Atualizar perde o try/catch. Handlers podem ser encadeados: são chamados na ordem de registro até um devolver true. Como passamos por IProblemDetailsService, a customização global (instance, traceId) também vale para esses erros.

Repare no que não está no mapeamento: ArgumentException. É tentador traduzi-la para 400, já que o construtor de LivroImpresso a lança para dados inválidos, mas a mesma exceção é lançada por bugs genuínos em qualquer lugar do código, inclusive na BCL. Mapeá-la globalmente transformaria defeitos do servidor em "culpa do cliente" e esconderia esses defeitos dos alertas. Exceções que chegam ao cliente devem ser tipos do domínio, criados para isso (RegraDeNegocioException e suas derivadas). O Detail = excecao.Message só é seguro por esse motivo: essas mensagens foram escritas para o cliente ler.

Por fim, TypedResults.Problem(...) e TypedResults.ValidationProblem(...) permitem montar ProblemDetails diretamente num endpoint, quando o erro é detectado no próprio endpoint e não por exceção:

if (string.IsNullOrEmpty(ifMatch))
    return TypedResults.Problem(
        statusCode: StatusCodes.Status428PreconditionRequired,
        title: "Cabeçalho If-Match obrigatório",
        detail: "Obtenha o produto com GET e reenvie o valor do ETag em If-Match.");

OpenAPI: o contrato gerado a partir do código

OpenAPI (antigo Swagger) é a especificação padrão para descrever APIs HTTP num documento JSON ou YAML: rotas, parâmetros, corpos, respostas e esquemas. A partir desse documento se geram clientes em qualquer linguagem, se testam contratos, se montam portais de documentação e se configuram gateways.

Por muitos anos, o ecossistema .NET usou a biblioteca comunitária Swashbuckle. No .NET 9 a Microsoft passou a oferecer geração nativa no pacote Microsoft.AspNetCore.OpenApi e tirou o Swashbuckle dos templates. No .NET 10, o documento gerado segue o OpenAPI 3.1 por padrão, versão alinhada ao JSON Schema moderno, e pode ser servido também em YAML.

dotnet add src/Livraria.Api package Microsoft.AspNetCore.OpenApi
dotnet add src/Livraria.Api package Scalar.AspNetCore
using Microsoft.OpenApi;   // em versões anteriores ao .NET 10: Microsoft.OpenApi.Models
using Scalar.AspNetCore;

builder.Services.AddOpenApi(opcoes =>
    opcoes.AddDocumentTransformer((documento, contexto, ct) =>
    {
        documento.Info = new OpenApiInfo
        {
            Title = "API da Livraria",
            Version = "v1",
            Description = "Catálogo, estoque e pedidos da Livraria do curso de C#."
        };
        return Task.CompletedTask;
    }));

var app = builder.Build();

if (app.Environment.IsDevelopment())
{
    app.MapOpenApi();                  // /openapi/v1.json
    app.MapScalarApiReference();       // /scalar — interface interativa
}

Boa parte da descrição sai de graça do que já escrevemos:

  • Rotas e parâmetros vêm do padrão de rota e da assinatura do método. pagina e tamanho aparecem como parâmetros de query opcionais, com os valores padrão.
  • Respostas vêm dos TypedResults. Results<Ok<ProdutoResposta>, NotFound> vira "200 com o esquema ProdutoResposta" e "404". É o ganho prometido no artigo anterior. Com Results.Ok(...), que devolve IResult, o gerador não saberia o que o endpoint devolve.
  • Esquemas vêm dos DTOs, incluindo os atributos de validação: [StringLength(200)] vira maxLength: 200, [Range] vira minimum/maximum e required vira required. Esse é o outro motivo para não expor entidades: o esquema publicado seria o do domínio.

O que não dá para inferir se declara com métodos de extensão:

grupo.MapPost("/livros", CriarLivro)
     .WithSummary("Cadastra um livro")
     .WithDescription("O ISBN deve ser um ISBN-13 válido. O Id é gerado pelo servidor.")
     .ProducesValidationProblem();

grupo.MapPut("/{id:int}", Atualizar)
     .WithSummary("Altera título e preço")
     .ProducesProblem(StatusCodes.Status412PreconditionFailed)
     .ProducesProblem(StatusCodes.Status428PreconditionRequired);

No .NET 10 os comentários XML também entram no documento. Com <GenerateDocumentationFile>true</GenerateDocumentationFile> no .csproj, o /// <summary> de um método de endpoint ou de uma propriedade de DTO vira a descrição correspondente, também por meio de um source generator. Mais um motivo para preferir métodos nomeados a lambdas.

O Scalar, em /scalar, é uma interface web gerada a partir do documento: navega pelos endpoints, mostra os esquemas e permite disparar requisições direto do navegador. O Swagger UI clássico continua disponível para quem preferir. Ambos são opcionais, porque o que importa é o documento.

O documento como artefato de build

Servir o JSON em tempo de execução é útil em desenvolvimento, mas o documento também pode ser gerado na compilação, com o pacote Microsoft.Extensions.ApiDescription.Server: a cada dotnet build, o arquivo Livraria.Api.json é escrito em disco. Isso abre algumas possibilidades:

  • Versionar o documento no repositório e revisar as mudanças do contrato no pull request, como qualquer outro código.
  • Rodar no CI uma ferramenta de comparação (como a oasdiff) que reprova o build quando uma mudança quebra clientes: campo removido, tipo alterado, parâmetro que virou obrigatório.
  • Gerar clientes tipados, em C# com Kiota ou NSwag e em TypeScript com o gerador de sua preferência. O front-end que vamos construir em "Blazor: componentes, parâmetros, eventos e ciclo de vida" pode consumir um cliente gerado desse modo.

Expor o documento em produção é uma decisão, não um padrão. Para APIs públicas, é documentação. Para APIs internas, é um mapa detalhado entregue a qualquer um que o encontre. Nesse caso, gere no build e publique só em um portal interno, ou proteja a rota com autorização, que é o assunto do próximo artigo.

O Program.cs da Fase 6 até aqui

using System.Diagnostics;
using Livraria.Api.Endpoints;
using Livraria.Api.Erros;
using Livraria.Api.Infra;
using Livraria.Aplicacao;
using Scalar.AspNetCore;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AdicionarPersistencia(builder.Configuration);   // o switch de "ASP.NET Core por dentro", extraído
builder.Services.AddScoped<ServicoDeVendas>();

builder.Services.AddValidation();
builder.Services.AddProblemDetails(opcoes =>
    opcoes.CustomizeProblemDetails = ctx =>
    {
        ctx.ProblemDetails.Instance =
            $"{ctx.HttpContext.Request.Method} {ctx.HttpContext.Request.Path}";
        ctx.ProblemDetails.Extensions["traceId"] =
            Activity.Current?.Id ?? ctx.HttpContext.TraceIdentifier;
    });
builder.Services.AddExceptionHandler<TradutorDeExcecoesDeDominio>();
builder.Services.AddOpenApi();

var app = builder.Build();

app.UseExceptionHandler();
app.UseStatusCodePages();
app.UseMiddleware<IdDeCorrelacao>();

if (app.Environment.IsDevelopment())
{
    app.MapOpenApi();
    app.MapScalarApiReference();
}

app.MapearProdutos();
app.MapearPedidos();

app.Run();

A ordem segue o que vimos em "ASP.NET Core por dentro": o tratamento de exceções vem primeiro para envolver todo o resto, e as páginas de status vêm logo depois para pegar as respostas vazias de qualquer camada interna.

Agora o contrato da Livraria está completo. Dados inválidos são barrados na borda com mensagens que apontam o campo, as regras de negócio viram respostas 409, 412 e 422 num formato padronizado, falhas internas não vazam detalhes, e a API publica uma descrição OpenAPI que gera clientes e protege o contrato no CI. Falta um detalhe grande: qualquer pessoa na rede pode cadastrar livros, mudar preços e apagar o catálogo.

Fontes e leituras recomendadas

Exercícios

Exercício 1

Escreva um atributo [DataNaoFutura] para DateOnly que rejeite datas posteriores a hoje, e aplique-o a um campo DataDePublicacao de NovoLivro. Que dependência escondida o seu atributo tem, e como ela atrapalha os testes?

Ver resposta

✓ Resposta: Derive de ValidationAttribute e sobrescreva IsValid: if (valor is DateOnly d && d > DateOnly.FromDateTime(DateTime.UtcNow)) return new ValidationResult("A data de publicação não pode estar no futuro."); return ValidationResult.Success;. A dependência escondida é o relógio. O teste depende do dia em que roda e não consegue simular a virada de meia-noite nem fusos horários. A correção é obter um TimeProvider (introduzido no .NET 8) a partir do ValidationContext, que expõe GetService: var relogio = contexto.GetService<TimeProvider>() ?? TimeProvider.System;. Nos testes, registra-se um FakeTimeProvider com a data desejada. Também é preciso decidir se "hoje" é em UTC ou no fuso da loja, e esse tipo de decisão tende a ficar implícito quando ninguém pensa nela.

Exercício 2

Um cliente manda {"titulo": "X", "preco": "trinta"}. Essa requisição chega à validação por atributos? Que resposta ela recebe, e em que ponto do pipeline o erro acontece?

Ver resposta

✓ Resposta: Não chega. O erro acontece antes, na desserialização do corpo: o System.Text.Json não converte "trinta" em decimal e lança JsonException. O binder das Minimal APIs transforma isso em BadHttpRequestException, e em Production a resposta é 400. Com AddProblemDetails e UseStatusCodePages, o corpo vem em ProblemDetails, mas sem o dicionário errors campo a campo, porque a validação por atributos nunca rodou. Isso mostra as duas etapas: primeiro o JSON precisa virar um objeto do tipo certo, e só depois esse objeto é validado. Em Development há uma surpresa (medida no .NET 10): a propriedade ThrowOnBadRequest das opções de rota vem ligada, a BadHttpRequestException é lançada em vez de virar um 400, e o UseExceptionHandler a transforma num 500. Quem testa só em Development vê um erro do servidor onde o cliente veria um 400.

Exercício 3

Por que o TradutorDeExcecoesDeDominio devolve false para exceções que não reconhece, em vez de escrever ele mesmo um 500? E o que aconteceria se ele fosse registrado depois de outro handler que sempre devolve true?

Ver resposta

✓ Resposta: Devolver false passa a exceção adiante na cadeia de handlers e, no fim, para o comportamento padrão do UseExceptionHandler. Esse comportamento já produz um 500 em ProblemDetails, registra a exceção no log com nível de erro e respeita o ambiente (detalhes só em Development). Reescrever isso no tradutor duplicaria lógica e arriscaria vazar mensagens internas. Os handlers são consultados na ordem de registro e a cadeia para no primeiro true. Um handler "pega-tudo" registrado antes faria o tradutor nunca rodar, e estoque insuficiente viraria 500. Handlers específicos vêm primeiro e genéricos por último, como nos catch de C#.

Exercício 4

Um colega diz que, com [Required], [Range] e [Isbn] no DTO, os throw do construtor de LivroImpresso viraram código morto e podem ser removidos. Dê dois caminhos concretos pelos quais um LivroImpresso inválido poderia ser criado se ele fizesse isso.

Ver resposta

✓ Resposta: (1) O Livraria.Cli e qualquer outro ponto de entrada que não passa pela API, como um importador de planilhas, um job agendado ou um consumidor de fila, criam livros sem nenhum DTO anotado. (2) Um futuro endpoint PATCH ou um novo DTO (NovoLivroV2) esquece um atributo, e nada mais impede o dado inválido. Há um terceiro: testes e seeds de desenvolvimento construindo entidades diretamente. As invariantes do domínio são a única verificação que vale para todos os caminhos, e a validação da API é uma camada de conveniência sobre elas. Código defensivo que "nunca dispara" é justamente o que impede o banco de receber lixo no dia em que alguém esquecer a outra camada.

Exercício 5

Configure a geração do documento OpenAPI em tempo de build e descreva uma verificação de CI que impeça mudanças que quebram clientes. Classifique como compatível ou incompatível: (a) acrescentar um campo opcional em ProdutoResposta; (b) acrescentar um campo obrigatório em NovoLivro; (c) renomear Preco para PrecoVenda na resposta; (d) aumentar o teto de tamanho de 100 para 200.

Ver resposta

✓ Resposta: Acrescente Microsoft.Extensions.ApiDescription.Server ao projeto (opcionalmente com <OpenApiDocumentsDirectory> apontando para uma pasta versionada). O dotnet build passa a escrever o JSON. No CI, compare o documento do branch com o do main, por exemplo com oasdiff breaking base.json novo.json --fail-on ERR, e reprove o pipeline se houver quebra não aprovada. Classificação: (a) compatível, porque clientes ignoram campos desconhecidos na resposta, desde que não usem desserialização estrita; (b) incompatível, porque clientes existentes não mandam o campo e passam a receber 400; (c) incompatível, porque clientes que leem preco passam a receber nulo; (d) compatível, porque relaxa uma restrição de entrada e toda requisição válida antes continua válida. A regra geral: na entrada, afrouxar é seguro e apertar quebra; na saída, acrescentar é seguro e remover ou renomear quebra.

Comentários

Mais em Linguagem C#

Cada Valor Tem um Tipo: Variáveis, Números e Conversões em C#
Cada Valor Tem um Tipo: Variáveis, Números e Conversões em C#

Variáveis e tipos em C#: var e const, a família dos inteiros e o estouro…

Performance e distribuição: BenchmarkDotNet, Native AOT e ferramentas de linha de comando
Performance e distribuição: BenchmarkDotNet, Native AOT e ferramentas de linha de comando

Medir antes de otimizar com BenchmarkDotNet e as ferramentas de diagnóstico do…

Propriedades e Encapsulamento: Deixar Ler, Controlar a Escrita
Propriedades e Encapsulamento: Deixar Ler, Controlar a Escrita

Propriedades em C#: get e set no lugar de getters e setters, propriedades…