Lidando com dinheiro em aplicações globalizadas

Dinheiro em aplicações é um dos conceitos mais mal implementados em sistemas de software. A escolha equivocada do tipo de dado, a ausência de encapsulamento, a mistura de moedas e a falta de suporte a localização geram bugs silenciosos, perdas financeiras e comportamentos inesperados em produção.

Neste guia, você constrói um Value Object Money em C#, entende por que o decimal é o tipo correto e aprende a formatar, persistir, serializar e testar valores monetários em sistemas que operam em vários mercados.

Por que dinheiro em aplicações merece um tipo próprio

A representação mais ingênua de dinheiro em aplicações é um decimal solto, espalhado pelas entidades.

public decimal Price { get; set; }
public decimal Total { get; set; }
public decimal Discount { get; set; }

Essa abordagem de dinheiro em aplicações apresenta problemas que só aparecem quando o sistema cresce ou passa a operar em múltiplos mercados. Por isso, vale entender cada um deles antes de escrever qualquer linha de código.

O primeiro problema é a ausência de moeda. Um decimal não carrega nenhuma informação sobre a moeda em que o valor está expresso.

Somar 100.00 (BRL) com 100.00 (USD) produz 200.00, um número semanticamente inválido, mas sintaticamente correto. Ou seja, o compilador não detecta esse erro.

O segundo problema é a ausência de comportamento. Formatação para exibição, conversão entre moedas e arredondamento variam conforme a moeda, e essas regras ficam dispersas quando nenhum tipo as encapsula.

O terceiro problema é a ausência de validação. Nada impede que um decimal receba um valor negativo onde só valores positivos fazem sentido, ou que receba decimal.MaxValue por um erro de entrada.

A solução é tratar dinheiro em aplicações como um Value Object que encapsula o valor e a moeda. Esse tipo expõe operações seguras e falha de forma explícita quando alguém viola as regras.

Se você ainda não domina o conceito de Value Object, o artigo sobre como criar bons Value Objects em C# traz a base necessária para acompanhar este guia.

O tipo decimal para dinheiro em aplicações .NET

Antes de qualquer discussão sobre arquitetura, é fundamental entender por que decimal é o tipo correto para valores monetários em C#. Afinal, double e float parecem servir para o mesmo propósito.

Os tipos double e float usam ponto flutuante binário, ou seja, representam números em base 2. A maioria das frações decimais não tem representação exata em base 2, o que gera erros de arredondamento pequenos, mas acumulativos.

double a = 0.1 + 0.2;
Console.WriteLine(a == 0.3); // false
Console.WriteLine(a);        // 0.30000000000000004

Já o decimal usa ponto flutuante decimal, que representa números em base 10 com 28 a 29 dígitos significativos. Com isso, frações como 0.1, 0.01 e 0.001 ficam exatas.

decimal a = 0.1m + 0.2m;
Console.WriteLine(a == 0.3m); // true
Console.WriteLine(a);          // 0.3

A própria documentação do tipo Decimal destaca que ele atende cálculos financeiros, que exigem muitos dígitos e não toleram erros de arredondamento.

Portanto, quando centavos importam e erros se acumulam ao longo de milhares de transações, decimal é a única escolha tecnicamente correta para dinheiro em aplicações financeiras.

O enum CurrencyCode para dinheiro em aplicações

O primeiro componente do tipo Money é a representação da moeda. A abordagem mais estruturada é um enum baseado nos códigos ISO 4217, o padrão internacional que define um código de três letras para cada moeda reconhecida.

public enum CurrencyCode
{
    Brl = 0,   // Brazilian Real
    Usd = 1,   // United States Dollar
    Eur = 2,   // Euro
    Gbp = 3,   // British Pound Sterling
    Jpy = 4,   // Japanese Yen
    Cny = 5,   // Chinese Yuan
    // ... demais moedas
}

O próprio .NET usa esse padrão. A propriedade RegionInfo.ISOCurrencySymbol, por exemplo, retorna o código ISO 4217 de três letras associado a um país ou região, como BRL para o Brasil e USD para os Estados Unidos.

Vantagens do enum sobre a string

Escolher um enum em vez de uma string oferece três vantagens concretas. A primeira é a segurança em tempo de compilação.

O compilador verifica CurrencyCode.Brl, enquanto uma string "BRL" aceita erros de digitação sem nenhum aviso. Assim, um simples "BLR" nunca passa despercebido.

