Repository navigation
Modelo de Dominio
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 sete 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/.
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.
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.
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
Idnasce 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 oidna resposta ou registrá-lo no log sem esperar o banco. -
IdeCreatedAttêmprivate set: depois de criados, nunca mudam. -
UpdatedAttemprotected 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.
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.
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:
- 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.
- 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 umFakeTimeProvider, que marca a hora que o teste quiser. - O
TimeZoneInfovem da configuraçãoBusiness:TimeZone(America/Sao_Paulo). -
Todaypega 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.
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 deClaim.cs. A única forma de chegar aPaidé chamarRegisterPayment(), que só funciona a partir deApproved. A regra não depende da disciplina de quem programa; o compilador a garante.
| 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.
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).
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.
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
IsValidpara 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
NormalizeOrThrowpara garantir a regra, parando no primeiro problema. Ela é a última linha de defesa.
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.
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.
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.
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.
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.
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 claimsmostraInAnalysis, e não1. - 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
UPDATEfeito à 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.
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, ounullse não encontrar". O?deixa explícito que o resultado pode faltar. - O
CancellationTokenpermite cancelar a consulta se o cliente desistir da requisição. - O sufixo
Asyncindica 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.
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.
- Abra
tests/src/PolicyDesk.Domain.Tests/Entities/ClaimsTests.cse leia os testes de transição. Eles são a especificação executável da máquina de estados. - Escreva um teste que tente aprovar um sinistro
Pendinge confira a mensagem da exceção. - Calcule à mão os dígitos verificadores do CPF
123.456.789-??e confira comCpfValidator.IsValid. - Tente criar um
PolicycomcoverageAmount: 1000.005me veja a exceção.
- 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ãoDateTimeem UTC; "hoje" vem doBusinessClock, 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.
- 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.
PolicyDesk · Distribuído sob a licença MIT · Repositório
Comece aqui
Como o sistema é feito
A API
Engenharia
Prática
Consulta