Skip to content

Modelo de Dominio

rodrigosdk edited this page Nov 11, 2026 · 2 revisions

Modelo de domínio: entidades que protegem as próprias regras

Para quem é este artigo: para quem quer entender onde as regras de negócio vivem no código, e para quem estuda modelagem orientada a objetos e DDD.

Pré-requisitos: Entendendo o negócio e noções de classes em C#. Termos como private set e construtor são explicados quando aparecem.

O que você vai aprender: o que é um modelo de domínio "rico", como uma classe garante que nunca existe em estado inválido, como funciona uma máquina de estados em código, como validar CPF, telefone e dinheiro, e como o sistema decide que dia é hoje.

Código: src/Backend/PolicyDesk.Domain/.


O problema: dados sem dono

Imagine uma classe de sinistro escrita do jeito mais simples possível:

public class Claim
{
    public decimal Amount { get; set; }
    public string Status { get; set; }
}

Qualquer parte do sistema pode fazer claim.Status = "Paid". Nada impede pagar um sinistro que nunca foi aprovado, ou criar um sinistro com valor negativo. As regras existem, mas ficam espalhadas em serviços, controllers e validadores, e basta um lugar esquecer de verificar para o dado ficar inconsistente.

Esse estilo tem nome: modelo anêmico (Anemic Domain Model). As classes têm dados, mas nenhum comportamento, como um corpo sem sangue.

O PolicyDesk usa o oposto, um modelo rico: cada entidade é dona dos próprios dados e só deixa que eles mudem por métodos que conferem as regras. Este artigo mostra como.


Três técnicas que fazem uma entidade se proteger

Antes de ver as entidades, vale conhecer as ferramentas do C# que elas usam.

1. private set: ninguém de fora altera.

public ClaimStatus Status { get; private set; } = ClaimStatus.Pending;

Qualquer código pode ler claim.Status, mas só a própria classe Claim pode mudar. Tentar claim.Status = ClaimStatus.Paid em outro arquivo é um erro de compilação.

2. O construtor valida tudo. A única forma de criar um Claim é pelo construtor, e ele recusa dados inválidos lançando uma exceção. Se o objeto foi criado, ele é válido. Esse princípio se chama Always-Valid Entity (entidade sempre válida).

3. Métodos com nomes do negócio. Em vez de Status = X, a entidade oferece Approve(), Deny(motivo), RegisterPayment(). Cada método confere se a mudança é permitida antes de fazê-la.

Juntas, essas três técnicas garantem que nenhuma regra possa ser contornada, nem por engano.


A base de tudo: Entity

Todas as entidades herdam de uma classe base:

public abstract class Entity
{
    public Guid Id { get; private set; } = Guid.NewGuid();
    public DateTime CreatedAt { get; private set; } = DateTime.UtcNow;
    public DateTime? UpdatedAt { get; protected set; }
}

Cada linha tem uma decisão por trás:

  • O Id nasce com o objeto, gerado pela aplicação (Guid.NewGuid()), e não pelo banco. Um GUID é um identificador de 128 bits praticamente impossível de repetir. A vantagem: o objeto já tem identidade antes de ser salvo, então o código pode devolver o id na resposta ou registrá-lo no log sem esperar o banco.
  • Id e CreatedAt têm private set: depois de criados, nunca mudam.
  • UpdatedAt tem protected set: só as classes filhas podem alterá-lo, dentro dos seus métodos de negócio.
  • Instantes ficam em UTC, o horário de referência mundial. Converter para o horário local é trabalho de quem exibe a informação.

A pasta onde essa classe mora se chama SeedWorks ("código-semente"), um nome popularizado por um guia de arquitetura da Microsoft. O padrão de ter uma classe base com o que é comum a todas as entidades se chama Layer Supertype. Veja Design Patterns › Entity e Layer Supertype.

Instante ou data? DateTime e DateOnly

O domínio usa dois tipos diferentes para tempo, e a diferença importa:

Tipo Representa Exemplo Usado em
DateTime (em UTC) Um instante: um ponto exato no tempo 15/10/2026 às 14:22:10 UTC CreatedAt, ApprovedAt, PaidAt
DateOnly Uma data de calendário: um dia, sem horário nem fuso 15/10/2026 BirthDate, StartDate, EndDate, OccurredOn