A segunda são os valores inteiros explícitos. Atribuir valores fixos a cada membro garante que adicionar ou reordenar membros no futuro não altere os valores já persistidos no banco de dados.

A terceira é a integração com o Entity Framework Core, que mapeia enums para colunas inteiras ou de texto de forma nativa. Dessa forma, você não precisa de conversores customizados.

Limitações do enum

A principal limitação do enum é a rigidez. Adicionar uma nova moeda exige alteração de código e um novo deploy.

Em sistemas cuja lista de moedas muda com frequência ou depende de configuração, um Value Object baseado em string, com validação contra a lista ISO 4217, pode servir melhor. Para a maioria dos sistemas, porém, o enum basta e oferece mais segurança.

O Value Object Money para dinheiro em aplicações

Com o CurrencyCode definido, o Value Object Money encapsula o valor e a moeda em um único tipo imutável. Esse é o coração de qualquer modelagem séria de dinheiro em aplicações.

public sealed record Money
{
    public decimal Amount { get; }
    public CurrencyCode Currency { get; }

    private Money(decimal amount, CurrencyCode currency)
    {
        Amount = amount;
        Currency = currency;
    }

    public static Money Create(decimal amount, CurrencyCode currency)
        => new(amount, currency);

    public static Money Zero(CurrencyCode currency)
        => new(0, currency);
}

Imutabilidade

Money é um record selado com propriedades somente leitura e construtor privado. Isso garante que nenhuma instância nasce sem passar pela fábrica, e que ninguém altera o valor ou a moeda depois da criação.

Quando o código precisa de uma operação aritmética, o resultado é sempre uma nova instância de Money. Em outras palavras, a instância original nunca muda.

var price = Money.Create(100m, CurrencyCode.Brl);
var tax = Money.Create(19m, CurrencyCode.Brl);
var total = price.Add(tax); // nova instância: 119 BRL

Além disso, o record entrega igualdade estrutural de graça. Duas instâncias com o mesmo valor e a mesma moeda são iguais, exatamente como esperamos de dinheiro em aplicações reais.

O método Zero

O padrão Zero complementa a fábrica Create. Ele permite inicializar campos monetários em agregados sem valores mágicos e sem verificações de nulidade.

// Sem Zero: requer verificação ou inicialização manual
public decimal Total { get; private set; } = 0m;

// Com Zero: expressivo e com moeda definida desde o início
public Money Total { get; private set; } = Money.Zero(CurrencyCode.Brl);

A vantagem é que Money.Zero(CurrencyCode.Brl) comunica explicitamente que o total vale zero reais. Por outro lado, um 0m isolado é apenas um número sem contexto de moeda.

Operações aritméticas seguras

A segurança de tipo do Money só se materializa por completo quando o próprio tipo encapsula as operações aritméticas e aplica a verificação de moeda.

public Money Add(Money other)
{
    EnsureSameCurrency(other);
    return new Money(Amount + other.Amount, Currency);
}

public Money Subtract(Money other)
{
    EnsureSameCurrency(other);
    return new Money(Amount - other.Amount, Currency);
}

public Money Multiply(decimal factor)
    => new(Amount * factor, Currency);

private void EnsureSameCurrency(Money other)
{
    if (Currency != other.Currency)
        throw new CurrencyMismatchException(Currency, other.Currency);
}

A verificação EnsureSameCurrency lança uma exceção específica quando alguém tenta operar moedas diferentes. Com isso, um erro que seria invisível com decimal puro passa a aparecer em tempo de execução.

Sobrecarga de operadores

A sobrecarga dos operadores aritméticos torna o código consumidor mais fluente e natural de ler.

public static Money operator +(Money left, Money right) => left.Add(right);
public static Money operator -(Money left, Money right) => left.Subtract(right);
public static Money operator *(Money money, decimal factor) => money.Multiply(factor);
public static Money operator *(decimal factor, Money money) => money.Multiply(factor);

O operador * aparece nas duas direções, money * 2m e 2m * money, porque a multiplicação é comutativa. Assim, o código consumidor pode escrever de qualquer forma.

var price = Money.Create(99.90m, CurrencyCode.Brl);
var total = price * 3;      // 299.70 BRL
var discounted = 0.9m * price; // 89.91 BRL

Por que a divisão não está incluída

O tipo não inclui divisão por padrão porque ela levanta uma questão de domínio nada trivial: como distribuir um centavo indivisível? Se dividirmos R$ 10,00 por 3, o resultado matemático é R$ 3,333....

