Entity modeling
Escopo: C#. Visão transversal: shared/architecture/entity-modeling.md. As decisões de domínio (quando extrair, como relacionar, onde mora a invariante) são as mesmas; aqui o foco é o idiom:
readonly record structpara IDs tipados,abstract class Entity<TId>com igualdade por ID,IReadOnlyList<T>em propriedades públicas e#nullable enablecomo guard rail (barreira de proteção).
Esta página serve a duas pessoas. A primeira está modelando a entidade inicial do projeto em C# e ainda não sabe quantas propriedades é demais. A segunda volta para revisar uma decisão antiga (por exemplo, vale a pena quebrar Customer agora que ela tem 18 campos?). As duas saem daqui com critério para decidir caso a caso.
O texto cobre quatro perguntas que aparecem cedo em todo projeto que cresce: quantas propriedades uma entidade aguenta antes de fragmentar; quando uma propriedade vira lista; como expressar relacionamentos um para muitos e muitos para muitos; quando faz sentido herdar de uma Entity<TId> base. Os exemplos assumem <Nullable>enable</Nullable> no .csproj e seguem o code style C# moderno: sealed por padrão, private init em setters de domínio, construtores private com factory estáticos.
Conceitos fundamentais
| Conceito | O que é |
|---|---|
| entity (entidade) | Objeto de domínio com identidade própria (Customer, Order); a igualdade é definida pelo ID, não pelas propriedades |
| value object (objeto de valor) | Conceito sem identidade, definido pelos próprios valores (Address, Money); a igualdade é estrutural, campo a campo |
| aggregate (agregado) | Cluster de entidades e value objects tratado como uma unidade transacional (Order + OrderItem formam um agregado) |
| aggregate root (raiz do agregado) | Única entidade externa do agregado; protege as invariantes e é o único ponto de entrada para o cluster |
| invariant (invariante, regra que sempre vale) | Restrição garantida pelo construtor ou factory e pelos métodos que alteram estado (ex.: pedido sempre tem ao menos um item) |
| boundary (limite) | Limite entre dois contextos onde os dados são validados ao atravessar (entrada da função, limite do agregado, limite do sistema) |
| strongly-typed id (identificador tipado) | ID embrulhado em um tipo próprio (CustomerId), em vez de Guid ou string cru, para impedir trocas acidentais entre IDs |
| record struct (estrutura de registro) | readonly record struct em C#: value type com igualdade estrutural nativa, IEquatable<T> implícito, alocação em pilha |
| init setter (setter de inicialização) | Modificador init que permite atribuição apenas durante a construção do objeto, tornando a propriedade efetivamente não-alterável |
| nullable reference types (tipos de referência anuláveis, NRT) | Recurso de #nullable enable que torna ausência de valor parte do contrato: string? pode ser nulo, string não pode |
| IReadOnlyList (lista somente leitura) | Interface IReadOnlyList<T>: expõe coleção sem Add, Remove nem atribuição; usado em propriedades públicas de agregados |
| cardinality (cardinalidade, quantidade da relação) | Quantos elementos a relação aceita entre dois conceitos: 0..1, 1, 0..N, 1..N, N..N |
| cohesion (coesão) | Medida de quanto as propriedades e operações de uma entidade pertencem ao mesmo conceito de negócio |
| God Object (objeto-deus, classe que sabe demais) | Antipadrão de classe que acumula responsabilidades demais e vira ponto de mudança para tudo |
| repository (repositório) | Componente que encapsula a persistência de uma entidade ou agregado, escondendo SQL e ORM do domínio |
| ORM (Object-Relational Mapping, mapeamento objeto-relacional) | Camada que traduz objetos do código para tabelas do banco e de volta (Entity Framework Core, Dapper, NHibernate) |
| soft delete (remoção lógica) | Marcar o registro como excluído (DeletedAt preenchido) sem apagar fisicamente, preservando histórico |
| multitenancy (multilocação) | Uma instância da aplicação serve múltiplos clientes (tenants) com isolamento de dados entre eles |
| row-level security (segurança por linha, RLS) | Recurso do banco que filtra linhas pelo contexto da requisição antes da query chegar ao app |
| GUID (Globally Unique Identifier · identificador único global) | Valor de 128 bits usado como ID, representado como Guid no .NET, no formato xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx |
Tamanho saudável da entidade
A pergunta "quantas propriedades é demais" não tem número certo, e ninguém deveria comprometer-se com um. O sinal que funciona é a coesão: as propriedades mudam juntas, são consultadas juntas, fazem sentido juntas. Quando um subconjunto começa a mudar em outro ritmo, ele já é outra coisa pedindo um nome próprio.
Os números abaixo servem como referência para começar a conversa:
- 5 a 10 propriedades: zona confortável. A maior parte das entidades de domínio cabe aqui.
- 10 a 15: hora de olhar a coesão. Se todos os campos descrevem o mesmo conceito (
Ordercom cabeçalho, totais e status), tudo bem. Se já dá para agrupar (endereço, preferências, dados fiscais), extrair. - 15 ou mais: quase sempre é sinal de duas entidades coladas na mesma classe. Quebrar.
Quando o nome da entidade não descreve mais o que ela faz e vira lista (CustomerWithAddressAndPreferencesAndAccount), o limite já passou.
❌ Ruim: Customer inchada misturando perfil, endereço, preferências e fiscal
public sealed class Customer
{
public Guid Id { get; private init; }
public string FirstName { get; private init; } = string.Empty;
public string LastName { get; private init; } = string.Empty;
public string Email { get; private init; } = string.Empty;
public string Phone { get; private init; } = string.Empty;
public DateOnly BirthDate { get; private init; }
public string Street { get; private init; } = string.Empty;
public string Number { get; private init; } = string.Empty;
public string? Complement { get; private init; }
public string City { get; private init; } = string.Empty;
public string State { get; private init; } = string.Empty;
public string ZipCode { get; private init; } = string.Empty;
public string Country { get; private init; } = string.Empty;
public bool NewsletterOptIn { get; private init; }
public bool SmsOptIn { get; private init; }
public string PreferredLanguage { get; private init; } = string.Empty;
public string? TaxId { get; private init; }
public string? TaxRegime { get; private init; }
public string? InvoiceEmail { get; private init; }
}
20 propriedades em quatro conceitos misturados. Mudar preferência de newsletter força reler toda a classe. Validar endereço significa duplicar a regra em todo método que cria ou atualiza um cliente. Metade dos campos é null para clientes pessoa física.
✅ Bom: Customer com extrações por conceito
public sealed class Customer : Entity<CustomerId>
{
public string Name { get; private init; }
public string Email { get; private init; }
public Address Address { get; private init; }
public CustomerPreferences Preferences { get; private init; }
public TaxInfo? TaxInfo { get; private init; }
private Customer(
CustomerId id,
string name,
string email,
Address address,
CustomerPreferences preferences,
TaxInfo? taxInfo)
: base(id)
{
Name = name;
Email = email;
Address = address;
Preferences = preferences;
TaxInfo = taxInfo;
}
public static Customer Register(
string name,
string email,
Address address,
CustomerPreferences preferences,
TaxInfo? taxInfo = null)
{
var customer = new Customer(
CustomerId.New(),
name,
email,
address,
preferences,
taxInfo);
return customer;
}
}
// value object: igualdade estrutural via record
public sealed record Address(
string Street,
string Number,
string? Complement,
string City,
string State,
string ZipCode,
string Country);
public sealed record CustomerPreferences(
bool NewsletterOptIn,
bool SmsOptIn,
string PreferredLanguage);
public sealed record TaxInfo(
string TaxId,
string TaxRegime,
string InvoiceEmail);
Cada tipo responde a uma pergunta clara. Address é reusada por Customer, Order (endereço de entrega) e qualquer outro contexto que precise de endereço, sem reinventar. TaxInfo é nullable inteiro, e quando presente vem completo. O record entrega igualdade estrutural sem boilerplate (código repetitivo de cerimônia).
Sinais concretos de que chegou a hora de quebrar:
- Métodos da entidade usam apenas metade dos campos.
- Validações em conflito (o que vale para um campo invalida o outro).
- Campos
T?demais (8 de 20 semprenull). - Persistência precisa de duas tabelas no banco para uma única entidade no código.
Composição: quando extrair
Quando uma entidade fica grande, há três padrões clássicos para extrair partes dela. Cada um responde a um cenário diferente, e a escolha depende de como o conceito extraído vai ser usado.
Value object embutido (Address dentro de Customer): o conceito é pequeno, não tem identidade própria, e faz parte do estado natural do dono. O endereço muda inteiro, nunca por partes. Em C#, vira sealed record com todos os campos como parâmetros do construtor primário, sem alteração depois de criado.
Value object opcional (TaxInfo dentro de Customer): o conceito existe apenas em alguns casos. Cliente pessoa física não tem; cliente pessoa jurídica tem. O campo é TaxInfo?; quando presente, traz o conceito completo (todos os campos juntos, validados juntos).
Entidade satélite (CustomerProfile separada): a informação é acessada raramente, tem volume maior, ou segue regras próprias de versionamento. A separação compensa quando 80% das consultas ao Customer não precisam do Profile. Aqui o satélite é uma entidade própria, com ID, e referencia o Customer por CustomerId.
❌ Ruim: campos opcionais espalhados no lugar de extrair value object
public sealed class Customer : Entity<CustomerId>
{
public string Name { get; private init; }
public string Email { get; private init; }
// se PJ, esses três aparecem; se PF, ficam null
public string? TaxId { get; private init; }
public string? TaxRegime { get; private init; }
public string? InvoiceEmail { get; private init; }
public bool HasTaxInfo()
{
var isFiscallyRegistered = TaxId is not null && TaxRegime is not null;
return isFiscallyRegistered;
}
}
A regra "se um campo de imposto existe, todos existem" mora no método HasTaxInfo. Cada nova feature de imposto vai precisar reler e replicar essa checagem. O compilador aceita Customer com TaxId preenchido e TaxRegime nulo, criando estado inválido.
✅ Bom: TaxInfo como value object opcional criado via factory
public sealed record TaxInfo
{
public string TaxId { get; }
public string TaxRegime { get; }
public string InvoiceEmail { get; }
private TaxInfo(string taxId, string taxRegime, string invoiceEmail)
{
TaxId = taxId;
TaxRegime = taxRegime;
InvoiceEmail = invoiceEmail;
}
public static TaxInfo Create(string taxId, string taxRegime, string invoiceEmail)
{
if (string.IsNullOrWhiteSpace(taxId) || string.IsNullOrWhiteSpace(taxRegime))
{
throw new DomainException("TaxInfo requires both TaxId and TaxRegime.");
}
var taxInfo = new TaxInfo(taxId, taxRegime, invoiceEmail);
return taxInfo;
}
}
public sealed class Customer : Entity<CustomerId>
{
public string Name { get; private init; }
public string Email { get; private init; }
public TaxInfo? TaxInfo { get; private init; }
public bool HasTaxInfo()
{
var isFiscallyRegistered = TaxInfo is not null;
return isFiscallyRegistered;
}
}
A invariante "se imposto existe, é completo" mora no factory TaxInfo.Create. Quem cria um cliente sem imposto passa null. Não tem como construir um TaxInfo parcial: o construtor é privado e o factory falha cedo.
Strongly-typed IDs
Quando o sistema cresce, IDs viram fonte recorrente de bug: alguém passa orderId onde a função esperava customerId, ou inverte a ordem dos argumentos sem perceber. Como todos são Guid, o compilador aceita a troca silenciosamente, e o erro só aparece quando um cliente é cobrado pelo pedido errado em produção.
A defesa em C# é barata: readonly record struct. Um CustomerId(Guid Value) é um value type que carrega IEquatable<T> e igualdade estrutural sem custo de alocação em heap. O compilador rejeita CustomerId onde se espera OrderId em tempo de compilação, sem nenhuma checagem em runtime.
❌ Ruim: IDs como Guid cru, fáceis de trocar de lugar
public static void TransferOwnership(Guid customerId, Guid orderId)
{
// assinatura: customerId primeiro, orderId depois
// se o caller inverter, o bug passa silencioso
orderRepository.Update(orderId, customerId);
}
// uso longe daqui, com nomes locais diferentes:
var newOwnerId = order.Id;
var targetOrderId = customer.Id;
TransferOwnership(newOwnerId, targetOrderId); // invertido; nada acusa
✅ Bom: readonly record struct com igualdade estrutural nativa
public readonly record struct CustomerId(Guid Value)
{
public static CustomerId New() => new(Guid.NewGuid());
public static CustomerId From(Guid value)
{
if (value == Guid.Empty)
{
throw new DomainException("CustomerId cannot be empty.");
}
var customerId = new CustomerId(value);
return customerId;
}
}
public readonly record struct OrderId(Guid Value)
{
public static OrderId New() => new(Guid.NewGuid());
public static OrderId From(Guid value)
{
if (value == Guid.Empty)
{
throw new DomainException("OrderId cannot be empty.");
}
var orderId = new OrderId(value);
return orderId;
}
}
public static void TransferOwnership(CustomerId customerId, OrderId orderId)
{
orderRepository.Update(orderId, customerId);
}
var newOwnerId = order.Id; // OrderId
var targetOrderId = customer.Id; // CustomerId
// trocar a ordem: erro de compilação
TransferOwnership(newOwnerId, targetOrderId);
// Argument 1: cannot convert from 'OrderId' to 'CustomerId'
readonly record struct entrega: value type (sem alocação em heap), igualdade estrutural (== e != por valor), IEquatable<T> implícito, ToString() legível, e suporte a pattern matching. O compilador rejeita a troca antes do código chegar perto do banco.
Em propriedades de entidade, o tipo fortemente tipado substitui o Guid cru em todo lugar:
public sealed class Order : Entity<OrderId>
{
public CustomerId CustomerId { get; private init; } // não Guid
public TenantId TenantId { get; private init; } // não Guid
}
BaseEntity: o que entra, o que sai
Toda entidade tem identidade, e concentrar essa identidade numa classe base faz sentido. O problema começa na pergunta seguinte: "já que existe uma base, por que não colocar nela os campos de auditoria?". Depois vem o soft delete, depois o Version e o TenantId. A cada rodada a base cresce, e cada entidade do sistema passa a carregar campos que não usa. É assim que nasce o God Object (classe que acumula responsabilidade demais).
A regra que funciona é mínima:
- Entra na base:
id. Único campo que toda entidade precisa, motivo claro, sem ambiguidade. - Sai da base: campos de auditoria (
CreatedAt,UpdatedAt,CreatedBy,UpdatedBy). Vão por interface (IAuditable) ou por composição (AuditInfocomo propriedade). - Caso à parte:
tenantId. Só faz sentido na aggregate root, nunca em entidade filha do agregado. Detalhes em Multitenancy.
❌ Ruim: Entity base inchada, todo mundo herda tudo
public abstract class Entity
{
public Guid Id { get; protected init; }
public DateTime CreatedAt { get; protected init; }
public DateTime UpdatedAt { get; protected set; }
public DateTime? DeletedAt { get; protected set; }
public int Version { get; protected set; }
public Guid TenantId { get; protected init; }
public string? CreatedBy { get; protected init; }
public string? UpdatedBy { get; protected set; }
}
public sealed class OrderItem : Entity
{
public Guid ProductId { get; private init; }
public int Quantity { get; private init; }
// OrderItem carrega TenantId, CreatedBy, Version que não usa nem precisa
}
OrderItem herda oito campos para expor três. O TenantId que ele carrega fica sempre vazio, porque nunca é preenchido nem consultado. Cada campo novo que entra na base passa a existir em toda entidade do sistema.
✅ Bom: Entity mínima genérica + composição para comportamentos extras
public abstract class Entity<TId>
where TId : struct, IEquatable<TId>
{
public TId Id { get; protected init; }
protected Entity(TId id)
{
Id = id;
}
public override bool Equals(object? obj)
{
if (obj is not Entity<TId> other) return false;
if (ReferenceEquals(this, other)) return true;
if (GetType() != other.GetType()) return false;
var isEqual = Id.Equals(other.Id);
return isEqual;
}
public override int GetHashCode() => Id.GetHashCode();
public static bool operator ==(Entity<TId>? left, Entity<TId>? right)
=> Equals(left, right);
public static bool operator !=(Entity<TId>? left, Entity<TId>? right)
=> !Equals(left, right);
}
// auditoria por interface, aplicada onde faz sentido
public interface IAuditable
{
DateTime CreatedAt { get; }
DateTime UpdatedAt { get; }
string? CreatedBy { get; }
string? UpdatedBy { get; }
}
public sealed class Customer : Entity<CustomerId>, IAuditable
{
public string Name { get; private init; }
public string Email { get; private init; }
public DateTime CreatedAt { get; private init; }
public DateTime UpdatedAt { get; private set; }
public string? CreatedBy { get; private init; }
public string? UpdatedBy { get; private set; }
}
public sealed class OrderItem : Entity<OrderItemId>
{
public ProductId ProductId { get; private init; }
public int Quantity { get; private init; }
// sem auditoria: faz parte do agregado Order, não vive sozinho
}
Entity<TId> carrega só o que toda entidade precisa, e o ID já vem tipado. Quem quer auditoria implementa IAuditable. OrderItem não expõe auditoria porque faz parte do agregado Order e não vive sozinho. O Equals compara por ID, conforme a definição de entidade.
A constraint where TId : struct, IEquatable<TId> fecha o tipo: só readonly record struct (e outros value types com igualdade) entram como TId. Isso impede acidentalmente passar string ou Guid cru como parâmetro de tipo.
Propriedade vs lista
A cardinalidade (quantos elementos a relação aceita) vem da regra de negócio. Se o domínio diz "cliente tem um endereço principal", o campo é único, mesmo que o banco guarde o histórico de todos os endereços já usados. Se o domínio diz "cliente pode ter vários telefones", a propriedade é lista, mesmo quando 90% dos clientes cadastram apenas um.
A tabela abaixo é a tradução direta de cada regra de cardinalidade para tipos C#:
| Regra de negócio | Modelo | Exemplo |
|---|---|---|
| Sempre exatamente um | Campo obrigatório | string Name, Money Total |
| Zero ou um | Campo T? | TaxInfo? TaxInfo (só PJ tem) |
| Zero ou mais | IReadOnlyList<T> (vazio, nunca null) | IReadOnlyList<OrderItem> Items |
| Exatamente N (N fixo) | N campos nomeados | Address.{Street, City, Country} |
Em C#, listas públicas usam IReadOnlyList<T> para impedir que callers façam Add direto. A escrita interna passa por um List<T> private, e o método de domínio é a única forma de alterar.
❌ Ruim: três campos numerados forçando uma lista mascarada
public sealed class Customer : Entity<CustomerId>
{
public string? Phone1 { get; private set; }
public string? Phone2 { get; private set; }
public string? Phone3 { get; private set; }
public void AddPhone(string number)
{
if (Phone1 is null) { Phone1 = number; return; }
if (Phone2 is null) { Phone2 = number; return; }
if (Phone3 is null) { Phone3 = number; return; }
throw new DomainException("Customer accepts at most 3 phones.");
}
}
A regra "cliente tem até três telefones" ficou gravada no formato da classe. Para aceitar um quarto telefone é preciso criar Phone4, mexer na tabela e revisar todo código que lê os campos, quando bastaria alterar um número dentro de um método. Percorrer os telefones também exige três if separados.
✅ Bom: lista interna mutável, exposição via IReadOnlyList
public enum PhoneType { Mobile, Home, Work }
public sealed record Phone(string Number, PhoneType Type);
public sealed class Customer : Entity<CustomerId>
{
private readonly List<Phone> _phones = [];
public IReadOnlyList<Phone> Phones => _phones;
public void AddPhone(Phone phone)
{
if (_phones.Count >= 3)
{
throw new DomainException("Customer can have at most 3 phones.");
}
_phones.Add(phone);
}
public void RemovePhone(string number)
{
var remaining = _phones.Where(p => p.Number != number).ToList();
_phones.Clear();
_phones.AddRange(remaining);
}
}
A regra "no máximo 3" mora em AddPhone, onde dá para mudar sem mexer no schema. A lista exposta é IReadOnlyList<Phone>: callers iteram à vontade, mas Add não existe na interface. Lista vazia ([]) é o estado neutro: itera sem verificação de nulo.
Listas seguem a regra de null-safety: nunca null, sempre []. Ausência e vazio são equivalentes para quem itera.
Relacionamentos 1:N
Um para muitos é o relacionamento mais comum em todo domínio: Order tem muitos OrderItem, Author tem muitos Book, Customer tem muitos Order. Antes de modelar, vale responder uma pergunta só: quem é o dono.
Quando os filhos não fazem sentido fora do pai (OrderItem sem Order não existe), eles vivem dentro do mesmo agregado. A aggregate root orquestra a vida dos filhos: cria, valida, remove. O acesso a um filho específico passa pelo root, nunca direto. Em código, a root é a única classe exposta do agregado, e o construtor da entidade filha pode ser internal ou private para que só o root produza instâncias.
Quando os filhos existem por conta própria (Customer tem muitos Order, mas Order faz sentido sem Customer em memória), cada lado é um agregado separado. A referência entre eles cruza o limite do agregado, então vai por ID (CustomerId), nunca por objeto completo.
❌ Ruim: filho carrega referência ao pai, ciclo bidirecional sem dono
public sealed class Order : Entity<OrderId>
{
private readonly List<OrderItem> _items = [];
public IReadOnlyList<OrderItem> Items => _items;
public Order(OrderId id) : base(id) { }
}
public sealed class OrderItem : Entity<OrderItemId>
{
public Order Order { get; private init; } // referência completa
public ProductId ProductId { get; private init; }
public int Quantity { get; private init; }
public OrderItem(OrderItemId id, Order order, ProductId productId, int quantity)
: base(id)
{
Order = order;
ProductId = productId;
Quantity = quantity;
}
}
var order = new Order(OrderId.New());
var item = new OrderItem(OrderItemId.New(), order, productId, quantity: 2);
order.Items.Add(item); // IReadOnlyList não tem Add: não compila
// quem valida que Items.Count não passa do limite?
// quem garante que RemoveItem limpa item.Order?
✅ Bom: aggregate root protege as invariantes, filhos sem referência circular
public sealed class OrderItem : Entity<OrderItemId>
{
public ProductId ProductId { get; private init; }
public int Quantity { get; private init; }
public decimal UnitPrice { get; private init; }
internal OrderItem(
OrderItemId id,
ProductId productId,
int quantity,
decimal unitPrice)
: base(id)
{
if (quantity <= 0)
{
throw new DomainException("Quantity must be positive.");
}
ProductId = productId;
Quantity = quantity;
UnitPrice = unitPrice;
}
public decimal Subtotal()
{
var subtotal = UnitPrice * Quantity;
return subtotal;
}
}
public sealed class Order : Entity<OrderId>
{
private readonly List<OrderItem> _items = [];
public CustomerId CustomerId { get; private init; }
public IReadOnlyList<OrderItem> Items => _items;
private Order(OrderId id, CustomerId customerId) : base(id)
{
CustomerId = customerId;
}
public static Order Place(CustomerId customerId)
{
var order = new Order(OrderId.New(), customerId);
return order;
}
public void AddItem(ProductId productId, int quantity, decimal unitPrice)
{
if (_items.Count >= 50)
{
throw new DomainException("Order can have at most 50 items.");
}
var item = new OrderItem(OrderItemId.New(), productId, quantity, unitPrice);
_items.Add(item);
}
public void RemoveItem(OrderItemId itemId)
{
var remaining = _items.Where(i => i.Id != itemId).ToList();
_items.Clear();
_items.AddRange(remaining);
}
public decimal Total()
{
var total = _items.Sum(i => i.Subtotal());
return total;
}
}
Order é o aggregate root: protege o limite de itens, calcula o total, encapsula a criação dos filhos. OrderItem não conhece Order. O construtor de OrderItem é internal, disponível apenas dentro do assembly do domínio. A relação é unidirecional, do dono para os dependentes.
Implicação prática para persistência: o repositório carrega o agregado inteiro (Order + OrderItem[]) em uma única transação. Carregar OrderItem solto, sem o pai, é sinal de modelo errado. Detalhes em platform/database.md.
Relacionamentos N:N
Muitos para muitos sempre cai em uma de duas situações distintas, e identificar qual delas é o seu caso decide a modelagem:
- Associação pura: o aluno está matriculado em cursos, e o domínio não pede nenhuma outra informação sobre essa matrícula. Modelar com lista de IDs em um dos lados (ou nos dois, se o acesso é simétrico).
- Associação com atributos próprios: a matrícula tem data, status, nota final, modalidade. Esses dados não pertencem nem ao aluno nem ao curso. Aqui o relacionamento vira entidade com nome próprio (
Enrollment, matrícula).
A regra de decisão é direta: quando o relacionamento carrega informação que não cabe em nenhum dos dois lados, ele é uma entidade, e merece um nome.
❌ Ruim: N:N com atributos espalhados em listas paralelas
public sealed class Student : Entity<StudentId>
{
private readonly List<CourseId> _courseIds = [];
private readonly List<DateTime> _enrollmentDates = []; // paralelo a _courseIds
public IReadOnlyList<CourseId> CourseIds => _courseIds;
public IReadOnlyList<DateTime> EnrollmentDates => _enrollmentDates;
public DateTime? EnrollmentDateOf(CourseId courseId)
{
var position = _courseIds.IndexOf(courseId);
if (position == -1) return null;
// se uma lista sair de ordem, os dados ficam inconsistentes
var enrolledAt = _enrollmentDates.ElementAtOrDefault(position);
return enrolledAt;
}
}
Duas listas paralelas: se uma sair de ordem ou perder um elemento, os dados ficam inconsistentes. Adicionar nota final, status, modalidade vira mais uma lista paralela. O compilador não detecta o desalinhamento.
✅ Bom: Enrollment como entidade que carrega os atributos do relacionamento
public enum EnrollmentStatus { Active, Completed, Withdrawn }
public sealed class Enrollment : Entity<EnrollmentId>
{
public StudentId StudentId { get; private init; }
public CourseId CourseId { get; private init; }
public DateTime EnrolledAt { get; private init; }
public EnrollmentStatus Status { get; private set; }
public decimal? FinalGrade { get; private set; }
private Enrollment(
EnrollmentId id,
StudentId studentId,
CourseId courseId,
DateTime enrolledAt)
: base(id)
{
StudentId = studentId;
CourseId = courseId;
EnrolledAt = enrolledAt;
Status = EnrollmentStatus.Active;
}
public static Enrollment Open(StudentId studentId, CourseId courseId)
{
var enrollment = new Enrollment(
EnrollmentId.New(),
studentId,
courseId,
DateTime.UtcNow);
return enrollment;
}
public void Complete(decimal grade)
{
if (Status != EnrollmentStatus.Active)
{
throw new DomainException("Only active enrollments can be completed.");
}
if (grade < 0 || grade > 10)
{
throw new DomainException("Grade must be between 0 and 10.");
}
Status = EnrollmentStatus.Completed;
FinalGrade = grade;
}
}
public sealed class Student : Entity<StudentId>
{
public string Name { get; private init; }
private Student(StudentId id, string name) : base(id)
{
Name = name;
}
}
public sealed class Course : Entity<CourseId>
{
public string Title { get; private init; }
private Course(CourseId id, string title) : base(id)
{
Title = title;
}
}
Student não lista cursos diretamente; Course não lista alunos diretamente. O relacionamento mora em Enrollment, que carrega data, status e nota. Consultas como "cursos do aluno X" viram queries sobre Enrollment, não navegação de lista.
Quando o N:N é pura associação (sem atributos), uma tabela intermediária só de IDs basta no banco, e o modelo pode expor IReadOnlyList<CourseId> em qualquer um dos lados. A regra continua: sem inventar entidade quando não há informação para ela carregar.
Identidade vs referência
Dentro do mesmo agregado, guarde o objeto inteiro: Order.Items é uma lista de OrderItem. O agregado é carregado do banco de uma vez e mantido coerente como um bloco só, então os filhos já estão em memória e guardar apenas o ID deles obrigaria a uma segunda ida ao banco para ler o que já veio.
Cruzando o limite de outro agregado, a referência muda de forma: vai por ID. Order referencia Customer por CustomerId, nunca pelo objeto Customer completo. Se carregasse o Customer inteiro, o agregado Order teria que manter esse Customer consistente, e essa já é a responsabilidade do agregado Customer. Com dois lugares responsáveis pela mesma regra, cada um passa a supor que o outro a aplicou.
❌ Ruim: agregado puxa outro agregado por referência direta
public sealed class Order : Entity<OrderId>
{
public Customer Customer { get; private init; } // Customer completo
private readonly List<OrderItem> _items = [];
public Order(OrderId id, Customer customer) : base(id)
{
Customer = customer;
}
}
// para criar Order, preciso buscar Customer inteiro do banco
var customer = await customerRepository.FindByIdAsync(customerId, ct);
if (customer is null) return NotFound();
var order = new Order(OrderId.New(), customer);
// para serializar Order para o frontend, vou junto enviar Customer completo
// mudanças em Customer (email, telefone) podem invalidar o cache de Order
✅ Bom: agregado referencia outro agregado por ID
public sealed class Order : Entity<OrderId>
{
public CustomerId CustomerId { get; private init; } // ID, não Customer
private readonly List<OrderItem> _items = [];
public IReadOnlyList<OrderItem> Items => _items;
private Order(OrderId id, CustomerId customerId) : base(id)
{
CustomerId = customerId;
}
public static Order Place(CustomerId customerId)
{
var order = new Order(OrderId.New(), customerId);
return order;
}
}
var order = Order.Place(customerId);
// quem precisa do Customer resolve o ID no momento certo
var customer = await customerRepository.FindByIdAsync(order.CustomerId, ct);
Order carrega só a referência. Quem precisa do Customer resolve o ID no momento certo. Buscar um pedido deixa de trazer junto o cliente, o endereço dele e tudo o que estiver pendurado ali.
Quando o status precisa carregar dados além do nome (data de pagamento, código de rastreio, motivo do cancelamento), vale usar uma hierarquia fechada de records em vez de enum simples. O pattern matching com is faz narrowing (estreitamento do tipo) automático:
✅ Bom: hierarquia de records quando o estado carrega dados
public abstract record OrderState
{
public sealed record Pending : OrderState;
public sealed record Settled(DateTime SettledAt) : OrderState;
public sealed record Shipped(DateTime SettledAt, string TrackingCode) : OrderState;
public sealed record Cancelled(DateTime CancelledAt, string Reason) : OrderState;
}
public string Summarize(OrderState state)
{
if (state is OrderState.Settled settled)
{
var summary = $"Settled at {settled.SettledAt:O}";
return summary;
}
if (state is OrderState.Shipped shipped)
{
var summary = $"Shipped, tracking {shipped.TrackingCode}";
return summary;
}
if (state is OrderState.Cancelled cancelled)
{
var summary = $"Cancelled: {cancelled.Reason}";
return summary;
}
var pending = "Pending";
return pending;
}
Para o estado simples, sem dados associados, um enum OrderStatus em um único campo resolve. A hierarquia de records compensa quando cada estado carrega informação própria.
Multitenancy
Em sistema multitenant, cada cliente (o tenant, ou inquilino) ocupa um espaço de dados isolado dentro da mesma aplicação. A regra crítica é uma: dado de um tenant nunca pode vazar para outro, seja em consulta, log, exportação, cache ou métrica.
A pergunta operacional é onde colocar o TenantId. A resposta varia conforme o papel do objeto:
- Na aggregate root: sim.
Order.TenantId,Customer.TenantId. É o que permite o repositório aplicar o filtro automaticamente. - Em entidade filha do agregado: não.
OrderItemnão precisa carregarTenantId, porque o pai (Order) já carrega, e a consulta passa sempre pelo pai. - Em value object: não.
Address,Moneynão pertencem a nenhum tenant em particular; são valores reutilizáveis.
O isolamento mora fora da entidade: no repositório, em middleware, ou na própria camada do banco com row-level security. A entidade só guarda o campo; quem aplica o filtro é a infraestrutura.
❌ Ruim: TenantId duplicado em toda entidade filha, esperando que o domínio aplique o filtro
public sealed class Order : Entity<OrderId>
{
public TenantId TenantId { get; private init; }
public CustomerId CustomerId { get; private init; }
private readonly List<OrderItem> _items = [];
}
public sealed class OrderItem : Entity<OrderItemId>
{
public TenantId TenantId { get; private init; } // duplica o TenantId do Order
public ProductId ProductId { get; private init; }
public int Quantity { get; private init; }
}
// e agora cada serviço precisa checar TenantId em toda operação:
public decimal CalculateOrderTotal(Order order, TenantId activeTenant)
{
if (order.TenantId != activeTenant)
{
throw new ForbiddenException("Tenant mismatch on order.");
}
foreach (var item in order.Items)
{
if (item.TenantId != activeTenant)
{
throw new ForbiddenException("Tenant mismatch on item.");
}
}
var total = order.Total();
return total;
}
✅ Bom: TenantId só no aggregate root, filtro aplicado no repositório
public sealed class Order : Entity<OrderId>
{
public TenantId TenantId { get; private init; } // único campo de tenant no agregado
public CustomerId CustomerId { get; private init; }
private readonly List<OrderItem> _items = [];
public IReadOnlyList<OrderItem> Items => _items;
}
public sealed class OrderItem : Entity<OrderItemId>
{
public ProductId ProductId { get; private init; }
public int Quantity { get; private init; }
// sem TenantId: herdado implicitamente pelo pai
}
public interface ITenantContext
{
TenantId Current();
}
public sealed class OrderRepository(AppDbContext db, ITenantContext tenantContext)
: IOrderRepository
{
public async Task<Order?> FindByIdAsync(OrderId orderId, CancellationToken ct)
{
var activeTenant = tenantContext.Current();
var order = await db.Orders
.Where(o => o.Id == orderId && o.TenantId == activeTenant)
.Include(o => o.Items)
.FirstOrDefaultAsync(ct);
return order;
}
}
A entidade não conhece o conceito de tenant ativo. O repositório injeta o filtro. Se alguém esquecer um filtro, o erro fica concentrado no repositório, não espalhado.
Para reforço extra, ativar row-level security no banco (PostgreSQL, SQL Server) garante o isolamento mesmo quando a aplicação falha em aplicar o filtro. Detalhes em platform/database.md.
Anti-patterns
Os padrões abaixo aparecem com frequência em código C# real, e cada um sinaliza que a modelagem merece uma segunda olhada. Quando um deles surgir na revisão, vale corrigir enquanto o custo é o de mexer em uma classe, antes que outros módulos passem a depender do formato errado.
God Entity. Entidade com 20+ propriedades misturando conceitos. Sintoma: o nome da classe vira lista (UserAccountWithPreferencesAndBilling). Tratamento: extrair value objects (sealed record) ou separar em agregados.
BaseEntity inchada. Classe base carregando audit + soft delete + multitenancy + versionamento, forçando filhos a herdar tudo. Sintoma: OrderItem carrega TenantId que nunca usa. Tratamento: deixar só TId Id na base genérica; demais campos viram interface (IAuditable) ou composição.
Campos opcionais por design ruim. Entidade com 8 dos 20 campos sempre null. Sintoma: caller obrigado a ?. em cada acesso. Tratamento: extrair os opcionais em value object nullable inteiro, ou separar em entidades distintas se a presença/ausência indica conceitos diferentes.
Lista mascarada como N campos. Phone1, Phone2, Phone3 quando o domínio diz "muitos telefones". Sintoma: lógica para "pegar o próximo slot vazio". Tratamento: IReadOnlyList<Phone> exposta, lista private List<Phone> interna.
Guid cru como ID. public Guid CustomerId em vez de public CustomerId CustomerId. Sintoma: bug onde OrderId foi passado no lugar de CustomerId e o compilador aceitou. Tratamento: readonly record struct por ID, factory From(Guid) com validação.
Referência direta cruzando agregado. Order.Customer (tipo Customer) em vez de Order.CustomerId. Sintoma: para carregar um pedido, o ORM puxa cinco tabelas via Include. Tratamento: referência por ID tipado; quem precisa do objeto resolve no momento certo.
Setter público em propriedade de domínio. public string Name { get; set; } em entidade de domínio. Sintoma: qualquer caller pode alterar o estado sem passar pela invariante. Tratamento: private set ou private init; alteração só via método de domínio.
Bidirecionalidade automática. Order.Items e OrderItem.Order mantidos sincronizados manualmente. Sintoma: bug onde lado A foi atualizado mas lado B ficou desatualizado. Tratamento: relação unidirecional do aggregate root para os filhos; OrderItem não conhece Order.
Referências
Cross-links dentro do guia:
../../../shared/architecture/entity-modeling.md: canônico transversal../../../shared/architecture/transactions.md: boundary transacional, Unit of Work../../../shared/architecture/domain-events.md: naming, outbox, eventual consistency./null-safety.md: null-safety idiomático C#
Bibliografia externa (livros, artigos, especificações): REFERENCES.md.
DoDocs v3.7.0 · Desenvolvido por @thiagocajadev · Baseado no trabalho de pmndrs/docs · Poimandres.