C# moderno: fundamentos para aplicações reais
Esta é a primeira aula prática do nosso roadmap para chegar ao nível de um .NET AI Engineer.
Pode parecer estranho começar uma série sobre inteligência artificial falando de C# antes de falar de modelos, agentes, RAG ou embeddings. Na verdade, essa ordem é intencional.
Quando uma aplicação de IA deixa de ser uma demonstração e passa a atender usuários reais, ela continua precisando de tudo o que qualquer software sério precisa: regras de negócio, tipos bem modelados, validação, tratamento de erros, acesso a dados, concorrência, testes, observabilidade, segurança e manutenção.
O modelo de IA será apenas uma dependência dentro desse sistema.
Se a base em C# for fraca, o resultado tende a ser um código no qual tudo é string, qualquer coisa pode ser null, regras ficam espalhadas em ifs, objetos mudam de estado sem controle e erros aparecem tarde demais, normalmente em produção.
Por isso, nesta aula não vamos fazer um passeio por toda a sintaxe da linguagem. O objetivo é entender os recursos de C# moderno que mais influenciam a qualidade de aplicações reais.
Vamos trabalhar com:
- sistema de tipos;
- value types e reference types;
- nullability;
- classes, records e record structs;
- imutabilidade;
- pattern matching;
- generics;
- coleções;
- LINQ;
- delegates e lambdas;
- exceções;
- extension members e extension methods;
- modelagem de domínio com tipos mais expressivos.
No momento desta publicação, C# 14 é a versão estável associada ao .NET 10, trazendo recursos como extension members, atribuição com operadores condicionais a nulo, melhorias em Span<T>, modificadores em parâmetros lambda e outras evoluções da linguagem. O ponto mais importante, porém, não é usar cada novidade assim que ela aparece. É saber escolher recursos que tornem a intenção do código mais clara e reduzam estados inválidos.
Ao longo da aula construiremos partes de um pequeno domínio de pedidos. Esse domínio continuará servindo como referência em artigos futuros.
Antes de começar: C# não é apenas sintaxe
É comum aprender C# desta forma:
var name = "Tiago";
var age = 35;
if (age >= 18)
{
Console.WriteLine($"{name} é maior de idade.");
}
Esse código ensina variáveis, condicionais e interpolação de strings. É útil para começar, mas quase nada disso ajuda a responder as perguntas difíceis de um sistema real.
Por exemplo:
Um pedido pode existir sem cliente?
Um preço pode ser negativo?
Um e-mail pode ser nulo?
Um pedido pago pode voltar para o estado de rascunho?
Duas instâncias com o mesmo identificador representam a mesma entidade?
Qualquer string pode ser usada como código de moeda?
Essas perguntas parecem pertencer apenas ao domínio do negócio, mas a forma como usamos a linguagem pode ajudar a responder cada uma delas.
C# possui um sistema de tipos estático. Isso significa que o compilador conhece os tipos das expressões e consegue detectar uma grande classe de erros antes da execução. Em C#, todos os tipos são classificados como tipos por valor ou tipos por referência, e essa distinção afeta cópia, identidade, mutabilidade e comportamento de memória.
A primeira mudança de mentalidade desta série é esta:
Tipos não servem apenas para armazenar dados. Tipos servem para expressar regras e limitar estados inválidos.
Vamos construir essa ideia passo a passo.
Passo 1: pare de transformar tudo em string
Imagine um método responsável por criar um pedido:
public static void CreateOrder(
string customerId,
string customerName,
string customerEmail,
string currency,
decimal total)
{
Console.WriteLine($"Pedido criado para {customerName}");
}
Esse método compila.
Mas ele aceita chamadas absurdas:
CreateOrder(
customerId: "USD",
customerName: "-100",
customerEmail: "Cliente XPTO",
currency: "abc",
total: -500m);
O compilador não consegue nos ajudar muito porque quase todos os parâmetros possuem o mesmo tipo.
Para o compilador, isto:
string customerId
e isto:
string currency
são apenas string.
Para o negócio, são conceitos completamente diferentes.
Esse problema é conhecido informalmente como primitive obsession: usamos tipos primitivos para representar conceitos que possuem significado próprio.
Uma primeira melhoria é criar tipos específicos.
public readonly record struct CustomerId(Guid Value)
{
public static CustomerId New() => new(Guid.NewGuid());
public override string ToString() => Value.ToString();
}
public readonly record struct Currency(string Value)
{
public static Currency Brl => new("BRL");
public static Currency Usd => new("USD");
}
Agora uma assinatura pode ser mais expressiva:
public static void CreateOrder(
CustomerId customerId,
string customerName,
string customerEmail,
Currency currency,
decimal total)
{
Console.WriteLine(
$"Pedido criado para {customerName} em {currency.Value}");
}
E esta chamada deixa de compilar:
CreateOrder(
customerId: Currency.Brl,
customerName: "Maria",
customerEmail: "[email protected]",
currency: new Currency("USD"),
total: 100m);
Currency não é CustomerId.
Parece uma pequena diferença, mas acabamos de transferir uma verificação do runtime para o compilador.
Por que record struct aqui?
record e record struct oferecem comportamento de igualdade por valor e sintaxe conveniente para representar dados. Um record comum é um tipo por referência, enquanto record struct é um tipo por valor. Records também fornecem recursos gerados pelo compilador, como igualdade baseada nos componentes e suporte conveniente à desconstrução, dependendo da forma declarada.
Para identificadores pequenos e imutáveis, um readonly record struct pode ser uma boa opção porque comunica duas coisas:
- o valor não deveria mudar depois de criado;
- a igualdade deve considerar o conteúdo, não a identidade do objeto.
Isso não significa que todo Guid precise virar um tipo customizado. Cada abstração tem custo. A pergunta correta é se existe um conceito de domínio importante o suficiente para justificar um tipo próprio.
Identificadores, dinheiro, percentual, código de país, código de moeda e intervalos de datas são candidatos frequentes.
Passo 2: entenda value types e reference types de verdade
Uma fonte comum de bugs em C# é assumir que toda atribuição funciona da mesma forma.
Considere este exemplo completo:
public sealed class Customer
{
public string Name { get; set; } = string.Empty;
}
public readonly record struct Coordinates(double Latitude, double Longitude);
var firstCustomer = new Customer
{
Name = "Maria"
};
var secondCustomer = firstCustomer;
secondCustomer.Name = "Joana";
Console.WriteLine(firstCustomer.Name);
var firstPoint = new Coordinates(-23.5505, -46.6333);
var secondPoint = firstPoint;
Console.WriteLine(firstPoint == secondPoint);
A saída relevante será:
Joana
True
Por quê?
Customer é uma class, portanto é um tipo por referência. Ao atribuir firstCustomer a secondCustomer, não criamos automaticamente uma cópia independente do objeto. As duas variáveis passam a referenciar a mesma instância.
Já Coordinates é um record struct, portanto é um tipo por valor. A atribuição copia o valor.
A documentação oficial do sistema de tipos de C# descreve exatamente essa distinção: value types armazenam seus dados diretamente e são copiados por valor, enquanto variáveis de reference types armazenam referências e podem apontar para o mesmo objeto.
O erro clássico: achar que class significa entidade e struct significa DTO pequeno
Essa regra é simplista demais.
A escolha deve considerar semântica e comportamento.
Uma entidade normalmente possui identidade própria e ciclo de vida. Por isso, classes costumam funcionar bem.
public sealed class Order
{
public Guid Id { get; }
public Order(Guid id)
{
Id = id;
}
}
Dois objetos Order podem representar conceitualmente a mesma entidade por possuírem o mesmo Id, mesmo que sejam instâncias diferentes em memória.
Já um valor como dinheiro costuma ser definido pelo próprio conteúdo:
public readonly record struct Money(
decimal Amount,
string Currency);
10 BRL deveria ser igual a outro 10 BRL independentemente de onde a instância foi criada.
Um cuidado importante com structs
Não escolha struct apenas por acreditar que ele será sempre mais rápido.
Structs grandes podem ser caros para copiar. Boxing pode introduzir alocações. Mutabilidade em structs cria comportamento surpreendente. APIs genéricas e interfaces também podem mudar o perfil de desempenho.
Primeiro modele corretamente. Depois meça o desempenho real quando houver evidência de gargalo.
Otimização baseada apenas em intuição costuma trocar clareza por complexidade sem benefício mensurável.
Passo 3: trate null como parte do contrato
Durante muitos anos, uma das fontes mais frequentes de falhas em aplicações .NET foi simples:
NullReferenceException
Veja este método:
public static string GetDisplayName(Customer customer)
{
return customer.Name.ToUpperInvariant();
}
O que acontece se customer for null?
O que acontece se Name for null?
Historicamente, referências podiam receber null sem que o tipo comunicasse claramente essa intenção.
Nullable reference types mudaram essa experiência ao permitir expressar, em tempo de compilação, se uma referência deveria aceitar null. O recurso é baseado em análise estática do compilador e não cria um novo tipo de runtime para string?.
Considere:
public sealed class Customer
{
public required string Name { get; init; }
public string? MiddleName { get; init; }
}
Aqui estamos dizendo:
Name
precisa ter valor
MiddleName
pode não ter valor
Isso é muito mais do que sintaxe.
É contrato.
Podemos então escrever:
public static string GetFullName(Customer customer)
{
return customer.MiddleName is null
? customer.Name
: $"{customer.Name} {customer.MiddleName}";
}
O compilador acompanha o fluxo e entende que, no segundo ramo, MiddleName não é nulo.
? não significa "ignore null"
Um erro comum é espalhar ? até os warnings desaparecerem.
Por exemplo:
public sealed class Order
{
public Customer? Customer { get; set; }
}
A pergunta importante é:
Um pedido válido pode existir sem cliente?
Se a resposta for não, modelar Customer? pode estar escondendo um problema de design.
Talvez o correto seja exigir o cliente no construtor:
public sealed class Order
{
public Guid Id { get; }
public Customer Customer { get; }
public Order(Guid id, Customer customer)
{
ArgumentNullException.ThrowIfNull(customer);
Id = id;
Customer = customer;
}
}
Agora o objeto nasce em um estado mais consistente.
O operador ! também não corrige o domínio
Isto:
string name = possiblyNullName!;
não transforma um valor nulo em não nulo.
O operador apenas informa ao compilador que você assume responsabilidade pela afirmação.
Se sua suposição estiver errada, o runtime continua podendo falhar.
Use ! em situações nas quais você realmente conhece uma garantia que a análise estática não conseguiu inferir. Não o use como botão para silenciar warnings.
Passo 4: use constructors e factories para impedir objetos inválidos
Considere este modelo:
public sealed class Product
{
public string Name { get; set; } = string.Empty;
public decimal Price { get; set; }
}
Ele permite:
var product = new Product
{
Name = "",
Price = -1000m
};
Talvez o sistema rejeite isso depois.
Mas por que permitir que um objeto inválido exista?
Podemos mover invariantes para a criação do objeto.
public sealed class Product
{
public Guid Id { get; }
public string Name { get; private set; }
public decimal Price { get; private set; }
public Product(
Guid id,
string name,
decimal price)
{
if (id == Guid.Empty)
{
throw new ArgumentException(
"O identificador é obrigatório.",
nameof(id));
}
if (string.IsNullOrWhiteSpace(name))
{
throw new ArgumentException(
"O nome é obrigatório.",
nameof(name));
}
if (price < 0)
{
throw new ArgumentOutOfRangeException(
nameof(price),
"O preço não pode ser negativo.");
}
Id = id;
Name = name.Trim();
Price = price;
}
}
Agora todo Product criado pelo construtor público precisa respeitar invariantes básicas.
Isso reduz a quantidade de código defensivo espalhado pela aplicação.
Sem essa garantia, cada método que recebe Product precisa se perguntar:
Será que Id está vazio?
Será que Name está vazio?
Será que Price está negativo?
Com invariantes centralizadas, os demais componentes podem trabalhar com um contrato mais forte.
Exceção ou resultado explícito?
Não existe uma resposta universal.
Para erro de programação ou argumento que viola um contrato interno, exceções como ArgumentException podem fazer sentido.
Para erros esperados de negócio, como "cupom expirado" ou "estoque insuficiente", modelar o resultado explicitamente pode ser melhor do que usar exceções como fluxo normal.
Exemplo:
public sealed record AddItemResult(
bool Success,
string? ErrorCode,
string? ErrorMessage)
{
public static AddItemResult Ok() =>
new(true, null, null);
public static AddItemResult Failure(
string code,
string message) =>
new(false, code, message);
}
Mais adiante veremos opções mais sofisticadas para modelar resultados e erros. O importante agora é distinguir falha excepcional de resultado esperado do negócio.
Passo 5: records são ótimos, mas não são uma licença para tornar tudo record
Records são especialmente úteis quando queremos representar dados cujo significado está fortemente ligado aos valores que carregam.
Veja um DTO de criação de pedido:
public sealed record CreateOrderRequest(
Guid CustomerId,
IReadOnlyCollection<CreateOrderItemRequest> Items);
public sealed record CreateOrderItemRequest(
Guid ProductId,
int Quantity);
O ganho é claro.
Não precisamos escrever manualmente várias propriedades apenas para transportar dados.
Também conseguimos usar with para criar uma nova instância com modificações:
var original = new CreateOrderItemRequest(
ProductId: Guid.NewGuid(),
Quantity: 1);
var updated = original with
{
Quantity = 2
};
original continua representando a quantidade 1.
updated representa 2.
Isso combina bem com estilos mais imutáveis.
Onde records podem ser uma escolha ruim?
Considere uma entidade de domínio com ciclo de vida complexo:
public sealed class Order
{
private readonly List<OrderItem> _items = [];
public Guid Id { get; }
public OrderStatus Status { get; private set; }
public IReadOnlyCollection<OrderItem> Items => _items;
public Order(Guid id)
{
Id = id;
Status = OrderStatus.Draft;
}
public void AddItem(OrderItem item)
{
ArgumentNullException.ThrowIfNull(item);
if (Status != OrderStatus.Draft)
{
throw new InvalidOperationException(
"Somente pedidos em rascunho podem receber itens.");
}
_items.Add(item);
}
public void Confirm()
{
if (_items.Count == 0)
{
throw new InvalidOperationException(
"Não é possível confirmar um pedido sem itens.");
}
Status = OrderStatus.Confirmed;
}
}
public enum OrderStatus
{
Draft,
Confirmed,
Paid,
Cancelled
}
Aqui temos comportamento, identidade e transições controladas.
Uma class tradicional comunica bem esse papel.
A regra prática não é "DTO usa record e entidade usa class" como dogma, mas essa divisão costuma ser um bom ponto de partida.
Use records quando igualdade por valor e representação de dados fizerem sentido.
Use classes quando identidade, encapsulamento e ciclo de vida forem centrais.
Passo 6: imutabilidade reduz o espaço de bugs possíveis
Imagine um objeto compartilhado por várias partes do sistema:
public sealed class CheckoutSettings
{
public int MaxItems { get; set; }
public decimal MinimumOrderValue { get; set; }
}
Qualquer componente com uma referência pode alterar as configurações.
settings.MinimumOrderValue = -999m;
Quanto mais pontos podem modificar um estado, mais difícil fica responder:
Quem alterou esse valor?
Uma alternativa:
public sealed record CheckoutSettings(
int MaxItems,
decimal MinimumOrderValue);
Ou, quando precisamos de validação:
public sealed class CheckoutSettings
{
public int MaxItems { get; }
public decimal MinimumOrderValue { get; }
public CheckoutSettings(
int maxItems,
decimal minimumOrderValue)
{
if (maxItems <= 0)
{
throw new ArgumentOutOfRangeException(
nameof(maxItems));
}
if (minimumOrderValue < 0)
{
throw new ArgumentOutOfRangeException(
nameof(minimumOrderValue));
}
MaxItems = maxItems;
MinimumOrderValue = minimumOrderValue;
}
}
Imutabilidade é especialmente útil em:
- objetos de configuração;
- mensagens e eventos;
- DTOs;
- value objects;
- dados compartilhados entre operações concorrentes;
- resultados de funções.
Ela reduz alterações inesperadas e torna o fluxo de dados mais previsível.
Mas mutabilidade não é sempre errada
Uma entidade de domínio pode precisar mudar de estado.
Um pedido muda de Draft para Confirmed.
A questão não é eliminar toda mutabilidade. É controlar quem pode modificar o quê e por quais operações.
Compare:
order.Status = OrderStatus.Paid;
com:
order.MarkAsPaid(paymentId);
A segunda forma oferece um ponto explícito para validar regras.
public void MarkAsPaid(Guid paymentId)
{
if (Status != OrderStatus.Confirmed)
{
throw new InvalidOperationException(
"Somente pedidos confirmados podem ser pagos.");
}
if (paymentId == Guid.Empty)
{
throw new ArgumentException(
"O pagamento é obrigatório.",
nameof(paymentId));
}
PaymentId = paymentId;
Status = OrderStatus.Paid;
}
Encapsular mutabilidade é muito diferente de liberar setters públicos em tudo.
Passo 7: pattern matching pode substituir árvores frágeis de if
Sistemas reais precisam tomar decisões com base em estado e formato dos dados.
Uma implementação tradicional pode crescer assim:
public static decimal CalculateDiscount(Order order)
{
if (order.Status == OrderStatus.Draft)
{
return 0m;
}
if (order.Status == OrderStatus.Confirmed)
{
if (order.Total >= 1000m)
{
return 0.10m;
}
if (order.Total >= 500m)
{
return 0.05m;
}
}
return 0m;
}
Pattern matching permite expressar a decisão de forma mais declarativa.
public static decimal CalculateDiscount(OrderSnapshot order) =>
order switch
{
{ Status: OrderStatus.Confirmed, Total: >= 1000m } => 0.10m,
{ Status: OrderStatus.Confirmed, Total: >= 500m } => 0.05m,
_ => 0m
};
public sealed record OrderSnapshot(
OrderStatus Status,
decimal Total);
A documentação de C# define pattern matching como uma forma de testar uma expressão por características, tipos, valores ou estrutura, usando recursos como is, switch, property patterns, relational patterns e outras combinações.
O que melhorou?
A regra ficou próxima de uma tabela de decisão.
Confirmed + Total >= 1000 -> 10%
Confirmed + Total >= 500 -> 5%
Qualquer outro caso -> 0%
Pattern matching com tipos
Também podemos tratar hierarquias de mensagens:
public abstract record PaymentResult;
public sealed record PaymentApproved(
string TransactionId) : PaymentResult;
public sealed record PaymentRejected(
string Reason) : PaymentResult;
public sealed record PaymentPending(
DateTimeOffset RetryAfter) : PaymentResult;
public static string Describe(PaymentResult result) =>
result switch
{
PaymentApproved approved =>
$"Pagamento aprovado: {approved.TransactionId}",
PaymentRejected rejected =>
$"Pagamento recusado: {rejected.Reason}",
PaymentPending pending =>
$"Pagamento pendente até {pending.RetryAfter:O}",
_ => "Resultado desconhecido"
};
Essa abordagem é útil quando o conjunto de casos é conceitualmente importante.
Cuidado com um switch gigante
Pattern matching melhora a expressão de decisões. Ele não transforma uma função com 150 casos em bom design.
Se uma regra muda independentemente, possui dependências próprias ou exige estratégias extensíveis, talvez polimorfismo, Strategy ou outra abstração seja mais adequada.
Use pattern matching para tornar decisões claras, não para concentrar todo o domínio em uma única função.
Passo 8: generics removem duplicação sem abandonar type safety
Suponha que precisamos representar resultados de operações.
Sem generics, poderíamos criar:
public sealed record CustomerResult(
bool Success,
Customer? Value,
string? Error);
public sealed record OrderResult(
bool Success,
Order? Value,
string? Error);
A estrutura é praticamente a mesma.
Generics permitem parametrizar o tipo.
public sealed record Result<T>(
bool Success,
T? Value,
string? Error)
{
public static Result<T> Ok(T value) =>
new(true, value, null);
public static Result<T> Failure(string error) =>
new(false, default, error);
}
Uso:
Result<Customer> customerResult =
Result<Customer>.Ok(customer);
Result<Order> orderResult =
Result<Order>.Failure(
"Pedido não encontrado.");
Generics permitem escrever algoritmos e estruturas reutilizáveis preservando informação de tipo. Eles aparecem em praticamente todo C# moderno, inclusive coleções, Task<T>, delegates e LINQ. Constraints permitem declarar capacidades que um parâmetro genérico precisa oferecer.
Constraints tornam a intenção mais forte
Considere um repositório genérico:
public interface IEntity
{
Guid Id { get; }
}
public interface IRepository<T>
where T : class, IEntity
{
Task<T?> GetByIdAsync(
Guid id,
CancellationToken cancellationToken);
Task AddAsync(
T entity,
CancellationToken cancellationToken);
}
O where informa ao compilador que T precisa ser uma classe e implementar IEntity.
Isso permite usar membros de IEntity de forma segura e impede tipos incompatíveis.
Mas existe um alerta arquitetural importante.
Nem toda duplicação pede um generic
Um GenericRepository<T> universal parece elegante até surgirem necessidades específicas:
buscar pedidos vencidos
buscar clientes por documento
carregar agregados com regras próprias
consultar projeções especializadas
Uma abstração genérica demais pode apagar conceitos importantes do domínio.
Use generics quando a operação realmente é genérica.
Não use apenas para reduzir o número de arquivos.
Passo 9: escolha a coleção pela semântica, não pelo hábito
Muitos desenvolvedores usam List<T> para tudo.
Ela é ótima, mas não representa todos os problemas.
Considere alguns tipos comuns:
var items = new List<OrderItem>();
var productsById = new Dictionary<Guid, Product>();
var processedMessageIds = new HashSet<Guid>();
var jobs = new Queue<ImportJob>();
var undoActions = new Stack<Action>();
Cada coleção comunica uma intenção.
List<T> representa uma sequência indexada e ordenada.
Dictionary<TKey, TValue> representa acesso por chave.
HashSet<T> representa unicidade e testes de pertencimento eficientes.
Queue<T> representa processamento FIFO.
Stack<T> representa LIFO.
Escolher a coleção certa melhora legibilidade e pode mudar drasticamente a complexidade de operações.
Exemplo: evitar busca linear repetida
Imagine:
var products = new List<Product>();
foreach (var item in orderItems)
{
var product = products.FirstOrDefault(
p => p.Id == item.ProductId);
// usa o produto
}
Se a lista for grande e essa busca acontecer repetidamente, estamos percorrendo a coleção várias vezes.
Podemos indexar por ID:
var productsById = products.ToDictionary(
product => product.Id);
foreach (var item in orderItems)
{
if (!productsById.TryGetValue(
item.ProductId,
out var product))
{
continue;
}
// usa o produto
}
Não significa que Dictionary é sempre melhor.
Significa que a estrutura de dados deveria refletir a operação predominante.
Passo 10: LINQ é poderoso, mas precisa ser compreendido
LINQ integra capacidades de consulta ao ecossistema C#, oferecendo um modelo consistente para filtrar, projetar, ordenar, agrupar e agregar dados. As APIs trabalham com abstrações como IEnumerable<T> e IQueryable<T>, mas o comportamento pode mudar bastante dependendo do provedor.
Comecemos com uma lista de pedidos:
var orders = new List<OrderSummary>
{
new(Guid.NewGuid(), "Maria", 150m, OrderStatus.Paid),
new(Guid.NewGuid(), "João", 80m, OrderStatus.Confirmed),
new(Guid.NewGuid(), "Ana", 750m, OrderStatus.Paid),
new(Guid.NewGuid(), "Carlos", 300m, OrderStatus.Cancelled)
};
public sealed record OrderSummary(
Guid Id,
string CustomerName,
decimal Total,
OrderStatus Status);
Podemos obter pedidos pagos acima de 100:
var paidOrders = orders
.Where(order => order.Status == OrderStatus.Paid)
.Where(order => order.Total > 100m)
.OrderByDescending(order => order.Total)
.Select(order => new
{
order.Id,
order.CustomerName,
order.Total
})
.ToList();
Vamos decompor:
Where
filtra
OrderByDescending
ordena
Select
projeta para um novo formato
ToList
materializa o resultado em uma lista
Esse encadeamento tende a ser mais expressivo do que loops manuais para consultas de dados.
Deferred execution: a query pode não executar quando você acha
Considere:
var expensiveOrders = orders
.Where(order => order.Total >= 500m);
Nesse ponto, para operações LINQ sobre IEnumerable<T>, normalmente criamos uma sequência que será enumerada depois.
A enumeração ocorre quando fazemos algo como:
foreach (var order in expensiveOrders)
{
Console.WriteLine(order.Id);
}
ou materializamos:
var list = expensiveOrders.ToList();
Isso tem consequências importantes.
Veja:
var paid = orders
.Where(order => order.Status == OrderStatus.Paid);
orders.Add(
new OrderSummary(
Guid.NewGuid(),
"Fernanda",
900m,
OrderStatus.Paid));
foreach (var order in paid)
{
Console.WriteLine(order.CustomerName);
}
O novo pedido pode participar da enumeração porque a consulta ainda não havia sido materializada.
Quando você precisa de um snapshot naquele momento, materialize explicitamente:
var paidSnapshot = orders
.Where(order => order.Status == OrderStatus.Paid)
.ToArray();
IEnumerable<T> não é IQueryable<T>
Esse ponto será fundamental quando chegarmos ao Entity Framework Core.
Em memória:
IEnumerable<OrderSummary> query = orders;
As operações são executadas pelo código .NET sobre objetos.
Com um provedor como EF Core:
IQueryable<OrderEntity> query = dbContext.Orders;
A expressão pode ser traduzida para outra linguagem, normalmente SQL.
Nem todo método C# arbitrário pode ser traduzido.
Por isso, uma expressão LINQ aparentemente inocente pode funcionar em memória e falhar, ou produzir uma consulta cara, quando aplicada a um provider de IQueryable<T>.
Aprender LINQ significa entender tanto sua expressividade quanto o momento e o local de execução.
Passo 11: delegates e lambdas permitem passar comportamento
Considere um método para filtrar pedidos.
Sem uma abstração, poderíamos criar vários métodos:
GetPaidOrders();
GetCancelledOrders();
GetHighValueOrders();
GetOrdersFromCustomer();
Mas podemos parametrizar o critério.
public static IReadOnlyList<OrderSummary> FilterOrders(
IEnumerable<OrderSummary> orders,
Func<OrderSummary, bool> predicate)
{
return orders
.Where(predicate)
.ToList();
}
Uso:
var highValueOrders = FilterOrders(
orders,
order => order.Total >= 500m);
Func<OrderSummary, bool> representa uma função que recebe OrderSummary e retorna bool.
A lambda:
order => order.Total >= 500m
é uma forma compacta de fornecer esse comportamento.
A documentação de C# descreve lambdas como funções anônimas que podem ser convertidas para delegates ou expression trees, dependendo do contexto.
Func, Action e delegates customizados
Alguns exemplos:
Func<int, int, int> sum =
(left, right) => left + right;
Action<string> log =
message => Console.WriteLine(message);
Predicate<OrderSummary> isPaid =
order => order.Status == OrderStatus.Paid;
Em APIs de negócio, um delegate nomeado pode comunicar melhor a intenção:
public delegate bool OrderEligibilityRule(
OrderSummary order);
Agora:
public static bool IsEligible(
OrderSummary order,
OrderEligibilityRule rule)
{
return rule(order);
}
Nem sempre precisamos criar um delegate customizado, mas nomes podem carregar significado.
Cuidado com closures
Uma lambda pode capturar variáveis externas:
decimal minimum = 500m;
var filtered = orders.Where(
order => order.Total >= minimum);
Isso é extremamente útil, mas a captura cria semânticas que precisam ser compreendidas, especialmente em loops, callbacks de longa duração e cenários de desempenho.
Não evite closures por princípio. Apenas saiba que a lambda pode depender de estado externo e que esse estado pode mudar.
Passo 12: exceções devem representar falhas, não decisões cotidianas
C# utiliza exceções para reportar e tratar situações excepcionais em runtime. try, catch e finally permitem recuperar falhas conhecidas ou garantir liberação de recursos.
Um erro comum é isto:
try
{
var number = int.Parse(input);
return number;
}
catch (FormatException)
{
return 0;
}
Se entrada inválida é um caso esperado, podemos usar uma API que modela a tentativa:
public static int ParseQuantity(string input)
{
if (!int.TryParse(input, out var quantity))
{
return 0;
}
return quantity;
}
Agora veja uma falha realmente excepcional em uma camada de integração:
public sealed class PaymentGatewayException : Exception
{
public PaymentGatewayException(
string message,
Exception innerException)
: base(message, innerException)
{
}
}
Uso:
try
{
await gateway.AuthorizeAsync(
request,
cancellationToken);
}
catch (HttpRequestException exception)
{
throw new PaymentGatewayException(
"Falha ao comunicar com o gateway de pagamento.",
exception);
}
Aqui estamos adicionando contexto ao erro da infraestrutura.
Nunca faça isto sem uma razão muito boa
catch (Exception)
{
}
A exceção desaparece.
O sistema pode continuar em estado incorreto e você perde informação essencial para diagnóstico.
Outro erro:
catch (Exception ex)
{
throw ex;
}
Ao relançar a mesma exceção, prefira:
catch
{
throw;
}
Isso preserva melhor a pilha original da exceção.
Capture onde você consegue agir
Não coloque try/catch em todo método.
Capture uma exceção quando você puder fazer algo útil, como:
- recuperar com uma estratégia alternativa;
- traduzir a falha para uma abstração mais adequada;
- adicionar contexto necessário;
- executar compensação;
- converter para uma resposta apropriada na fronteira da aplicação.
Logging duplicado em cada camada também costuma gerar ruído. Mais tarde, em ASP.NET Core, veremos tratamento centralizado de erros.
Passo 13: extension methods e extension members melhoram APIs, mas podem esconder complexidade
Antes de C# 14, métodos de extensão já permitiam adicionar uma sintaxe de chamada de instância a tipos que não controlamos.
Exemplo tradicional:
public static class StringExtensions
{
public static bool HasValue(this string? value)
{
return !string.IsNullOrWhiteSpace(value);
}
}
Uso:
string? customerName = "Maria";
if (customerName.HasValue())
{
Console.WriteLine(customerName);
}
C# 14 amplia esse conceito com extension blocks e membros de extensão, incluindo propriedades e outros membros suportados pela nova sintaxe. A documentação oficial destaca que extension members permitem criar APIs mais naturais para tipos que você não controla, sem modificar o tipo original.
Um exemplo simples com a nova sintaxe:
public static class OrderExtensions
{
extension(OrderSummary order)
{
public bool IsClosed =>
order.Status is
OrderStatus.Paid or
OrderStatus.Cancelled;
public string DisplayTotal =>
$"R$ {order.Total:N2}";
}
}
Uso:
if (order.IsClosed)
{
Console.WriteLine(order.DisplayTotal);
}
A leitura fica natural.
Quando extensão vira problema
Uma extensão pode dar a impressão de que um comportamento pertence ao tipo quando, na verdade, depende de infraestrutura pesada.
Evite coisas como:
await order.SaveToDatabaseAsync();
se isso for apenas um extension method escondendo acesso a banco, dependências globais ou comportamento surpreendente.
Extensões funcionam melhor quando:
- a operação é coesa com o tipo;
- a dependência é clara;
- não existe mutação surpreendente;
- o nome comunica o custo;
- não estamos tentando substituir uma abstração de domínio que deveria existir.
Passo 14: modele dinheiro como dinheiro, não como dois parâmetros soltos
Vamos aplicar vários conceitos em um tipo mais realista.
Uma primeira versão poderia ser:
public static decimal AddMoney(
decimal leftAmount,
string leftCurrency,
decimal rightAmount,
string rightCurrency)
{
if (leftCurrency != rightCurrency)
{
throw new InvalidOperationException(
"As moedas precisam ser iguais.");
}
return leftAmount + rightAmount;
}
O problema está na assinatura.
Nada impede inverter parâmetros:
AddMoney(
10m,
"BRL",
20m,
"USD");
O erro só aparece em runtime.
Vamos criar um tipo:
public readonly record struct Money
{
public decimal Amount { get; }
public string Currency { get; }
public Money(decimal amount, string currency)
{
if (string.IsNullOrWhiteSpace(currency))
{
throw new ArgumentException(
"A moeda é obrigatória.",
nameof(currency));
}
Amount = amount;
Currency = currency.Trim().ToUpperInvariant();
}
public Money Add(Money other)
{
if (!string.Equals(
Currency,
other.Currency,
StringComparison.Ordinal))
{
throw new InvalidOperationException(
"Não é possível somar valores de moedas diferentes.");
}
return new Money(
Amount + other.Amount,
Currency);
}
public static Money Zero(string currency) =>
new(0m, currency);
}
Uso:
var subtotal = new Money(100m, "BRL");
var shipping = new Money(20m, "BRL");
var total = subtotal.Add(shipping);
Console.WriteLine(
$"{total.Currency} {total.Amount:N2}");
Agora os valores relacionados viajam juntos.
A regra de moeda está centralizada.
A soma retorna uma nova instância, mantendo o tipo imutável.
Este é um exemplo de como um recurso simples da linguagem pode melhorar o design.
Passo 15: um pequeno domínio integrado
Agora vamos juntar os conceitos em uma implementação mais completa.
O objetivo não é criar uma arquitetura definitiva. É mostrar como tipos, encapsulamento, records, coleções e pattern matching cooperam.
public readonly record struct OrderId(Guid Value)
{
public static OrderId New() => new(Guid.NewGuid());
}
public readonly record struct ProductId(Guid Value);
public readonly record struct Money
{
public decimal Amount { get; }
public string Currency { get; }
public Money(decimal amount, string currency)
{
if (amount < 0)
{
throw new ArgumentOutOfRangeException(
nameof(amount));
}
if (string.IsNullOrWhiteSpace(currency))
{
throw new ArgumentException(
"A moeda é obrigatória.",
nameof(currency));
}
Amount = amount;
Currency = currency.Trim().ToUpperInvariant();
}
public Money Add(Money other)
{
if (Currency != other.Currency)
{
throw new InvalidOperationException(
"As moedas precisam ser iguais.");
}
return new Money(
Amount + other.Amount,
Currency);
}
public Money Multiply(int quantity)
{
if (quantity <= 0)
{
throw new ArgumentOutOfRangeException(
nameof(quantity));
}
return new Money(
Amount * quantity,
Currency);
}
}
public sealed class OrderItem
{
public ProductId ProductId { get; }
public string ProductName { get; }
public Money UnitPrice { get; }
public int Quantity { get; }
public Money Total => UnitPrice.Multiply(Quantity);
public OrderItem(
ProductId productId,
string productName,
Money unitPrice,
int quantity)
{
if (string.IsNullOrWhiteSpace(productName))
{
throw new ArgumentException(
"O nome do produto é obrigatório.",
nameof(productName));
}
if (quantity <= 0)
{
throw new ArgumentOutOfRangeException(
nameof(quantity));
}
ProductId = productId;
ProductName = productName.Trim();
UnitPrice = unitPrice;
Quantity = quantity;
}
}
public enum OrderStatus
{
Draft,
Confirmed,
Paid,
Cancelled
}
public sealed class Order
{
private readonly List<OrderItem> _items = [];
public OrderId Id { get; }
public OrderStatus Status { get; private set; }
public IReadOnlyCollection<OrderItem> Items => _items;
public Order(OrderId id)
{
Id = id;
Status = OrderStatus.Draft;
}
public void AddItem(OrderItem item)
{
ArgumentNullException.ThrowIfNull(item);
if (Status != OrderStatus.Draft)
{
throw new InvalidOperationException(
"Não é possível alterar um pedido já confirmado.");
}
_items.Add(item);
}
public Money CalculateTotal()
{
if (_items.Count == 0)
{
return Money.Zero("BRL");
}
var currency = _items[0].UnitPrice.Currency;
var total = Money.Zero(currency);
foreach (var item in _items)
{
total = total.Add(item.Total);
}
return total;
}
public void Confirm()
{
if (Status != OrderStatus.Draft)
{
throw new InvalidOperationException(
"Somente pedidos em rascunho podem ser confirmados.");
}
if (_items.Count == 0)
{
throw new InvalidOperationException(
"O pedido precisa ter pelo menos um item.");
}
Status = OrderStatus.Confirmed;
}
}
Vamos analisar as decisões.
OrderId e ProductId não são Guid genéricos
Usamos tipos diferentes para impedir a troca acidental de identificadores.
Money mantém valor e moeda juntos
O sistema não precisa passar decimal e string separados em dezenas de métodos.
OrderItem nasce válido
Quantidade precisa ser positiva e nome precisa existir.
Order controla sua própria coleção
Externamente, expomos:
IReadOnlyCollection<OrderItem>
Internamente, mantemos:
List<OrderItem>
Isso evita que outro componente faça algo como:
order.Items.Clear();
A alteração precisa passar por métodos do agregado.
O estado não possui setter público
Isto não compila:
order.Status = OrderStatus.Paid;
A classe controla suas transições.
Essa é a essência de encapsulamento: não esconder dados por estética, mas proteger regras.
Passo 16: refatorando o total com LINQ sem perder clareza
A implementação anterior usa foreach:
public Money CalculateTotal()
{
if (_items.Count == 0)
{
return Money.Zero("BRL");
}
var currency = _items[0].UnitPrice.Currency;
var total = Money.Zero(currency);
foreach (var item in _items)
{
total = total.Add(item.Total);
}
return total;
}
Podemos usar Aggregate:
public Money CalculateTotal()
{
if (_items.Count == 0)
{
return Money.Zero("BRL");
}
var zero = Money.Zero(
_items[0].UnitPrice.Currency);
return _items.Aggregate(
zero,
(total, item) => total.Add(item.Total));
}
É menor.
É melhor?
Depende da equipe e da familiaridade com Aggregate.
Uma regra importante para C# moderno:
Código mais curto não é automaticamente código mais simples.
LINQ é excelente quando deixa a transformação óbvia.
Se o leitor precisar decodificar uma cadeia complexa de operadores, um foreach explícito pode ser mais claro.
Evite competição por quantidade mínima de linhas.
O objetivo é reduzir carga cognitiva.
Passo 17: use o compilador como parceiro de design
Um bom código C# faz o compilador trabalhar a seu favor.
Compare uma API permissiva:
public Task ProcessAsync(
string id,
string type,
string? payload,
bool force,
int mode)
com uma API mais explícita:
public Task<ProcessingResult> ProcessAsync(
DocumentId documentId,
DocumentType documentType,
DocumentContent content,
ProcessingOptions options,
CancellationToken cancellationToken)
Na segunda assinatura, existe mais informação semântica.
Podemos ir além:
public sealed record ProcessingOptions(
ProcessingMode Mode,
bool ForceReprocessing);
public enum ProcessingMode
{
Standard,
HighAccuracy,
LowCost
}
Agora isto não é possível:
mode: 927
O conjunto de opções foi limitado.
Esse princípio será especialmente útil em aplicações de IA.
Imagine:
public Task<string> AskAsync(
string model,
string prompt,
double temperature,
int maxTokens)
Funciona, mas permite combinações inválidas e espalha detalhes técnicos por toda a aplicação.
Mais adiante poderemos evoluir para:
public Task<ChatResponse> AskAsync(
ChatRequest request,
CancellationToken cancellationToken)
com tipos próprios para modelo, mensagens, configurações e resultados.
Quanto mais importante o domínio, mais valioso é tornar estados inválidos difíceis de representar.
O que há de especialmente relevante no C# 14?
Até aqui usamos principalmente recursos consolidados da linguagem porque eles são os que mais afetam o design diário.
Ainda assim, como esta série acompanha um roadmap de 2026 e 2027, vale entender onde C# 14 se encaixa.
A versão adicionou recursos que melhoram expressividade e ergonomia, incluindo a nova sintaxe de extension members, atribuições com operadores condicionais a nulo, suporte ampliado a conversões envolvendo Span<T> e ReadOnlySpan<T>, melhorias em lambdas e outros refinamentos.
Um exemplo simples de atribuição condicional a nulo:
public sealed class CustomerProfile
{
public string? DisplayName { get; set; }
}
CustomerProfile? profile = GetProfile();
profile?.DisplayName = "Maria";
A atribuição acontece apenas quando profile não é nulo.
Isso reduz código equivalente a:
if (profile is not null)
{
profile.DisplayName = "Maria";
}
É uma melhoria de ergonomia.
Mas perceba a diferença entre ergonomia e arquitetura.
A nova sintaxe pode reduzir algumas linhas.
Ela não responde se CustomerProfile deveria ser mutável, se DisplayName pode ser nulo ou se essa atualização deveria existir como uma operação de domínio.
O desenvolvedor moderno precisa dominar os dois níveis:
Sintaxe
como escrever
Design
por que escrever dessa forma
Armadilhas comuns em projetos reais
Depois de conhecer os recursos, precisamos falar sobre os erros mais frequentes.
1. Criar abstrações demais cedo demais
Depois de aprender records, generics e interfaces, surge a tentação de criar:
IEntity<TId>
IAggregateRoot<TId>
IRepository<TEntity, TId>
IService<TEntity, TRequest, TResponse>
IMapper<TSource, TDestination>
antes de existir uma necessidade concreta.
O resultado pode ser um sistema muito genérico e pouco expressivo.
Abstração deveria surgir de padrões reais e forças arquiteturais, não da vontade de usar todos os recursos da linguagem.
2. Expor setters públicos por conveniência
Isto é fácil:
public string Status { get; set; }
Mas permite:
order.Status = "banana";
Tipos e encapsulamento existem para impedir isso.
Use enums, tipos específicos e métodos de transição quando fizer sentido.
3. Transformar nullability em guerra contra warnings
Warnings do nullable não são ruído para silenciar indiscriminadamente.
Eles indicam inconsistências entre o contrato declarado e o fluxo que o compilador conseguiu provar.
Corrija o modelo sempre que possível.
Use ?, validação e ! conscientemente.
4. Usar exceptions para validação comum de entrada
Usuário informar um cupom inválido pode ser esperado.
Banco de dados indisponível é outra categoria de evento.
Misturar tudo como exceção dificulta observabilidade e desenho das APIs.
5. Escrever LINQ impossível de depurar
Isto pode ser tecnicamente válido:
var result = data
.Where(...)
.SelectMany(...)
.GroupBy(...)
.Select(...)
.OrderBy(...)
.ThenBy(...)
.Take(...)
.ToDictionary(...);
Mas uma cadeia grande pode esconder custo e intenção.
Nomeie etapas intermediárias quando isso melhora entendimento.
6. Confundir imutabilidade com ausência total de estado
Aplicações reais possuem estado.
A questão é tornar as mudanças explícitas e controladas.
7. Usar recurso novo porque é novo
C# evolui continuamente.
Nem todo projeto precisa adotar imediatamente cada nova construção.
Considere:
- versão do SDK;
- target framework;
- compatibilidade do ambiente;
- familiaridade da equipe;
- tooling;
- legibilidade;
- benefício real.
A linguagem serve ao software. O software não existe para demonstrar a linguagem.
Um exercício completo para consolidar a aula
Vamos propor um exercício que pode ser desenvolvido antes do próximo artigo.
Crie uma aplicação console chamada:
Logby.Store
Ela deverá permitir montar um pedido em memória.
Comece com:
dotnet new console -n Logby.Store
cd Logby.Store
Crie os tipos:
CustomerId
OrderId
ProductId
Money
Customer
Product
OrderItem
Order
OrderStatus
Implemente estas regras:
- um produto não pode ter nome vazio;
- preço não pode ser negativo;
- quantidade de um item precisa ser maior que zero;
- um pedido só recebe itens enquanto estiver em rascunho;
- um pedido sem itens não pode ser confirmado;
- um pedido confirmado não pode receber novos itens;
- o total deve ser calculado a partir dos itens;
- a moeda deve ser consistente entre os itens;
- identificadores de tipos diferentes não devem ser intercambiáveis;
- propriedades que não deveriam mudar livremente não devem ter setters públicos.
Depois crie um fluxo de demonstração:
var customerId = CustomerId.New();
var order = new Order(OrderId.New());
var keyboard = new OrderItem(
new ProductId(Guid.NewGuid()),
"Teclado mecânico",
new Money(350m, "BRL"),
1);
var mouse = new OrderItem(
new ProductId(Guid.NewGuid()),
"Mouse sem fio",
new Money(150m, "BRL"),
2);
order.AddItem(keyboard);
order.AddItem(mouse);
var total = order.CalculateTotal();
Console.WriteLine(
$"Total: {total.Currency} {total.Amount:N2}");
order.Confirm();
Console.WriteLine(
$"Status: {order.Status}");
O total esperado é:
BRL 650,00
Dependendo da cultura configurada no ambiente, a formatação numérica pode variar, então o exercício deve validar principalmente o valor numérico e a moeda.
Depois tente quebrar o sistema de propósito:
order.AddItem(
new OrderItem(
new ProductId(Guid.NewGuid()),
"Monitor",
new Money(1200m, "BRL"),
1));
Isso deve falhar porque o pedido já foi confirmado.
Tente criar uma quantidade 0.
Tente misturar USD e BRL.
Tente passar um ProductId onde o código espera um OrderId.
O objetivo do exercício não é apenas fazer o caminho feliz funcionar.
É testar se o seu design dificulta estados inválidos.
Checklist de domínio antes de seguir no roadmap
Antes de avançarmos para o próximo ponto, você deveria conseguir olhar para um código C# e responder com segurança:
- quando usar
class,recorderecord struct; - qual é a diferença prática entre value type e reference type;
- o que
string?comunica; - por que o operador
!não resolve um valor realmente nulo; - como constructors e factories ajudam a proteger invariantes;
- por que setters públicos podem enfraquecer encapsulamento;
- quando pattern matching melhora uma decisão;
- como generics preservam type safety em abstrações reutilizáveis;
- por que
List<T>não é a única coleção relevante; - como LINQ pode executar de forma diferida;
- por que
IEnumerable<T>eIQueryable<T>não devem ser tratados como equivalentes; - como delegates e lambdas permitem passar comportamento;
- quando usar exceptions e quando preferir um resultado explícito;
- quando extension methods e extension members melhoram uma API;
- por que código menor nem sempre é código melhor.
Não é necessário decorar todas as APIs da BCL.
É necessário desenvolver um modelo mental sólido.
Como isso se conecta com IA
Ainda não fizemos nenhuma chamada para um LLM.
Mesmo assim, praticamente tudo desta aula aparecerá novamente quando começarmos a construir aplicações de IA.
Tipos fortes
Em vez de espalhar strings:
string model;
string role;
string toolName;
poderemos modelar contratos mais seguros.
Nullability
Respostas de APIs externas, resultados de busca, tool calls e dados recuperados podem estar ausentes.
Precisamos deixar isso explícito.
Records
Mensagens, requests, responses, eventos e resultados estruturados combinam muito bem com records.
Pattern matching
Será útil para tratar diferentes tipos de mensagem, resultados de ferramentas, estados de agentes e respostas estruturadas.
Generics
As próprias abstrações modernas de IA no ecossistema .NET usam generics extensivamente.
LINQ
Vamos manipular resultados de retrieval, ranking, histórico de conversa, metadados e coleções de documentos.
Imutabilidade
Mensagens e eventos imutáveis tornam pipelines mais previsíveis.
Exceções
Chamadas para modelos podem falhar por timeout, rate limit, indisponibilidade, cancelamento e problemas de autenticação.
Precisaremos tratar esses casos corretamente sem esconder falhas.
Ou seja, aprender C# profundamente não é uma etapa separada da engenharia de IA.
É a fundação sobre a qual todo o restante será construído.
Guia rápido de decisão
Use class quando
O objeto possui identidade, ciclo de vida, comportamento e mudanças controladas de estado.
Exemplos:
Order
Customer
Subscription
Conversation
Considere record quando
O tipo representa principalmente dados e igualdade por valor faz sentido.
Exemplos:
CreateOrderRequest
ChatMessage
SearchResult
ConfigurationSnapshot
Considere readonly record struct quando
Você possui um valor pequeno, imutável e semanticamente independente, e a escolha por value type faz sentido para o caso.
Exemplos:
OrderId
Percentage
Coordinates
Use nullable reference types para expressar intenção
string
significa que a intenção é existir um valor não nulo.
string?
significa que ausência é válida no contrato.
Use pattern matching quando
Você precisa expressar decisões baseadas em forma, tipo, propriedades ou faixas de valor de maneira clara.
Use generics quando
A mesma estrutura ou algoritmo realmente funciona para vários tipos sem perder semântica importante.
Use LINQ quando
Ele torna consultas e transformações mais claras.
Prefira um loop explícito quando a lógica ficar mais fácil de entender dessa forma.
Use exceções quando
Existe uma falha excepcional ou uma violação de contrato que não deveria fazer parte do fluxo cotidiano esperado.
Modele resultados esperados explicitamente quando isso tornar a regra mais clara.
Conclusão
Dominar C# moderno não significa conhecer de memória a lista de recursos adicionados em cada versão da linguagem.
Significa saber transformar regras e intenções em código que o compilador, sua equipe e você mesmo daqui a seis meses consigam compreender.
Nesta aula vimos um princípio que aparecerá durante toda a série:
Um bom design tenta tornar o caminho correto fácil e o estado inválido difícil de representar.
Tipos específicos evitam troca acidental de valores.
Nullable reference types tornam ausência parte explícita do contrato.
Records ajudam a representar dados e valores.
Imutabilidade reduz mudanças inesperadas.
Encapsulamento protege invariantes.
Pattern matching expressa decisões de forma declarativa.
Generics reutilizam estruturas preservando tipos.
Coleções adequadas comunicam intenção.
LINQ torna transformações poderosas, desde que entendamos sua execução.
Delegates e lambdas permitem tratar comportamento como valor.
Exceções precisam representar falhas de forma consciente.
Extensões podem melhorar a ergonomia sem substituir um bom design.
Esse conjunto forma a base de C# que usaremos no restante do roadmap.
No próximo passo, sairemos da linguagem e entraremos na plataforma: vamos entender .NET 10 por dentro, incluindo runtime, compilação, assemblies, JIT, garbage collector, gerenciamento de memória, tipos, SDK, CLI e o que realmente acontece entre escrever um arquivo .cs e executar uma aplicação.
Fontes
- What's new in C# 14: documentação oficial das novidades e recursos introduzidos no C# 14.
- The C# type system: visão oficial do sistema de tipos, incluindo value types e reference types.
- Nullable reference types: documentação sobre nullability, análise estática e contratos de referências anuláveis.
- Records: referência oficial para
record,record classerecord struct. - Pattern matching overview: visão geral dos recursos de pattern matching em C#.
- Generic types and methods: fundamentos de generics e seu papel em APIs type-safe.
- Language Integrated Query: documentação oficial de LINQ e dos modelos de consulta em C#.
- Exceptions and Exception Handling: referência sobre tratamento de exceções em aplicações C#.
- Lambda expressions and anonymous functions: documentação sobre lambdas, delegates e expression trees.
- Extension member declarations: referência da sintaxe de extension members disponível no C# moderno.