Qualquer arredondamento produz um erro. Afinal, 3 × R$ 3,33 = R$ 9,99, e não R$ 10,00.

Essa questão, conhecida como o problema do "penny", exige uma decisão de negócio explícita, e não um comportamento padrão silencioso. Portanto, implemente a divisão apenas quando o domínio definir com clareza o destino do resíduo.

Exibição e localização de dinheiro em aplicações

A formatação para exibição é uma das áreas em que a localização mais pesa no tratamento de dinheiro em aplicações globais. O mesmo valor 1234.56 aparece de formas completamente diferentes conforme a cultura.

Cultura Formato
pt-BR R$ 1.234,56
en-US $1,234.56
de-DE 1.234,56 €
ja-JP ¥1,235
fr-FR 1 234,56 €

O .NET oferece suporte nativo a essa variação por meio de CultureInfo e do especificador de formato "C" (currency).

var value = 1234.56m;

var ptBr = value.ToString("C", new CultureInfo("pt-BR")); // R$ 1.234,56
var enUs = value.ToString("C", new CultureInfo("en-US")); // $1,234.56
var deDe = value.ToString("C", new CultureInfo("de-DE")); // 1.234,56 €

Segundo a documentação dos formatos numéricos padrão, o "C" usa propriedades do NumberFormatInfo da cultura, como CurrencySymbol, CurrencyDecimalDigits e os padrões de posição do símbolo.

Usando a moeda correta na formatação

No entanto, o especificador "C" usa a moeda padrão da cultura, e não necessariamente a moeda do objeto Money. Para garantir a moeda correta, crie um NumberFormatInfo customizado.

public string Format(CultureInfo culture)
{
    var format = (NumberFormatInfo)culture.NumberFormat.Clone();
    format.CurrencySymbol = GetCurrencySymbol(Currency);
    return Amount.ToString("C", format);
}

private static string GetCurrencySymbol(CurrencyCode currency) => currency switch
{
    CurrencyCode.Brl => "R$",
    CurrencyCode.Usd => "$",
    CurrencyCode.Eur => "€",
    CurrencyCode.Gbp => "£",
    CurrencyCode.Jpy => "¥",
    _ => currency.ToString().ToUpperInvariant()
};

Repare que o código clona o NumberFormat antes de alterar o símbolo. Isso acontece porque o objeto da cultura costuma ser somente leitura, e alterar uma cópia evita efeitos colaterais.

Um ToString invariante

O ToString padrão do Value Object deve usar o código ISO como fallback seguro, sem depender de cultura.

public override string ToString()
    => $"{Amount:F2} {Currency.ToString().ToUpperInvariant()}";
// resultado: "1234.56 BRL"

Esse formato invariante serve bem para logs, serialização e depuração. Para exibição ao usuário, por outro lado, use Format(culture) com a cultura correta.

Para aprofundar o tema de culturas, recursos e traduções, consulte o artigo sobre globalization e localization no .NET.

Serialização e persistência de dinheiro em aplicações

Um tipo bem modelado para dinheiro em aplicações precisa sair da memória sem perder informação. Por isso, a persistência e a serialização merecem o mesmo cuidado que o domínio.

Entity Framework Core

Para persistir Money com o Entity Framework Core, a abordagem de Owned Entity é a mais adequada. Ela mapeia as duas propriedades em colunas distintas da mesma tabela.

builder.OwnsOne(x => x.Price, money =>
{
    money.Property(m => m.Amount)
        .HasColumnName("price_amount")
        .HasColumnType("numeric(18,2)")
        .IsRequired();

    money.Property(m => m.Currency)
        .HasColumnName("price_currency")
        .HasConversion<string>()
        .HasMaxLength(3)
        .IsRequired();
});

A conversão .HasConversion<string>() grava o CurrencyCode como texto ("BRL", "USD") em vez de inteiro. Assim, os dados ficam legíveis direto no banco e resistem a reordenações do enum.

O artigo sobre Value Objects no EF Core detalha outras formas de mapear esse tipo de estrutura.

Serialização JSON

Em APIs REST, a serialização de Money precisa entregar ao cliente tanto o valor quanto a moeda.

{
  "price": {
    "amount": 1234.56,
    "currency": "BRL"
  }
}

Com o System.Text.Json, um conversor customizado garante a serialização e a desserialização corretas.

public class MoneyJsonConverter : JsonConverter<Money>
{
    public override Money Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
    {
        using var doc = JsonDocument.ParseValue(ref reader);
        var amount = doc.RootElement.GetProperty("amount").GetDecimal();
        var currency = Enum.Parse<CurrencyCode>(
            doc.RootElement.GetProperty("currency").GetString()!, ignoreCase: true);
        return Money.Create(amount, currency);
    }