Uma data de nascimento não tem horário. Se ela fosse um DateTime, alguém poderia gravar "10/05/1990 às 23h em Brasília", que em UTC já é dia 11, e a data mudaria de dia sem ninguém perceber. Com DateOnly, esse erro simplesmente não pode acontecer.


O relógio de negócio: BusinessClock

Três regras perguntam "que dia é hoje?": a data de nascimento e a data de ocorrência não podem ser futuras, e um segurado com apólice em vigor hoje não pode ser inativado.

A resposta óbvia, DateTime.UtcNow, tem dois problemas:

  1. O dia em UTC não é o dia em Brasília. Às 22h do dia 10 em Brasília, em UTC já é dia 11. Nesse horário, um sinistro com a data de amanhã seria aceito.
  2. Não dá para testar. Para testar o que acontece às 22h, o teste teria de rodar às 22h.

A solução é tratar o tempo como uma dependência que o código recebe, em vez de algo que ele busca sozinho:

public sealed class BusinessClock(TimeProvider timeProvider, TimeZoneInfo timeZone)
{
    public DateTime UtcNow => timeProvider.GetUtcNow().UtcDateTime;

    public DateOnly Today => DateOnly.FromDateTime(TimeZoneInfo.ConvertTime(timeProvider.GetUtcNow(), timeZone).DateTime);
}
  • O TimeProvider é a abstração de tempo do próprio .NET. Em produção, ele lê o relógio do sistema. Nos testes, é trocado por um FakeTimeProvider, que marca a hora que o teste quiser.
  • O TimeZoneInfo vem da configuração Business:TimeZone (America/Sao_Paulo).
  • Today pega o instante atual, converte para o fuso de negócio e devolve só a data.

Um detalhe de design: as entidades não recebem o relógio. É o caso de uso que pergunta clock.Today e passa o resultado como parâmetro: new Policyholder(..., today). Assim, a entidade continua simples e testável, sem depender de nada externo. Os instantes (CreatedAt, ApprovedAt...) continuam usando DateTime.UtcNow, porque nenhuma regra depende deles.


As entidades

Claim: o sinistro e sua máquina de estados

O sinistro é a entidade mais interessante, porque tem um ciclo de vida. Veja o construtor:

public Claim(Guid policyId, string description, decimal amount, DateOnly occurredOn)
{
    if (policyId == Guid.Empty)
        throw new DomainRuleException(ResourcesMessageException.POLICY_REQUIRED);
    if (string.IsNullOrWhiteSpace(description))
        throw new DomainRuleException(ResourcesMessageException.DESCRIPTION_REQUIRED);
    if (FieldLimits.Exceeds(description, FieldLimits.Description))
        throw new DomainRuleException(FieldLimits.DescriptionTooLong);
    if (amount <= 0)
        throw new DomainRuleException(ResourcesMessageException.CLAIM_AMOUNT_MUST_BE_POSITIVE);
    if (!MoneyLimits.IsValid(amount))
        throw new DomainRuleException(MoneyLimits.ClaimAmountInvalidPrecision);
    if (occurredOn == default)
        throw new DomainRuleException(ResourcesMessageException.CLAIM_OCCURRENCE_DATE_REQUIRED);

    PolicyId = policyId;
    Description = description.Trim();
    Amount = amount;
    OccurredOn = occurredOn;
}

Repare no formato: uma verificação por linha, cada uma saindo imediatamente com um erro (throw). Essas verificações se chamam guard clauses ("cláusulas de guarda"). Só depois de todas passarem, o objeto é preenchido. O resultado: não existe um Claim com valor negativo ou sem descrição em lugar nenhum do sistema.

Agora, as mudanças de estado:

public void Approve()
{
    EnsureStatus(ClaimStatus.InAnalysis, ResourcesMessageException.CLAIM_MUST_BE_IN_ANALYSIS_FOR_APPROVAL);

    Status = ClaimStatus.Approved;
    ApprovedAt = DateTime.UtcNow;
    UpdatedAt = ApprovedAt;
}

private void EnsureStatus(ClaimStatus expected, string error)
{
    if (Status != expected)
        throw new DomainRuleException(error);
}

