@maiddev/domain-composition-root
v1.0.3
Published
Type-safe DDD composition root for TypeScript
Readme
@maiddev/domain-composition-root
Um Composition Root tipado para organizar e expor o domínio da sua aplicação como uma estrutura navegável.
🧠 Por que esse package existe?
Em aplicações grandes, o domínio rapidamente deixa de ser apenas um conjunto de classes.
Você começa com:
Aluno
├── LancarNotaCommand
├── BuscarAlunoQuery
└── ...Depois aparecem dezenas ou centenas de:
- Aggregates
- Commands
- Queries
- Repositories
- Services
- Policies
- Use Cases
- Eventos
E surge uma pergunta simples:
Onde está cada coisa do meu domínio?
Normalmente, a resposta está espalhada por imports, pastas e arquivos.
Este package propõe uma abordagem diferente:
O domínio também pode ser um objeto.
Por exemplo:
domain.context.aggregates.alunos.commands.lancarNota
domain.context.aggregates.alunos.queries.buscarPorId
domain.context.queries.buscarAlunoPorIdOu seja, o Composition Root passa a representar explicitamente a arquitetura do sistema.
🎯 O objetivo
O objetivo é criar um mapa tipado do domínio da aplicação.
Você registra seus componentes:
const domain = new DomainCompositionRoot({
aggregates: {
alunos: {
commands: {
lancarNota: new LancarNotaCommand(repository),
},
},
},
queries: {
buscarAlunoPorId: new BuscarAlunoPorIdQuery(repository),
},
});E passa a ter acesso a eles através de uma estrutura organizada:
domain.context.aggregates.alunos.commands.lancarNotaou:
domain.context.queries.buscarAlunoPorIdO TypeScript conhece essa estrutura.
Isso significa que você ganha:
- autocomplete;
- navegação pelo domínio;
- segurança de tipos;
- composição explícita;
- um único ponto de entrada para o domínio;
- uma representação estrutural da arquitetura.
📦 Instalação
npm install @maiddev/domain-composition-root🧠 Conceitos básicos
| Peça | O que é | Contrato |
|---|---|---|
| CommandInterface | Uma ação que altera algo (ex: lançar uma nota) | execute(input): output |
| QueryInterface | Uma ação que só lê algo (ex: buscar um aluno) | run(input): output |
| AggregateInterface | Um agrupamento de comandos e queries de um mesmo assunto (ex: alunos) | { commands?, queries? } |
| DomainContext | O "mapa" geral do seu domínio, com todos os agregados e queries soltas | { aggregates?, queries? } |
| DomainCompositionRoot | A classe que junta tudo isso e te dá o domain.context pra usar | — |
🚀 Uso básico
1. Crie o contrato do seu repositório
// alunos.repository.contract.ts
export interface AlunosRepositoryContract {
lancarNota(input: { alunoId: string; periodo: string; disciplinaId: string; valor: number }): void;
buscarPorId(input: { alunoId: string }): any;
}2. Implemente o repositório de verdade
// alunos.repository.postgres.ts
import type { AlunosRepositoryContract } from "./alunos.repository.contract.js";
export class AlunosRepositoryPostgres implements AlunosRepositoryContract {
lancarNota(input: { alunoId: string; periodo: string; disciplinaId: string; valor: number }): void {
// sua lógica de banco aqui
}
buscarPorId(input: { alunoId: string }) {
// sua lógica de banco aqui
}
}3. Crie seus comandos e queries
// lancar-nota.command.ts
import { z } from "zod";
import type { CommandInterface } from "@maiddev/domain-composition-root";
import type { AlunosRepositoryContract } from "../alunos.repository.contract.js";
const inputSchema = z.object({
alunoId: z.string(),
periodo: z.string(),
disciplinaId: z.string(),
valor: z.number(),
});
export class LancarNotaCommand implements CommandInterface {
constructor(private readonly alunosRepository: AlunosRepositoryContract) {}
execute(input: z.output<typeof inputSchema>) {
const parsedInput = inputSchema.parse(input);
this.alunosRepository.lancarNota(parsedInput);
return { sucesso: true };
}
}// buscar-aluno.query.ts
import { z } from "zod";
import type { QueryInterface } from "@maiddev/domain-composition-root";
import type { AlunosRepositoryContract } from "../alunos.repository.contract.js";
const inputSchema = z.object({ alunoId: z.string() });
export class BuscarAlunoPorIdQuery implements QueryInterface {
constructor(private readonly alunosRepository: AlunosRepositoryContract) {}
run(input: z.output<typeof inputSchema>) {
const parsedInput = inputSchema.parse(input);
return this.alunosRepository.buscarPorId(parsedInput);
}
}4. Monte o seu domínio 🎉
import { DomainCompositionRoot } from "@maiddev/domain-composition-root";
import { AlunosRepositoryPostgres } from "./entities/aluno/alunos.repository.postgres.js";
import { LancarNotaCommand } from "./entities/aluno/commands/lancar-nota.command.js";
import { BuscarAlunoPorIdQuery } from "./entities/aluno/queries/buscar-aluno.query.js";
const alunosRepository = new AlunosRepositoryPostgres();
const domain = new DomainCompositionRoot({
aggregates: {
alunos: {
commands: {
lancarNota: new LancarNotaCommand(alunosRepository),
},
queries: {
buscarAlunoPorId: new BuscarAlunoPorIdQuery(alunosRepository),
},
},
},
});
// E é só usar, com autocomplete e tipos corretos:
domain.context.aggregates.alunos.commands.lancarNota.execute({
alunoId: "akj27h4",
disciplinaId: "dsp-098",
periodo: "Semestre 2",
valor: 9.9,
});
domain.context.aggregates.alunos.queries.buscarAlunoPorId.run({
alunoId: "akj27h4",
});🔎 Queries dentro de um agregado
Além de comandos, um agregado também pode ter suas próprias queries. Basta declarar a chave queries junto de commands na hora de registrar o agregado:
const domain = new DomainCompositionRoot({
aggregates: {
alunos: {
commands: {
lancarNota: new LancarNotaCommand(alunosRepository),
},
queries: {
buscarPorId: new BuscarAlunoPorIdQuery(alunosRepository),
},
},
},
});
// A query fica "dentro" do agregado, junto com os comandos:
domain.context.aggregates.alunos.queries.buscarPorId.run({
alunoId: "akj27h4",
});Repare que isso é diferente das queries globais (seção abaixo): aqui a query pertence diretamente ao agregado alunos, então ela fica acessível em aggregates.alunos.queries, e não em context.queries.
Você pode inclusive ter os dois ao mesmo tempo — uma query específica do agregado e uma query global equivalente, caso queira expor a mesma busca de duas formas diferentes:
domain.context.aggregates.alunos.queries.buscarPorId.run({ alunoId: "akj27h4" }); // pelo agregado
domain.context.queries.buscarAlunoPorId.run({ alunoId: "akj27h4" }); // globalmente💡 Diferente de
register_query(que só existe para o contexto raiz), hoje as queries de um agregado só podem ser definidas na hora da criação doDomainCompositionRoot— não existe ainda umregister_queryequivalente por agregado.
🔗 Registrando queries globais
Nem toda query precisa pertencer a um agregado. Para queries "globais" (fora de qualquer aggregate), use register_query.
⚠️ Atenção: register_query não muda o objeto original — ele retorna uma nova instância de DomainCompositionRoot com o tipo já atualizado. Por isso, a chamada precisa ser encadeada direto na criação do DomainCompositionRoot. Reatribuir o resultado a uma variável (domain = domain.register_query(...)) não resolve — veja o motivo logo abaixo.
// ❌ Não funciona — o domain original não é alterado
let domain = new DomainCompositionRoot({
aggregates: {
alunos: {
commands: {
lancarNota: new LancarNotaCommand(container.repositories.alunosRepositoryPostgres),
},
}
},
queries: {
buscarAlunoPorId: new BuscarAlunoPorIdQuery(container.repositories.alunosRepositoryPostgres)
}
});
domain.register_query({
buscarAlunoPorId: new BuscarAlunoPorIdQuery(container.repositories.alunosRepositoryPostgres)
});
// domain.context.queries continua sem "buscarAlunoPorId" 😕// ✅ Funciona — encadeando a chamada direto na criação
let domain = new DomainCompositionRoot({
aggregates: {
alunos: {
commands: {
lancarNota: new LancarNotaCommand(container.repositories.alunosRepositoryPostgres),
},
}
},
queries: {
buscarAlunoPorId: new BuscarAlunoPorIdQuery(container.repositories.alunosRepositoryPostgres)
}
})
.register_query({
buscarAlunoPorId: new BuscarAlunoPorIdQuery(container.repositories.alunosRepositoryPostgres)
});
domain.context.queries.buscarAlunoPorId.run({ alunoId: "akj27h4" });⚠️ Reatribuir à mesma variável também não funciona (por motivos que eu ainda não faço ideia):
// ❌ Também não funciona
domain = domain.register_query({
buscarAlunoPorId: new BuscarAlunoPorIdQuery(alunosRepository),
});
domain.context.queries.buscarAlunoPorId.run({ alunoId: "akj27h4" });
// Erro de tipo: o TypeScript continua enxergando "domain" com o tipo
// que foi inferido lá na declaração original (sem o "queries"),
// mesmo que o valor reatribuído já tenha o formato certo em runtime.Por enquanto, a forma segura é sempre encadear a chamada de register_query direto na criação do DomainCompositionRoot, como no exemplo "✅ Funciona" acima. O TypeScript já sabe automaticamente que queries.buscarAlunoPorId existe na nova instância — sem precisar declarar tipo nenhum manualmente. ✨
🛠️ Boas práticas recomendadas
- Use zod (ou similar) para validar
input/outputdentro de cada comando e query; - Mantenha os repositórios atrás de contratos (
interface), assim você troca Postgres por Mongo, memória, etc, sem tocar no domínio; - Um comando faz uma coisa só e retorna algo simples (ex:
{ sucesso: boolean }); - Uma query nunca deve alterar dados, só ler;
- Como
register_query(e futuros métodos parecidos) retornam uma nova instância, sempre encadeie as chamadas na hora da criação do domínio — reatribuir a variável não preserva o tipo corretamente.
📄 Licença
MIT — use, modifique e distribua à vontade.