    public override void Write(Utf8JsonWriter writer, Money value, JsonSerializerOptions options)
    {
        writer.WriteStartObject();
        writer.WriteNumber("amount", value.Amount);
        writer.WriteString("currency", value.Currency.ToString().ToUpperInvariant());
        writer.WriteEndObject();
    }
}

Note que o método Read volta a usar a fábrica Create. Dessa forma, qualquer validação futura do tipo também protege os dados que chegam pela API.

Conversão de dinheiro entre moedas nas aplicações

O Value Object Money deixa a conversão de moedas de fora de propósito. Converter exige taxas de câmbio, que são dados externos, variáveis no tempo e dependentes de uma fonte autoritativa.

Por isso, essa responsabilidade pertence à camada de serviços, e não ao domínio.

public interface IExchangeRateService
{
    Task<decimal> GetRateAsync(CurrencyCode from, CurrencyCode to, DateTime date);
}

public class MoneyConversionService
{
    private readonly IExchangeRateService _rates;

    public MoneyConversionService(IExchangeRateService rates)
        => _rates = rates;

    public async Task<Money> ConvertAsync(Money source, CurrencyCode targetCurrency)
    {
        if (source.Currency == targetCurrency)
            return source;

        var rate = await _rates.GetRateAsync(source.Currency, targetCurrency, DateTime.UtcNow);
        return Money.Create(source.Amount * rate, targetCurrency);
    }
}

Manter a conversão fora do Value Object garante que o dinheiro em aplicações continue representado por um Money puro, sem dependências de infraestrutura. Além disso, você testa, versiona e altera a lógica de conversão de forma independente.

Testes unitários para dinheiro em aplicações

Os testes de dinheiro em aplicações devem cobrir cinco áreas distintas do tipo Money. Os exemplos abaixo usam NUnit, mas a ideia vale para qualquer framework.

A primeira área é a criação. O teste verifica se o tipo armazena o valor e a moeda corretamente, incluindo zero e valores negativos quando o domínio permite.

[Test(Description = "Should create money with valid amount and currency")]
public void Money_ShouldCreate_WhenValuesAreValid()
{
    var money = Money.Create(100m, CurrencyCode.Brl);
    Assert.That(money.Amount, Is.EqualTo(100m));
    Assert.That(money.Currency, Is.EqualTo(CurrencyCode.Brl));
}

A segunda área são as operações aritméticas, com o caminho feliz e a exceção de moedas diferentes.

[Test(Description = "Should throw CurrencyMismatchException when adding different currencies")]
public void Money_ShouldThrowCurrencyMismatchException_WhenAddingDifferentCurrencies()
{
    var brl = Money.Create(100m, CurrencyCode.Brl);
    var usd = Money.Create(50m, CurrencyCode.Usd);
    Assert.Throws<CurrencyMismatchException>(() => brl.Add(usd));
}

A terceira é a comutatividade do operador de multiplicação. O teste garante que money * factor e factor * money produzem o mesmo resultado.

[Test(Description = "Should multiply using operator with factor on both sides")]
public void Money_ShouldMultiply_UsingOperator_BothSides()
{
    var money = Money.Create(100m, CurrencyCode.Brl);
    Assert.That(money * 3m, Is.EqualTo(3m * money));
}

A quarta é a igualdade estrutural, que o record garante.

[Test(Description = "Should be equal when amount and currency are the same")]
public void Money_ShouldBeEqual_WhenAmountAndCurrencyAreTheSame()
{
    var a = Money.Create(100m, CurrencyCode.Brl);
    var b = Money.Create(100m, CurrencyCode.Brl);
    Assert.That(a, Is.EqualTo(b));
}

A quinta é a formatação, com atenção especial à cultura. Testes de ToString precisam fixar a cultura para não falhar em ambientes com configurações regionais diferentes.

[SetUp]
public void SetUp()
    => Thread.CurrentThread.CurrentCulture = CultureInfo.InvariantCulture;

[Test(Description = "Should return formatted string with amount and currency code")]
public void Money_ShouldReturnFormattedString_FromToString()
{
    var money = Money.Create(1234.56m, CurrencyCode.Brl);
    Assert.That(money.ToString(), Is.EqualTo("1234.56 BRL"));
}

