⚠️ Projeto didático. Este repositório é uma prova de conceito, criada para demonstrar o padrão Entity-Attribute-Value aplicado com rigor relacional: um usuário define, em tempo de execução, quais propriedades cada tipo de registro possui — e o banco continua garantindo integridade, tipagem e capacidade de consulta, sem gerar uma única linha de DDL. Ele não é uma referência pronta para produção — várias simplificações foram feitas de propósito para manter o foco no aprendizado (ver Limitações).
O domínio é a agenda de um agente de crédito. Cada Compromisso pertence a um TipoCompromisso, e cada tipo tem um conjunto próprio de propriedades — Visita Técnica pede valor solicitado e data da visita, Renegociação pede saldo devedor e quantidade de parcelas, Análise Documental pede protocolo e prazo de retorno. O usuário cria tipos e propriedades pela API, e a aplicação passa a aceitar, validar e devolver esses campos imediatamente.
A crítica mais comum e mais justa ao EAV é que a coerência dos dados passa a depender inteiramente da aplicação. Este projeto existe para mostrar que não precisa ser assim: uma chave estrangeira composta faz o próprio banco recusar gravar, em um compromisso do tipo A, o valor de uma propriedade do tipo B.
METAMODELO (o usuário define)
┌──────────────────────────────────────────────────────────┐
│ POST /api/tipos-de-compromisso │
│ POST /api/tipos-de-compromisso/{id}/propriedades │
└───────────────────────────┬──────────────────────────────┘
▼
TipoCompromisso ──1:N──> PropriedadeTipo
│ │
│ │ Chave · Rotulo · TipoDado
│ │ Obrigatoria · Ordem · Ativa
▼ ▼
DADOS (o usuário preenche)
┌──────────────────────────────────────────────────────────┐
│ POST /api/compromissos { chave, valor } │
└───────────────────────────┬──────────────────────────────┘
▼
Compromisso ──1:N──> CompromissoValor <──1:N── PropriedadeTipo
│
ValorTexto · ValorNumero · ValorData · ValorBool
(exatamente uma preenchida — CHECK do banco)
As duas FKs de CompromissoValor compartilham TipoCompromissoId:
FK (CompromissoId, TipoCompromissoId) → Compromisso (Id, TipoCompromissoId)
FK (PropriedadeId, TipoCompromissoId) → PropriedadeTipo (Id, TipoCompromissoId)
Não existe valor de TipoCompromissoId que faça uma linha incoerente passar.
- O usuário cria um tipo e suas propriedades — a
Chaveé gerada pelo sistema a partir do rótulo, normalizada e imutável. - A tela consulta o metamodelo (
GET /api/tipos-de-compromisso/{id}) e monta o formulário. Nenhuma coluna física é conhecida em tempo de compilação. - Ao gravar um compromisso, a aplicação lê o
TipoDadodeclarado e decide em qual coluna tipada o valor entra. É a única responsabilidade que o banco não consegue assumir sozinho — porque oTipoDadomora em outra tabela. - Ao ler, a varredura parte de
PropriedadeTipo(não deCompromissoValor), então propriedades ainda não preenchidas aparecem vazias em vez de sumir do formulário.
A modelagem completa que embasa o desenho está em Documentação complementar.
| Padrão / mecanismo | Onde | O que resolve |
|---|---|---|
| Metamodelo separado dos dados | TipoCompromisso + PropriedadeTipo vs. Compromisso + CompromissoValor |
A definição é dado, não schema. Criar um tipo novo é um INSERT, nunca um CREATE TABLE. |
| FK composta (coerência de tipo) | CompromissoValor carrega TipoCompromissoId de forma redundante |
O banco recusa gravar, em um compromisso do tipo A, o valor de uma propriedade do tipo B. É a resposta à crítica clássica ao EAV. |
| Colunas de valor tipadas | ValorTexto · ValorNumero · ValorData · ValorBool |
'92500' > '180000' é verdadeiro numa coluna VARCHAR única. Com colunas tipadas, filtro e ordenação funcionam de verdade. |
| CHECK de valor exclusivo | CK_Valor_Exclusivo |
Exatamente uma coluna de valor preenchida por linha — nem duas, nem nenhuma. |
| PK composta | PK (CompromissoId, PropriedadeId) |
Uma propriedade não pode ser preenchida duas vezes no mesmo compromisso. |
| Ausência de linha = não preenchido | DadosIniciaisSeed e ServicoDeCompromissos |
Nunca se grava uma linha com todas as colunas nulas. É isso que mantém o relatório de pendências correto. |
| Chave estável e imutável | GeradorDeChaveDePropriedade |
Rotulo é livre e o usuário renomeia à vontade; Chave é gerada, normalizada e nunca muda. Ela também é a fronteira contra injeção em pivots dinâmicos. |
| Soft delete no metamodelo | Ativo / Ativa |
Excluir propriedade é Ativa = 0: some do formulário, mas continua visível nos compromissos que já têm valor gravado. Histórico preservado. |
| Troca de tipo de dado bloqueada | ServicoDePropriedadesDoTipo.AlterarTipoDeDadoAsync |
Se já existe valor gravado, a troca é recusada. A saída correta é criar propriedade nova e inativar a anterior. |
| Obrigatoriedade em runtime | PropriedadesObrigatoriasNaoPreenchidas |
Obrigatório não é NOT NULL: a regra é ligada depois que já existem registros, então quem valida é a aplicação — e os antigos continuam válidos. |
| Índices filtrados por tipo de valor | IX_Valor_Numero · IX_Valor_Data |
É o que viabiliza filtro e ordenação reais sobre propriedades dinâmicas — o ganho de ter colunas tipadas em vez de uma coluna textual. |
| Validação agregada | ExcecaoDeRequisicaoInvalida |
Todos os problemas do payload voltam de uma vez em inconsistencias, não um por requisição. |
| Mapeamento de chaves alternativas no EF | ContextoDeBancoDeDados |
HasAlternateKey + HasPrincipalKey com par de colunas: a FK composta do modelo existe também no ORM. |
| Esquema criado fora da aplicação | docker/inicializacao/01-criar-esquema.sql |
Sem migrations, de propósito: o banco é a fonte da verdade e o DDL fica legível ao lado do material didático. |
| Seed idempotente no start | Infraestrutura/Seeds/DadosIniciaisSeed |
Carga de demonstração em transação única, que não duplica em reinícios. |
| # | Projeto | Tipo | Responsabilidade |
|---|---|---|---|
| 1 | demo-eav.api |
ASP.NET Core 10 (REST) | Projeto único e monolítico, de propósito. Dominio, Infraestrutura (EF Core + seeds), Aplicacao (requisições, respostas, serviços) e Controllers. |
| — | SQL Server 2022 | Infra (container) | Banco DemonstracaoPadraoEav, com as quatro tabelas do modelo EAV. |
| — | mssql-tools | Infra (container efêmero) | Espera o banco subir, cria o esquema via sqlcmd e encerra. |
| — | DBGate | Infra (container) | Cliente web para inspecionar os dados, já com a conexão registrada. |
Quatro tabelas, divididas em duas camadas:
| Camada | Tabela | Papel |
|---|---|---|
| Metamodelo | TipoCompromisso |
O tipo que o usuário cadastra (Visita Técnica, Renegociação…). |
| Metamodelo | PropriedadeTipo |
As propriedades de cada tipo: chave, rótulo, tipo de dado, obrigatoriedade, ordem. |
| Dados | Compromisso |
A instância, com as propriedades fixas DataHora e Descricao. |
| Dados | CompromissoValor |
A tabela associativa que materializa o E-A-V. |
Entity → CompromissoId
Attribute → PropriedadeId
Value → ValorTexto / ValorNumero / ValorData / ValorBool
Tipos de dado suportados: TEXTO, INTEIRO, DECIMAL, DATA, BOOL — expostos na API como Texto, Inteiro, Decimal, Data, Booleano.
O detalhamento completo — cardinalidades, restrições de integridade, regras de evolução do metamodelo e índices sugeridos — está em anexos/modelagem.md.
| Verbo | Rota | O que faz |
|---|---|---|
| GET | /api/tipos-de-compromisso?incluirInativos= |
Lista com contagem de propriedades e de compromissos vinculados |
| GET | /api/tipos-de-compromisso/{id}?incluirPropriedadesInativas= |
O tipo com suas propriedades — é a consulta que monta o formulário |
| POST | /api/tipos-de-compromisso |
Cria o tipo |
| PUT | /api/tipos-de-compromisso/{id}/nome |
Renomeia |
| POST | /api/tipos-de-compromisso/{id}/inativacao · /reativacao |
Soft delete e volta |
| DELETE | /api/tipos-de-compromisso/{id} |
Exclusão física — bloqueada se houver compromissos vinculados |
| Verbo | Rota | O que faz |
|---|---|---|
| GET | /api/tipos-de-compromisso/{tipo}/propriedades?incluirInativas= |
Lista, com a contagem de valores já gravados |
| POST | /api/tipos-de-compromisso/{tipo}/propriedades |
Cria — a Chave é gerada a partir do rótulo |
| PUT | .../{propriedade}/rotulo |
Renomeia a exibição; a chave permanece |
| PUT | .../{propriedade}/obrigatoriedade · /ordem |
Ajusta a regra e a posição no formulário |
| PUT | .../{propriedade}/tipo-de-dado |
Bloqueado se já existir valor gravado |
| POST | .../{propriedade}/inativacao · /reativacao |
Soft delete e volta |
| Verbo | Rota | O que faz |
|---|---|---|
| GET | /api/compromissos/{id} |
O compromisso completo em um único JSON |
| POST | /api/compromissos |
Grava o compromisso e seus valores tipados |
Corpo da gravação:
{
"identificadorDoTipoCompromisso": "A0000000-0000-0000-0000-000000000001",
"dataHora": "2026-09-02T09:30:00",
"descricao": "Visita — Serralheria Praia de Iracema",
"valores": [
{ "chave": "VALOR_SOLICITADO", "valor": "62500,90" },
{ "chave": "DATA_VISITA", "valor": "02/09/2026" },
{ "chave": "POSSUI_GARANTIA", "valor": "sim" }
]
}Cada valor identifica a propriedade por chave ou por identificadorDaPropriedade, e manda o conteúdo como texto — é a aplicação que escolhe a coluna do EAV, a partir do TipoDado declarado no metamodelo. Formatos aceitos: decimal com vírgula ou ponto; data em aaaa-MM-dd ou dd/MM/aaaa; booleano em true, false, 1, 0, sim, nao.
Validações da aplicação, todas devolvidas juntas em inconsistencias (HTTP 400):
| Situação | Por que é da aplicação, e não do banco |
|---|---|
| Propriedade não pertence ao tipo | O banco também bloqueia, pela FK composta — aqui a mensagem é legível |
Valor incompatível com o TipoDado |
O CHECK garante uma coluna preenchida, não qual |
| Propriedade obrigatória não preenchida | Obrigatoriedade é definida em runtime, não é NOT NULL |
| Propriedade repetida no mesmo payload | A PK composta também bloqueia |
| Propriedade inativa recebendo valor novo | Soft delete preserva histórico, mas não aceita gravação nova |
Códigos de retorno: 400 requisição inconsistente · 404 recurso não encontrado · 409 regra de negócio violada.
Na raiz do repositório:
docker compose up -d --buildO projeto roda exclusivamente via Docker Compose. Não há caminho de execução local.
Isso sobe, nesta ordem: SQL Server, o inicializador (que cria o banco e o esquema e encerra), o DBGate e a API — que só inicia depois que o inicializador termina com sucesso, garantido por depends_on: condition: service_completed_successfully. Sem migrations, essa ordem não é detalhe: o seed do startup precisa de um esquema pronto.
Endereços padrão:
| Serviço | URL / Porta | Credenciais |
|---|---|---|
| API | http://localhost:8080 | — |
| API (OpenAPI) | http://localhost:8080/openapi/v1.json | — |
| DBGate | http://localhost:8090 | conexão já registrada |
| SQL Server | localhost:1433 |
sa / SenhaForte@2026 (db DemonstracaoPadraoEav) |
Senhas, nome do banco e portas ficam em .env.
Para parar:
docker compose downOs volumes
dados-do-sql-serveredados-do-dbgatepersistem entre reinícios. Para começar do zero (banco vazio, esquema e seed reaplicados):docker compose down -v.
| Etapa | Quem faz |
|---|---|
CREATE DATABASE, tabelas, constraints e índices |
docker/inicializacao/01-criar-esquema.sql, via sqlcmd |
| Carga do metamodelo e dos compromissos | A própria aplicação, no startup (DadosIniciaisSeed) |
| Consultas de leitura, filtro, validação e integridade | Você, no DBGate — seções 5 a 10 de anexos/script.sql |
O 01-criar-esquema.sql é idempotente (IF OBJECT_ID(...) IS NULL), então docker compose up repetido não derruba nada.
O DadosIniciaisSeed reproduz exatamente os INSERT das seções 3 e 4 do script.sql — mesmos identificadores, mesmos valores.
| Situação | Resultado |
|---|---|
| Banco inacessível | Registra aviso no log e a aplicação sobe sem gravar |
| Tabelas do modelo inexistentes | Registra aviso pedindo a execução do script e não grava |
Já existe qualquer TipoCompromisso |
Não grava nada (idempotente) |
| Banco vazio | Grava em transação única; qualquer falha desfaz tudo |
"Seeds": {
"Habilitada": true,
"IncluirCompromissosDeDemonstracao": true
}IncluirCompromissosDeDemonstracao = false carrega só o metamodelo, deixando Compromisso e CompromissoValor vazios — útil para criar os compromissos ao vivo pela API.
O seed preserva de propósito uma lacuna: o compromisso Marcenaria Beira-Mar fica sem a propriedade obrigatória
DATA_VISITA. É ela que dá o que mostrar no relatório de pendências.
O arquivo está em anexos/colecao-insomnia.json — 43 requests em 4 pastas, com o environment Ambiente Local já configurado (urlBase e os identificadores do seed).
-
No Insomnia: Application → Preferences → Data → Import Data → From File.
-
Selecione o environment Ambiente Local.
-
As pastas 1 a 3 funcionam isoladas, contra os dados do seed. A pasta 4 é a trilha guiada.
- 1 — Tipos de Compromisso: CRUD do metamodelo, incluindo a exclusão física recusada (409).
- 2 — Propriedades do Tipo: renomear rótulo mantendo a chave, alterar obrigatoriedade e ordem, soft delete, e a troca de tipo de dado bloqueada (409).
- 3 — Compromissos: leitura completa, duas gravações e quatro tentativas que devem falhar (400).
- 4 — Fluxo ponta a ponta: 16 requests encadeados por resposta.
A pasta 4 é a da apresentação ao vivo. Rode de cima para baixo:
- 01–04 criam um tipo que não existia e três propriedades de tipos de dado diferentes (
Decimal,Data,Booleano). Repare nas chaves geradas:VALOR_AVALIADO_DO_BEM,DATA_DA_VISTORIA,LAUDO_EMITIDO. - 05 lê o formulário dinâmico do tipo recém-criado. Nenhuma tabela foi criada no caminho.
- 06–07 gravam e releem um compromisso. Confira
colunaDeValorUtilizada:ValorNumero,ValorDataeValorBool, cada um na coluna certa. - 08–10 provam os três bloqueios de gravação: obrigatória faltando, valor incompatível com o tipo, propriedade de outro tipo.
- 11 renomeia um rótulo e 12 tenta trocar o tipo de dado de uma propriedade que já tem valor — recusado.
- 13–15 inativam uma propriedade e releem: ela some do formulário (passo 15) mas continua no compromisso (passo 14), com
propriedadeAtiva: false. É a diferença entre a tela de cadastro e o histórico. - 16 inativa o tipo, fechando o ciclo.
O nome do tipo criado no passo 01 recebe um sufixo aleatório, então o fluxo pode ser repetido quantas vezes você quiser. Os requests encadeados usam a última resposta armazenada, então precisam ser executados em ordem ao menos uma vez.
-- Metamodelo: o que o usuário definiu
SELECT * FROM dbo.TipoCompromisso;
SELECT * FROM dbo.PropriedadeTipo ORDER BY TipoCompromissoId, Ordem;
-- Dados
SELECT * FROM dbo.Compromisso ORDER BY DataHora;
SELECT * FROM dbo.CompromissoValor;
-- Formulário dinâmico: o que a tela renderiza para um tipo
SELECT p.Ordem, p.Chave, p.Rotulo, p.TipoDado, p.Obrigatoria
FROM dbo.PropriedadeTipo p
WHERE p.TipoCompromissoId = 'A0000000-0000-0000-0000-000000000001'
AND p.Ativa = 1
ORDER BY p.Ordem;
-- Filtro numérico com ordenação: o que uma coluna VARCHAR única quebraria
SELECT c.Descricao, ValorSolicitado = v.ValorNumero
FROM dbo.CompromissoValor v
JOIN dbo.Compromisso c ON c.Id = v.CompromissoId
WHERE v.PropriedadeId = 'B0000000-0000-0000-0000-000000000001'
AND v.ValorNumero > 50000
ORDER BY v.ValorNumero DESC;
-- Relatório de pendências: obrigatórias não preenchidas
SELECT c.Descricao, Tipo = t.Nome, PropriedadeVazia = p.Rotulo
FROM dbo.Compromisso c
JOIN dbo.TipoCompromisso t ON t.Id = c.TipoCompromissoId
JOIN dbo.PropriedadeTipo p ON p.TipoCompromissoId = c.TipoCompromissoId
AND p.Obrigatoria = 1 AND p.Ativa = 1
LEFT JOIN dbo.CompromissoValor v ON v.CompromissoId = c.Id AND v.PropriedadeId = p.Id
WHERE v.CompromissoId IS NULL;
-- Auditoria: valor gravado na coluna errada para o TipoDado (deve vir vazio)
SELECT Compromisso = c.Descricao, Propriedade = p.Rotulo, p.TipoDado
FROM dbo.CompromissoValor v
JOIN dbo.PropriedadeTipo p ON p.Id = v.PropriedadeId
JOIN dbo.Compromisso c ON c.Id = v.CompromissoId
WHERE NOT (
(p.TipoDado = 'TEXTO' AND v.ValorTexto IS NOT NULL) OR
(p.TipoDado IN ('DECIMAL','INTEIRO') AND v.ValorNumero IS NOT NULL) OR
(p.TipoDado = 'DATA' AND v.ValorData IS NOT NULL) OR
(p.TipoDado = 'BOOL' AND v.ValorBool IS NOT NULL));As seções 8 a 10 de
anexos/script.sqltrazem as provas de integridade prontas — osINSERTque o banco deve recusar, cada um dentro de umTRY/CATCHque imprime o motivo. É o material mais direto para mostrar a FK composta funcionando.
Base: .NET 10 · C# · ASP.NET Core (controllers) · OpenAPI
Persistência: SQL Server 2022 · EF Core 10 · Fluent API (sem migrations)
Infra local: Docker Compose · DBGate · mssql-tools
Testes manuais: Insomnia (collection versionada) · arquivo .http
Note o que não está aqui: nenhuma biblioteca de mapeamento, de validação ou de mediator. A conversão de valores, a geração de chave e a validação são do próprio projeto — de propósito, para que o mecanismo do EAV fique visível em vez de escondido atrás de um pacote.
| Documento | Conteúdo |
|---|---|
anexos/modelagem.md |
A modelagem: diagrama entidade-relacionamento, cardinalidades, as restrições de integridade que sustentam o modelo, as regras de evolução do metamodelo e os índices sugeridos. |
anexos/script.sql |
O laboratório em SQL puro: DDL, carga, consultas de leitura e pivot dinâmico, filtros tipados, validação, provas de integridade e evolução do metamodelo sem DDL. |
anexos/colecao-insomnia.json |
Collection com 43 requests, incluindo a trilha guiada de 16 passos. |
docker/inicializacao/01-criar-esquema.sql |
O DDL efetivamente aplicado pelo container inicializador. |
O
script.sqle a API são companheiros: o primeiro mostra o modelo em SQL puro, sem intermediários; a segunda mostra o que a aplicação precisa assumir por cima dele. Rodar as duas coisas lado a lado é o que fecha a demonstração.
Estas simplificações são intencionais para focar no aprendizado; em produção você trataria cada uma:
- Segredos em texto claro no
.env, nodocker-compose.ymle noappsettings.json— inclusive a senha dosa, usada também pelo DBGate. - API sem autenticação/autorização, e o DBGate exposto sem senha.
- Sem migrations: o esquema vem de um script SQL aplicado por fora. Uma mudança de tabela precisa entrar em três lugares —
anexos/script.sql,docker/inicializacao/01-criar-esquema.sqle o mapeamento Fluent API. - Escopo da API incompleto: não há listagem paginada de compromissos, edição, exclusão nem o endpoint de pivot tabular. O pivot dinâmico existe só no
script.sql— é o custo real do EAV e vale mostrar em SQL. - Sem testes automatizados. As garantias do modelo são demonstradas manualmente, pela collection e pelas provas de integridade do script.
- Sem paginação, cache ou limite de resultados em nenhuma consulta.
- Sem histórico de alteração de valores:
CompromissoValorguarda o estado atual, não a trilha de quem mudou o quê e quando. - Sem controle de concorrência: não há
rowversionnem verificação otimista; duas gravações simultâneas no mesmo compromisso não conflitam explicitamente. TipoDadoé validado pela aplicação, não por constraint entre tabelas. OCHECKgarante que só uma coluna de valor está preenchida, mas não que seja a coerente com o tipo declarado — a seção 10 doscript.sqldiscute como fechar essa lacuna com coluna computada persistida.- Seed com identificadores fixos, alinhados aos do
script.sql— sem estratégia de geração distribuída.