Approve() primeiro confere se o sinistro está em análise. Se não estiver, lança um erro com a mensagem "Somente sinistros em análise podem ser aprovados". Se estiver, muda o status e registra quando.

Método Só funciona a partir de Leva para Também registra
StartAnalysis() Pending InAnalysis UpdatedAt
Approve() InAnalysis Approved ApprovedAt
Deny(reason) InAnalysis Denied DenialReason (obrigatório, até 1000 caracteres)
RegisterPayment() Approved Paid PaidAt

Isso é uma máquina de estados: um conjunto de estados possíveis e as transições permitidas entre eles. Compare a tabela com o diagrama em Entendendo o negócio: cada seta virou um método.

💡 Para estudar: procure no projeto inteiro por Status = ClaimStatus. Só aparece dentro de Claim.cs. A única forma de chegar a Paid é chamar RegisterPayment(), que só funciona a partir de Approved. A regra não depende da disciplina de quem programa; o compilador a garante.

Policy: a apólice

Propriedade Regra
PolicyholderId Obrigatório
Description Obrigatória, até 500 caracteres
CoverageAmount Maior que zero, até 2 casas decimais
PremiumAmount Maior que zero, até 2 casas decimais
StartDate, EndDate DateOnly, obrigatórias, e o fim depois do início
Status Nasce Active
CancelledAt Preenchido ao cancelar

A apólice responde perguntas e protege duas operações:

public bool IsCancelled => Status == PolicyStatus.Cancelled;

public bool IsInForce(DateOnly date) => !IsCancelled && date >= StartDate && date <= EndDate;

public void RegisterClaim(decimal amount, decimal committedAmount)
{
    if (committedAmount + amount > CoverageAmount)
        throw new DomainRuleException(ResourcesMessageException.CLAIM_AMOUNT_EXCEEDS_COVERAGE);

    UpdatedAt = DateTime.UtcNow;
}

public void Cancel()
{
    if (IsCancelled)
        throw new DomainRuleException(ResourcesMessageException.POLICY_ALREADY_CANCELLED);

    Status = PolicyStatus.Cancelled;
    CancelledAt = DateTime.UtcNow;
    UpdatedAt = CancelledAt;
}

IsInForce é uma pergunta de negócio feita à entidade: "você cobre esta data?". O caso de uso de sinistro não compara datas por conta própria; ele pergunta à apólice. O conhecimento sobre vigência mora junto com os dados de vigência. Esse princípio se chama Tell, Don't Ask ("mande, não pergunte"): em vez de pegar os dados de um objeto e decidir do lado de fora, peça ao objeto que decida.

RegisterClaim guarda a regra do saldo da cobertura (veja o exemplo passo a passo em Entendendo o negócio). O caso de uso busca no banco a soma dos sinistros que ainda contam e entrega à apólice, que decide. E por que esse método mexe no UpdatedAt se ele não muda nada visível? Para detectar concorrência: alterar a apólice muda a versão dela no banco. Se dois sinistros forem abertos ao mesmo tempo e, juntos, estourarem a cobertura, um deles vai encontrar a apólice com versão diferente e será recusado com 409. Veja Persistência › Concorrência.

Policyholder: o segurado

public Policyholder(string name, string cpf, DateOnly birthDate, string email, string phone, DateOnly today)
{
    Name = NormalizeNameOrThrow(name);
    Cpf = CpfValidator.NormalizeOrThrow(cpf);
    BirthDate = EnsureValidBirthDate(birthDate, today);
    Email = EmailValidator.NormalizeOrThrow(email);
    Phone = EnsureValidPhone(phone);
}

Três ideias aparecem aqui:

Normalizar na entrada. Cada dado é colocado em uma forma única antes de ser guardado: o CPF vira só dígitos ("529.982.247-25" → "52998224725"), o e-mail vai para minúsculas, o telefone vai para o formato internacional ("(83) 9 8647-1687" → "+5583986471687") e o nome perde os espaços das pontas. Assim, comparações e índices únicos do banco funcionam sem surpresas: não existem duas formas diferentes de escrever o mesmo CPF.

Validar tudo antes de mudar qualquer coisa. Veja o método de atualização:

public void Update(string name, DateOnly birthDate, string email, string phone, DateOnly today)
{
    var normalizedName = NormalizeNameOrThrow(name);
    var validBirthDate = EnsureValidBirthDate(birthDate, today);
    var normalizedEmail = EmailValidator.NormalizeOrThrow(email);
    var validPhone = EnsureValidPhone(phone);

    Name = normalizedName;
    BirthDate = validBirthDate;
    Email = normalizedEmail;
    Phone = validPhone;
    UpdatedAt = DateTime.UtcNow;
}

Primeiro, todas as validações, guardando os resultados em variáveis. Só depois, as atribuições. Se o telefone for inválido, o erro acontece antes de o nome ser alterado. A entidade nunca fica "meio atualizada". Isso é atomicidade em memória: ou tudo muda, ou nada muda.

Métodos que parecem não fazer nada. RegisterPolicy() só toca o UpdatedAt. Ele é chamado quando uma apólice é emitida para o segurado, e serve, como o RegisterClaim da apólice, para mudar a versão do registro no banco. Assim, se alguém tentar inativar o segurado no mesmo instante em que uma apólice é emitida, uma das duas operações é recusada. Detalhes em Persistência › Proteção entre agregados.

Deactivate() faz a exclusão lógica (Active = false).


Um construtor escondido para o banco

Todas as entidades têm, além do construtor público, um construtor privado e vazio:

// Usado pelo EF Core ao carregar do banco: não reaplica as regras de criação
private Claim() { }

Por quê? Quando o Entity Framework lê um registro do banco, ele precisa criar o objeto. Se usasse o construtor público, as validações rodariam de novo. Parece bom, mas imagine que uma regra nova entra em vigor: "nome com no máximo 150 caracteres". Um segurado cadastrado antes, com 200 caracteres, não poderia mais ser lido. O sistema quebraria por causa de dados antigos.

O construtor privado resolve isso: as regras de criação valem para dados novos; dados que já estão no banco são lidos como estão. O EF Core consegue usar construtores privados, e ninguém mais consegue.


Validadores de valor

Algumas validações são usadas em vários lugares, então ganharam classes próprias em Domain/Validations/. São classes estáticas, sem estado, e todas seguem o mesmo formato:

Método O que faz Lança exceção?
Normalize(x) Coloca o valor na forma padrão Não
IsValid(x) Responde se é válido Não
NormalizeOrThrow(x) Normaliza e, se for inválido, lança DomainRuleException Sim

Por que duas formas de validar? Porque há dois usos diferentes:

  • A camada de aplicação usa IsValid para juntar todos os erros e devolvê-los de uma vez ao cliente: "CPF inválido; e-mail inválido; telefone inválido". Quem preenche um formulário prefere saber de tudo de uma vez.
  • A entidade usa NormalizeOrThrow para garantir a regra, parando no primeiro problema. Ela é a última linha de defesa.

CpfValidator: como funcionam os dígitos verificadores

O CPF tem 11 dígitos: 9 de base e 2 dígitos verificadores, calculados a partir dos 9 primeiros. Se alguém digitar um número errado, o cálculo não bate, e o erro é detectado sem consultar a Receita.

private static int CalculateCheckDigit(string digits, int length)
{
    var sum = 0;
    for (var i = 0; i < length; i++)
        sum += (digits[i] - '0') * (length + 1 - i);

    var remainder = sum * 10 % 11;
    return remainder == 10 ? 0 : remainder;
}

Vamos calcular à mão para o CPF 529.982.247-25.

Primeiro dígito verificador. Multiplique os 9 primeiros dígitos pelos pesos de 10 a 2 e some:

Dígito 5 2 9 9 8 2 2 4 7
Peso 10 9 8 7 6 5 4 3 2
Produto 50 18 72 63 48 10 8 12 14

Soma = 295. Depois, 295 × 10 = 2950, e o resto da divisão por 11 é 2. O 10º dígito do CPF é 2. ✔️

Segundo dígito verificador. O mesmo processo com os 10 primeiros dígitos (incluindo o 2 que acabamos de calcular) e pesos de 11 a 2:

5·11 + 2·10 + 9·9 + 9·8 + 8·7 + 2·6 + 2·5 + 4·4 + 7·3 + 2·2 = 347. Depois, 3470 mod 11 = 5. O 11º dígito é 5. ✔️

