Você provavelmente já usa records no C#. Eles chegaram, reduziram aquele boilerplate insuportável de construtores e Deconstructs, e viraram o padrão oficial para DTOs em APIs.
Mas se você parar por aí, está deixando dinheiro na mesa.
O C# sempre foi uma linguagem orientada a objetos, e por sinal, a melhor em termos de expressividade. Porém, nossos domínios podem se beneficiar muito das técnicas de programação funcional, inclusive se tornando ainda mais expressivos e seguros.
As atualizações recentes da linguagem deixam claro que esta visão é compartilhada pelo time do produto pela maneira com que flertam agressivamente com o paradigma funcional. E nessa nova realidade, records não são apenas “classes curtas”: eles são os blocos de construção para um modelo de domínio imutável e expressivo.
Esta é a primeira parte de uma série onde vou explorar como trazer conceitos de Programação Funcional (como Immutability, Pure Functions e Functional Core) para o nosso bom e velho C#, sem transformar seu código em algo alienígena para o time.
Hoje, vamos falar de Modelagem de Tipos.
O problema da “Obsessão Primitiva”
Em OOP clássico, nos acostumamos a passar Guid, string e int para todo lado. O problema é que um Guid pode ser um ID de cliente, de pedido ou de produto. O compilador não sabe a diferença, e cedo ou tarde, alguém vai passar o ID errado no lugar errado.
// O compilador aceita isso, mas seu domínio chora:
public void ProcessarPagamento(Guid clienteId, Guid pedidoId, decimal valor)
{
// Se você inverter os IDs na chamada, ninguém avisa.
}
A solução canônica do DDD é criar Value Objects. Classes que encapsulam esse valor. O problema? O custo. Criar uma classe inteira, com Equals, GetHashCode e sobrecarga de operadores apenas para segurar um Guid parece engenharia excessiva (e muitas vezes é, considerando o impacto no heap).
É aqui que o C# moderno brilha.
Tiny Types com record struct
Podemos usar readonly record struct para criar o que chamamos de “Tiny Types”. Eles têm a semântica de valor, são alocados na stack (zero garbage collection overhead na maioria dos casos) e são declarados em uma linha.
Sobre record structs
Aqui usamos record struct porque tiny types são valores, não identidades, e struct evita alocações no heap, reduzindo a pressão no GC. Se você precisa de polimorfismo, use record class.
Para Value Objects simples, especialmente os que representam um único valor, record struct é o ideal.
Isso entrega praticamente o mesmo custo de um int, só que com a segurança de um tipo específico do domínio.
// Em vez de passar Guids soltos:
public void ProcessarPagamento(Guid clienteId, decimal valor) { ... }
// Definimos Tiny Types expressivos:
public readonly record struct ClienteId(Guid Value);
public readonly record struct ValorMonetario(decimal Value);
// O compilador agora é seu segurança:
public void ProcessarPagamento(ClienteId clienteId, ValorMonetario valor) { ... }
Ao usar readonly, garantimos a imutabilidade. Ao usar record struct, ganhamos performance e a comparação estrutural de graça. Seu domínio fica mais seguro sem ficar mais lento.
Entrando em programação funcional - Comportamento via Extensions
Poderíamos implementar nossos métodos da mesma forma que faríamos com qualquer classe ou struct do C#, mas vamos aproveitar para entrar em conceitos funcionais e analisar onde ganhamos.
No paradigma funcional, tendemos a separar os dados (nossos records) do comportamento. Em vez de criar “Métodos de Instância” que alteram o estado interno (o que é impossível, já que são imutáveis), usamos Extension Methods para criar fluxos de transformação.
Vamos a um exemplo de um tiny type Cpf, aqui queremos representar o Cpf e garantir a validade. Observe a diferença dos códigos.
O Result.Ok, é spoiler sobre o próximo post.
public class Cpf
{
public readonly string Value { get; }
public Cpf(string value)
{
if (!Validar(value))
{
throw new ArgumentException("CPF inválido.");
}
Value = value;
}
public override int GetHashCode()
{
return Value.GetHashCode();
}
public override bool Equals(object? obj)
{
if (obj is Cpf cpf)
{
return Value == cpf.Value;
}
return false;
}
public static bool operator ==(Cpf? left, Cpf? right)
{
if (left is null)
{
return right is null;
}
return left.Equals(right);
}
public static bool operator !=(Cpf? cpf1, Cpf? cpf2)
{
return !(cpf1 == cpf2);
}
private static bool Validar(string value)
{
// Omitindo por simplicidade
return true;
}
}
public readonly record struct Cpf(string Value);
public static class CpfExtensions
{
extension(Cpf cpf)
{
public static Result<Cpf> Create(string value) =>
value.IsValidCpf()
? Result.Ok(new Cpf(value))
: Result.Fail<Cpf>("CPF inválido");
}
extension(string value)
{
private bool IsValidCpf()
{
//Omitido por simplicidade
return true;
}
}
}
Por que isso importa?
Na orientação a objetos, costumamos encapsular o comportamento dentro de classes. Nesta abordagem, o foco muda, escrevemos funções puras, que apenas recebem valores e retornam valores, sem efeitos colaterais ou estado interno.
Ao fazer isso, você desbloqueia três efeitos imediatos:
- Domínio mais seguro: o compilador passa a evitar bugs pra você.
- Regras de negócio testáveis sem mocks, funções puras são triviais de isolar.
- Menos ifs/elses espalhados: validação centralizada nos tipos, transformação em extensions.
Começamos a montar aqui, um Imperative Shell, Functional Core. Colocamos nosso domínio no centro do sistema, sendo funcional, imutável e altamente composável. A casca, continua usando OO para lidar com IO, dependências, infraestrutura e tudo que não é regra de negócio.
Nos próximos posts, vamos compor funções e tipos, lidar com fluxos mais complexos e validações sem cair no exception hell.
Por enquanto, faça um teste: pegue aquele método que recebe 5 parâmetros primitivos e refatore para usar record structs.
Seu “eu do futuro” vai agradecer.
