Padrões de código¶
TLDR: entidade com constructor privado e factory methods, schema Zod como fonte única de verdade, repositório composto de interfaces granulares, handler que verifica permissão antes de agir, Dep/Impl como wrapper de @Inject/@Injectable, scalar semântico em vez de string/number cru. Módulo campus é a referência viva de todos esses padrões.
| Termo | Vá pra |
|---|---|
Constructor privado, create/load/update |
Entidade de domínio |
| A fonte única de verdade da forma dos dados | Schemas Zod |
| Metadado de campo reaproveitado em Swagger e GraphQL | FieldMetadata e QueryFields |
| Interfaces granulares vs. o padrão novo de leitura/escrita | Interfaces de repositório |
| O fluxo padrão de um handler | Command e query handlers |
| "Throw on deny" | Permission checker |
Os wrappers de @Inject/@Injectable |
Dep e Impl |
IdUuid em vez de string |
Scalars semânticos |
Entidade de domínio¶
Constructor privado, factory methods estáticos, validação Zod em cada operação:
// src/modules/ambientes/campus/domain/campus.ts
export class Campus {
static readonly entityName = "Campus";
id!: IdUuid;
nomeFantasia!: string;
// ...
private constructor() {}
static create(dados: unknown): Campus {
const parsed = zodValidate(Campus.entityName, CampusCreateSchema, dados);
const instance = new Campus();
instance.id = generateUuidV7();
instance.nomeFantasia = parsed.nomeFantasia;
// ...
return instance;
}
static load(dados: unknown): Campus { /* reconstrói do banco, valida com schema completo */ }
update(dados: unknown): void {
const parsed = zodValidate(Campus.entityName, CampusUpdateSchema, dados);
if (parsed.nomeFantasia !== undefined) this.nomeFantasia = parsed.nomeFantasia;
// ...
zodValidate(Campus.entityName, CampusSchema, this); // revalida o estado completo
}
}
create() recebe dados brutos (unknown), gera UUID v7 e datas. load() reconstrói uma entidade a partir de dado existente. update() aplica mudança parcial e revalida o estado completo no final, como rede de segurança. Exceção: Estado e Cidade aceitam id no create (usam código IBGE em vez de UUID gerado).
Schemas Zod¶
Cada entidade define seus schemas num arquivo *.schemas.ts, fonte única de verdade da forma dos dados:
export const CampusSchema = EntityBaseSchema.extend({
nomeFantasia: z.string().min(1),
// ...
endereco: z.object({ id: z.string().uuid() }).or(EnderecoFindOneQueryResultSchema),
});
export const CampusCreateSchema = CampusSchema.omit({
id: true, dateCreated: true, dateUpdated: true, dateDeleted: true,
});
export const CampusUpdateSchema = CampusCreateSchema.partial();
EntityBaseSchema fornece id, dateCreated, dateUpdated, dateDeleted. CreateSchema é o schema base sem id e datas (gerados automaticamente). UpdateSchema é CreateSchema.partial(). Exceção: Estado/Cidade, cujo CreateSchema é igual ao schema completo, já que o id vem de fora (código IBGE).
FieldMetadata e QueryFields¶
FieldMetadata define metadado de cada campo uma única vez, reaproveitado em Swagger, GraphQL e validação:
export const CampusFields = {
nomeFantasia: createFieldMetadata({
description: "Nome fantasia do campus",
schema: z.string().min(1, "nomeFantasia é obrigatório"),
}),
cnpj: createFieldMetadata({
description: "CNPJ do campus",
schema: z.string().min(1).transform((v) => v.replace(/\D/g, "")).pipe(z.string().regex(/^\d{14}$/)),
}),
};
.swaggerMetadata alimenta decorators REST, .gqlMetadata alimenta decorators GraphQL, ambos derivados de description, nullable e defaultValue. Regra: gqlMetadata retorna { description, nullable, defaultValue } direto, o DTO nunca especifica isso manualmente. Nunca fazer spread de ...SharedFields, sempre pick explícito ({ page: SharedFields.page, limit: SharedFields.limit }).
Interfaces de repositório¶
Duas convenções coexistem hoje. A mais antiga e mais comum compõe interfaces granulares via intersection type (Interface Segregation Principle):
export const ICampusRepository = Symbol("ICampusRepository");
export type ICampusRepository = IRepositoryFindAll<CampusListQueryResult> &
IRepositoryFindById<CampusFindOneQueryResult> &
IRepositoryCreate<ICampus> &
IRepositoryUpdate<ICampus> &
IRepositorySoftDelete;
Interfaces disponíveis (src/domain/abstractions/repositories/): IRepositoryCreate<T>, IRepositoryUpdate<T>, IRepositorySoftDelete, IRepositoryFindAll<T>, IRepositoryFindById<T>, IRepositoryFindByIdSimple<T>.
Um padrão mais novo, piloto em OfertaFormacao (ver Decisões arquiteturais), separa leitura de escrita de forma mais explícita:
export interface ICampusRepository {
// Write side
loadById: IRepositoryLoadById<Campus>;
save: IRepositorySave<Campus>;
softDeleteById: IRepositorySoftDeleteById;
// Read side
getFindOneQueryResult: IRepositoryGetFindOneQueryResult<CampusFindOneQuery, CampusFindOneQueryResult>;
getFindAllQueryResult: IRepositoryGetFindAllQueryResult<CampusListQuery, CampusListQueryResult>;
}
O write side devolve o agregado de domínio (loadById/save), o read side devolve dado já hidratado pra apresentação (getFindOneQueryResult/getFindAllQueryResult). Os demais módulos continuam no padrão antigo até serem migrados, não há prazo definido pra essa migração.
Command e query handlers¶
@Impl()
export class CampusCreateCommandHandlerImpl implements ICampusCreateCommandHandler {
constructor(
@Dep(ICampusRepository) private readonly repository: ICampusRepository,
@Dep(ICampusPermissionChecker) private readonly permissionChecker: ICampusPermissionChecker,
) {}
async execute(accessContext: IAccessContext | null, dto: CampusCreateCommand) {
await this.permissionChecker.ensureCanCreate(accessContext, { dto });
const domain = Campus.create({ nomeFantasia: dto.nomeFantasia /* ... */ });
const { id } = await this.repository.create({ ...domain });
const result = await this.repository.findById(accessContext, { id });
ensureExists(result, Campus.entityName, id);
return result;
}
}
Fluxo padrão de um command handler: verificar permissão, criar/atualizar entidade de domínio, persistir via repositório, retornar resultado. Um query handler é mais simples, delega leitura direto ao repositório sem passar por permission checker (ver nota em Autenticação e autorização sobre o roadmap de filtragem por permissão em leitura).
Permission checker¶
Padrão "throw on deny": se o usuário não tem permissão, a exceção é lançada, não um retorno booleano:
export interface IPermissionChecker {
ensureCanCreate(ac: IAccessContext | null, payload: { dto: unknown }): Promise<void>;
ensureCanUpdate(ac: IAccessContext | null, payload: { dto: unknown }, id: string): Promise<void>;
ensureCanDelete(ac: IAccessContext | null, payload: { dto: unknown }, id: string): Promise<void>;
}
As implementações atuais são no-ops intencionais, não verificam nada ainda. Isso não é sinalizado como anti-pattern, é o estado esperado até a autorização granular ser implementada módulo a módulo.
Dep e Impl¶
Decorators em src/domain/dependency-injection/ que abstraem o NestJS:
export const Dep = (token: any): ParameterDecorator => NestjsInject(token);
export const Impl = (): ClassDecorator => Injectable();
Dep é um wrapper de @Inject(), Impl é um wrapper de @Injectable(). O acoplamento resultante entre domínio e NestJS é aceito pragmaticamente, ver Decisões arquiteturais.
Scalars semânticos¶
Type aliases que adicionam significado a um tipo primitivo, em src/domain/abstractions/scalars/:
| Scalar | Tipo base | Propósito |
|---|---|---|
IdUuid |
string |
Identificador UUID |
IdNumeric |
number |
Identificador numérico (códigos IBGE) |
ScalarDateTimeString |
string |
Data/hora em ISO string |
ScalarDate |
string |
Data sem hora, em ISO string |
Usar id: IdUuid em vez de id: string permite que o TypeScript sinalize se um campo de identificador for trocado por um campo de texto qualquer, algo que o tipo string cru não distingue.