O validador também recusa CPFs com todos os dígitos iguais, como 111.111.111-11. Eles passam no cálculo, mas a Receita não os emite.

💡 Para estudar: os testes geram CPFs válidos com a classe CpfGenerator, que usa outra fórmula: resto < 2 ? 0 : 11 - resto. Prove para si mesmo que as duas dão sempre o mesmo resultado.

EmailValidator

Usa o MailAddress.TryCreate do próprio .NET e acrescenta duas regras: o endereço interpretado precisa ser idêntico ao recebido (o que recusa formatos como "Maria <maria@exemplo.com>"), e o domínio precisa ter um ponto que não esteja nas pontas (o que recusa maria@localhost). O limite é de 254 caracteres, o máximo prático definido pela RFC 5321, a especificação do e-mail.

PhoneValidator

Validar telefone parece fácil até você conhecer as exceções: DDDs que não existem, celulares brasileiros que precisam do 9, números estrangeiros com tamanhos diferentes. Em vez de reinventar essas regras, o projeto usa a libphonenumber-csharp, uma adaptação da biblioteca do Google usada pelo Android, que conhece o plano de numeração de cada país.

var parsed = PhoneUtil.Parse(phone, "BR");
if (parsed.HasExtension || !PhoneUtil.IsValidNumber(parsed))
    return false;
// ...
return PhoneUtil.Format(number, PhoneNumberFormat.E164);

O segundo parâmetro, "BR", diz: "se o número não começar com +, considere que é brasileiro".

Entrada Resultado
(83) 9 8647-1687 ✔️ +5583986471687
+44 20 7946 0958 ✔️ +442079460958 (Londres)
(20) 9 8647-1687 ❌ o DDD 20 não existe
(83) 8647-1687 ❌ celular sem o 9
+55 83 98647-1687 ramal 12 ❌ números com ramal são recusados
Números com dígitos árabes ou de outros alfabetos ❌ recusados

O formato de saída, E.164, é o padrão internacional: +, código do país, DDD e número, só com dígitos. Gravar todos os telefones assim faz com que duas máscaras diferentes do mesmo número virem o mesmo texto. A migration NormalizePhonesToE164 converteu os telefones antigos e é irreversível: a formatação original se perdeu.

MoneyLimits: dinheiro tem casas decimais certas

public const int Precision = 18;
public const int Scale = 2;

public static bool IsValid(decimal amount) =>
    decimal.Round(amount, Scale) == amount && Math.Abs(amount) < MaxExclusive;

No banco, as colunas de dinheiro são numeric(18,2): até 18 dígitos no total, 2 deles depois da vírgula (portanto, até 16 antes). Se alguém enviar 1000.005, o PostgreSQL arredondaria em silêncio para 1000.01, e o valor gravado seria diferente do enviado. Ninguém perceberia.

MoneyLimits espelha a regra do banco no código: se arredondar para 2 casas muda o número, ele é recusado com uma mensagem clara. É melhor o cliente receber um erro do que ter um valor alterado sem aviso.

FieldLimits: uma fonte única para os tamanhos

public const int Cpf = 11;
public const int Email = 254;
public const int PolicyholderName = 150;
public const int Phone = 20;
public const int Description = 500;
public const int DenialReason = 1000;
public const int Status = 20;

Essas constantes são usadas em três lugares: na validação da aplicação, nas regras das entidades e na definição das colunas do banco (HasMaxLength(FieldLimits.Description)). Se o limite de descrição mudar, muda em um lugar só. Sem isso, seria fácil a validação aceitar 600 caracteres e o banco aceitar só 500.


Enums: os status gravados como texto

public enum ClaimStatus
{
    Pending,
    InAnalysis,
    Approved,
    Denied,
    Paid
}

Um enum é um tipo com uma lista fechada de valores. No banco, o status é gravado pelo nome ("InAnalysis"), e não pelo número interno do enum. Por quê?

  • O banco fica legível. SELECT * FROM claims mostra InAnalysis, e não 1.
  • Reordenar o enum não estraga os dados. Se o status fosse gravado como número, mover um valor de lugar mudaria o significado de todos os registros antigos.
  • O banco se protege. Uma regra no banco (check constraint) só aceita os valores do enum. Um UPDATE feito à mão com 'Unknown' é recusado.

