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:

  1. Domínio mais seguro: o compilador passa a evitar bugs pra você.
  2. Regras de negócio testáveis sem mocks, funções puras são triviais de isolar.
  3. 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.