O [SetUp] que fixa CultureInfo.InvariantCulture é obrigatório em qualquer teste que verifique a representação textual de decimais. Sem ele, o separador decimal varia conforme a configuração regional do ambiente de CI.

Nesse cenário, "1234.56 BRL" vira "1234,56 BRL" em máquinas pt-BR e quebra o teste. Para revisar os conceitos de base, veja os fundamentos dos testes de unidade.

Armadilhas comuns com dinheiro em aplicações

Mesmo com um bom Value Object, alguns erros continuam frequentes no tratamento de dinheiro em aplicações. Conhecer essas armadilhas ajuda a revisar o código com mais atenção.

Arredondamento implícito

Operações com decimal podem produzir mais casas decimais do que a moeda suporta. O iene japonês, por exemplo, não possui subdivisões decimais.

O real brasileiro usa duas casas, enquanto o dinar kuwaitiano usa três. Arredondar sem considerar a moeda produz resultados incorretos.

// Incorreto: arredonda para 2 casas para todas as moedas
var rounded = Math.Round(amount, 2);

// Correto: considera as casas decimais da moeda
var decimals = GetDecimalPlaces(currency);
var rounded = Math.Round(amount, decimals, MidpointRounding.AwayFromZero);

Uma implementação simples de GetDecimalPlaces pode partir das unidades menores definidas na ISO 4217.

private static int GetDecimalPlaces(CurrencyCode currency) => currency switch
{
    CurrencyCode.Jpy => 0, // iene não tem subdivisão
    _ => 2                 // padrão da maioria das moedas
};

Vale um alerta importante sobre o modo de arredondamento. Segundo a documentação do Math.Round, o padrão do .NET é MidpointRounding.ToEven, o chamado arredondamento bancário.

Nesse modo, valores no ponto médio vão para o par mais próximo, então 2.345 vira 2.34. Por isso, sempre informe o MidpointRounding desejado e deixe a regra de negócio explícita.

Persistência como float no banco de dados

Gravar valores monetários como float ou double em bancos relacionais traz os mesmos erros da representação binária. O tipo correto é NUMERIC ou DECIMAL, com precisão e escala definidas.

price_amount NUMERIC(18, 2) NOT NULL

Lembre ainda que a escala precisa acomodar a moeda com mais casas que o sistema suporta. Assim, se você aceitar o dinar kuwaitiano, a coluna precisa de três casas decimais.

Exibir o código ISO como símbolo

"BRL" não é um símbolo de moeda, e sim um código ISO. Exibir "BRL 1.234,56" para um usuário brasileiro é tecnicamente correto, mas culturalmente inadequado.

O símbolo correto é "R$ 1.234,56". Portanto, trate essa distinção na camada de apresentação.

Conclusão

Dinheiro é um conceito simples na superfície e extremamente complexo na implementação correta. Um decimal sem contexto de moeda é uma abstração insuficiente para dinheiro em aplicações que operam em mais de um mercado.

O Value Object Money, combinado com o enum CurrencyCode baseado na ISO 4217, cria uma fundação sólida. A exceção CurrencyMismatchException e a separação entre formatação e representação interna completam esse desenho.

Os princípios que tornam esse design robusto são imutabilidade, validação na criação, operações encapsuladas e falha explícita. De fato, são os mesmos princípios que definem bons Value Objects em qualquer contexto.

Dinheiro em aplicações é apenas o exemplo mais consequente de como o descuido com tipos de domínio produz prejuízos reais e mensuráveis. Então, revise hoje como o seu sistema representa valores monetários.

FAQ

Por que não usar double para representar dinheiro em C#?

Porque o double usa base 2 e não representa frações decimais com exatidão. Com isso, pequenos erros de arredondamento se acumulam em cálculos financeiros.

O que é a ISO 4217?

É o padrão internacional que define códigos de três letras para moedas, como BRL, USD e EUR. Ele também define quantas casas decimais cada moeda usa.

Por que o tipo Money não converte moedas sozinho?

Porque a conversão depende de taxas de câmbio externas e variáveis no tempo. Essa lógica pertence a um serviço da aplicação, e não ao domínio.

Qual o modo de arredondamento padrão do Math.Round?

O padrão é MidpointRounding.ToEven, também chamado de arredondamento bancário. Informe o modo explicitamente quando a regra de negócio exigir outro comportamento.

Como persistir o tipo Money no Entity Framework Core?

Use OwnsOne para mapear Amount e Currency em colunas da mesma tabela. Converta a moeda para texto com HasConversion<string>() para manter os dados legíveis.