O custo: renomear um valor passa a exigir uma migration que atualize os dados gravados.

A API tem enums próprios, em português (StatusSinistro.EmAnalise, StatusApolice.Ativa), e a camada de aplicação converte de um para o outro. Assim, o domínio pode mudar sem quebrar quem usa a API.


Os contratos de repositório

O domínio precisa buscar e salvar dados, mas não sabe nada de banco. Ele declara interfaces, dizendo o que precisa:

public interface IClaimsReadOnlyRepository
{
    Task<Claim?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
    Task<IEnumerable<Claim>> GetByPolicyIdAsync(Guid policyId, CancellationToken cancellationToken = default);
    Task<decimal> SumCommittedAmountAsync(Guid policyId, CancellationToken cancellationToken = default);
}

public interface IClaimsUpdateOnlyRepository
{
    Task<Claim?> GetByIdForUpdateAsync(Guid id, CancellationToken cancellationToken = default);
}

public interface IClaimsWriteOnlyRepository
{
    Task AddAsync(Claim claim);
}

public interface IUnitOfWork
{
    Task CommitAsync(CancellationToken cancellationToken = default);
}

Alguns detalhes de leitura:

  • Task<Claim?> significa "uma operação assíncrona que devolve um sinistro, ou null se não encontrar". O ? deixa explícito que o resultado pode faltar.
  • O CancellationToken permite cancelar a consulta se o cliente desistir da requisição.
  • O sufixo Async indica métodos assíncronos, uma convenção do .NET.
  • As listagens paginadas devolvem um PagedResult<T>(Items, TotalCount): a página e o total de registros.

Por que três interfaces para o mesmo sinistro (leitura, alteração e escrita)? Isso é explicado em Design Patterns › Repositórios segregados.


Agregados: onde termina cada entidade

No DDD, um agregado é um grupo de objetos tratado como uma unidade de consistência, com uma entidade "raiz" que controla o acesso. No PolicyDesk, cada entidade é a raiz do próprio agregado:

Agregado Raiz Como se refere aos outros
Segurado Policyholder —
Apólice Policy PolicyholderId (só o id)
Sinistro Claim PolicyId (só o id)

Repare: as entidades não têm propriedades de navegação. Não existe policy.Policyholder nem policy.Claims. Elas se referem umas às outras só pelo id, como recomenda Vaughn Vernon em Implementing Domain-Driven Design. Isso mantém cada agregado pequeno e evita carregar, sem querer, uma apólice com milhares de sinistros só para ler a descrição.

A consequência é que regras que envolvem mais de um agregado são coordenadas pelo caso de uso. O saldo da cobertura é um exemplo: o RegisterClaimUseCase busca a soma dos sinistros e entrega à apólice, que decide em RegisterClaim. A regra mora na entidade; a coordenação mora no caso de uso.


Experimente

  1. Abra tests/src/PolicyDesk.Domain.Tests/Entities/ClaimsTests.cs e leia os testes de transição. Eles são a especificação executável da máquina de estados.
  2. Escreva um teste que tente aprovar um sinistro Pending e confira a mensagem da exceção.
  3. Calcule à mão os dígitos verificadores do CPF 123.456.789-?? e confira com CpfValidator.IsValid.
  4. Tente criar um Policy com coverageAmount: 1000.005m e veja a exceção.

Resumo

  • O PolicyDesk usa um modelo rico: as entidades protegem as próprias regras com private set, construtores que validam e métodos com nomes do negócio.
  • O fluxo do sinistro é uma máquina de estados garantida pelo compilador.
  • Datas de calendário são DateOnly; instantes são DateTime em UTC; "hoje" vem do BusinessClock, no fuso de negócio.
  • Validadores reutilizáveis normalizam e validam CPF, e-mail, telefone e dinheiro.
  • Agregados se referem uns aos outros só pelo id; regras entre agregados são coordenadas pelos casos de uso.

Para aprofundar

  • Domain-Driven Design, Eric Evans, capítulos sobre entidades e agregados.
  • Implementing Domain-Driven Design, Vaughn Vernon, capítulo 10 (agregados).
  • "Anemic Domain Model", artigo de Martin Fowler.
  • Próximo artigo sugerido: Persistência e Banco de Dados.

Clone this wiki locally