server-repositorio
Como escrever a porta de repositório em domain/ports e o repositório Prisma em infrastructure/database/repositories no server do Smaug — mapper toDomain com tipo estrutural inline (ou XRecord do domín
SKILL.md.claude/skills/server-repositorio/SKILL.md
GitHubPorta e repositório
Quando usar esta skill
Ao criar/alterar server/src/domain/ports/*.repository.ts ou
server/src/infrastructure/database/repositories/prisma-*.repository.ts.
As portas de serviço de autenticação (password-hasher.ts, access-token-service.ts,
refresh-token-generator.ts, auth-event-logger.ts) também moram em domain/ports/, mas não são
repositório: ver "Portas que não são repositório" no fim.
Porta — domain/ports/<agregado>.repository.ts
import { OneTimeRevenue } from "@src/domain/entities/one-time-revenue.entity";
export interface OneTimeRevenueRepository {
findById(id: string): Promise<OneTimeRevenue | null>;
findAllByUser(userId: string): Promise<OneTimeRevenue[]>;
findByUserAndCompetence(
userId: string,
competenceYear: number,
competenceMonth: number,
): Promise<OneTimeRevenue[]>;
create(revenue: OneTimeRevenue): Promise<OneTimeRevenue>;
update(revenue: OneTimeRevenue): Promise<OneTimeRevenue>;
delete(id: string): Promise<void>;
}
Regras da porta:
- Interface, nunca classe abstrata. Nome
<Agregado>Repository, sem prefixoI. - Devolve entidades de domínio, jamais linha do Prisma.
- Todo método devolve
Promise. Busca de um item devolveT | null— não lança, não usaundefined. update(entity)recebe a entidade inteira (já atualizada por copy-on-write), não um patch.- Escrita composta passa os filhos explicitamente:
create(expense: InstallmentExpense, installments: Installment[]). - Vocabulário de método em uso:
findById,findAllByUser/listByUser,findByUserAndCompetence,findActiveForCompetence,findByCategoryId,findByIdWithVersions,create,update,delete,terminate,addVersion,findVersions,findVersionForMonth,countLinkedExpenses,deleteFutureInstallments,findInstallmentsByExpense,findInstallmentsByCompetence; na autenticação,findByEmail,findByUserId,createWithPasswordCredential,findSessionById,findTokenByHash,rotate,revokeSession,revokeAllForUser,deleteStaleForUser. OUserRepositorynão tem maiscreate: usuário só nasce junto com a credencial (createWithPasswordCredential(user, credential)). - Escrita que pode perder corrida devolve
Promise<boolean>(rotate:false= outra requisição chegou antes) em vez de lançar; conflito de unicidade que tem significado de negócio vira oDomainErrorcorrespondente dentro do repositório (EmailAlreadyExistsError). - "Agora" chega por parâmetro (
now: Date) quando a escrita depende do tempo — o repositório não chamanew Date()para regra (verserver-caso-de-uso). - JSDoc só quando o nome não basta, e em português:
/** Quantidade de despesas (avulsas, parceladas e versões de recorrentes) ligadas à categoria. */
Repositório Prisma — infrastructure/database/repositories/prisma-<agregado>.repository.ts
import { PrismaClient } from "@prisma/client";
import { OneTimeRevenue } from "@src/domain/entities/one-time-revenue.entity";
import { OneTimeRevenueRepository } from "@src/domain/ports/one-time-revenue.repository";
export class PrismaOneTimeRevenueRepository implements OneTimeRevenueRepository {
constructor(private readonly prisma: PrismaClient) {}
public async findById(id: string): Promise<OneTimeRevenue | null> {
const record = await this.prisma.oneTimeRevenue.findUnique({ where: { id } });
if (!record) return null;
return PrismaOneTimeRevenueRepository.toDomain(record);
}
public async findAllByUser(userId: string): Promise<OneTimeRevenue[]> {
const records = await this.prisma.oneTimeRevenue.findMany({
where: { userId },
orderBy: [{ competenceYear: "desc" }, { competenceMonth: "desc" }, { createdAt: "desc" }],
});
return records.map(PrismaOneTimeRevenueRepository.toDomain);
}
public async update(revenue: OneTimeRevenue): Promise<OneTimeRevenue> {
const record = await this.prisma.oneTimeRevenue.update({
where: { id: revenue.id },
data: {
description: revenue.description,
amount: revenue.amount,
updatedAt: revenue.updatedAt,
},
});
return PrismaOneTimeRevenueRepository.toDomain(record);
}
private static toDomain(record: {
id: string;
userId: string;
description: string;
amount: number;
competenceMonth: number;
competenceYear: number;
createdAt: Date;
updatedAt: Date;
}): OneTimeRevenue {
return OneTimeRevenue.create({
id: record.id,
userId: record.userId,
description: record.description,
amount: record.amount,
competenceMonth: record.competenceMonth,
competenceYear: record.competenceYear,
createdAt: record.createdAt,
updatedAt: record.updatedAt,
});
}
}
Regras do repositório
export class Prisma<Agregado>Repository implements <Agregado>Repository. Sem@injectable()— o projeto não usa decorators.constructor(private readonly prisma: PrismaClient) {}— o client é injetado, nunca importado como singleton dentro do repositório.Todos os métodos públicos são
public asynce devolvem entidade.O mapper linha→entidade é
private static to…, chamado comoClasse.toDomain(record)ou passado point-free em.map(Classe.toDomain)(funciona por serstatic).- Um agregado por arquivo →
toDomain. - Vários →
toExpenseDomain/toInstallmentDomain/toVersionDomain/toRevenueDomain. - Sentido inverso (entidade→linha) →
private static toVersionData(...).
- Um agregado por arquivo →
O parâmetro
recordé tipado com um objeto estrutural inline, nunca com o tipo gerado do Prisma. É isso que mantém o domínio livre de Prisma e faz a regrano-domain-to-infravaler na prática.createlista todas as colunas explicitamente (inclusiveid,createdAt,updatedAt, que vêm da entidade);updatelista apenas as colunas mutáveis +updatedAt.orderByé sempre explícito em consultas de lista — tipicamente[{ competenceYear: "desc" }, { competenceMonth: "desc" }, { createdAt: "desc" }].Reconstrução usa
Entity.create({ ...record }). Exceções, que usamEntity.rehydrate(record):RecurringExpenseeRecurringExpenseVersion(ocreate()rejeita competência passada) eAuthSession/RefreshToken(a fábrica —start/issue— calcula datas a partir de "agora" e não reconstrói uma linha). Para essas duas, orecordé tipado com a interfaceAuthSessionRecord/RefreshTokenRecordexportada pela entidade, em questatus,authMethoderevokedReasonsãostring(o schema não usa enum): a linha do Prisma satisfaz o tipo estruturalmente, e é orehydrateque valida e estreita para a união fechada.Filtro "a partir de uma competência" usa o idioma
OR:OR: [{ competenceYear: { gt: year } }, { competenceYear: year, competenceMonth: { gt: month } }];Filtrar por dono através de relação:
installmentExpense: { userId }.Escrita composta:
createaninhado +includeordenado (installments: { create: installments.map(...) }/include: { installments: { orderBy: [{ installmentNumber: "asc" }] } }). Atualização em lote:this.prisma.$transaction(items.map((i) => this.prisma.installment.update({ ... }))).Filtrar em memória é aceitável quando a regra vive na entidade:
records.map(toExpenseDomain).filter((e) => e.isActiveForMonth(month, year)).Escrita em várias tabelas que precisa ser atômica usa
$transactioninterativa (async (tx) => { … }), comocreateWithPasswordCredential(usuário + credencial) erevokeSession(sessão antes dos tokens — a mesma ordem dorotate, para não haver deadlock).Compare-and-set:
updateManycom o estado esperado nowhere, conferindocount. Se não casou, lance um erro-sentinela privado para desfazer a transação e traduza parafalse; umP2002(UNIQUE deparent_token_id) também virafalse. Trecho deprisma-auth-session.repository.ts:/** Sinal interno de `rotate`: um compare-and-set não casou e a transação precisa ser desfeita. */ class RotationLostError extends Error {} public async rotate(currentTokenId: string, next: RefreshToken, now: Date): Promise<boolean> { try { await this.prisma.$transaction(async (tx) => { const session = await tx.authSession.updateMany({ where: { id: next.sessionId, status: "ACTIVE", expiresAt: { gt: now } }, data: { lastUsedAt: now }, }); if (session.count !== 1) throw new RotationLostError(); const current = await tx.refreshToken.updateMany({ where: { id: currentTokenId, sessionId: next.sessionId, status: "ACTIVE", expiresAt: { gt: now }, }, data: { status: "ROTATED", rotatedAt: now }, }); if (current.count !== 1) throw new RotationLostError(); await tx.refreshToken.create({ data: { ...PrismaAuthSessionRepository.toTokenData(next), sessionId: next.sessionId, parentTokenId: currentTokenId, }, }); }); return true; } catch (error) { if ( error instanceof RotationLostError || PrismaAuthSessionRepository.isUniqueViolation(error) ) { return false; } throw error; } }P2002 com significado de negócio: o
metado erro muda por driver adapter (o SQLite informa as colunas, o PostgreSQL o nome do índice), então a detecção procura o nome da coluna nometaserializado. Deprisma-user.repository.ts:private static isEmailConflict(error: unknown): boolean { return ( error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002" && `${JSON.stringify(error.meta)}`.includes("email") ); }
Checklist
- Porta em
domain/ports/, devolvendo entidades eT | null. - Classe Prisma
implementsa porta, comPrismaClientinjetado. -
private static toDomain(record: { ...inline... }). -
orderByexplícito em toda lista. -
createcom todas as colunas;updatesó com as mutáveis. -
$transactionpara escrita em lote. - Registrado no
container.tscom a variável anotada pelo tipo da porta. - Teste do repositório: unitário (client dublado) em
tests/unit/infrastructure/database/repositories/, ou — quando o comportamento depende do banco de verdade (transação, CAS, UNIQUE) — integração com SQLite próprio emtests/integration/infrastructure/database/repositories/(como os de auth). Concorrência roda nos dois providers:prisma-auth-session.concurrency.test.tsliga o PostgreSQL quando háPOSTGRES_TEST_DATABASE_URL(jobmigrationsdo CI, com client postgresql).
Não faça
- Não importe
@prisma/clientfora deinfrastructure/. - Não tipe o
recordcomPrisma.OneTimeRevenueGetPayload<...>nem com o model gerado. - Não use
@injectable(). - Não faça
prisma.$queryRawde regra de negócio — a regra vive no domínio. - Não devolva
undefinedde umfindById; devolvanull. - Não faça "ler, conferir e depois gravar" fora de transação para regra de concorrência: use o
compare-and-set com
updateMany+count. - Não deixe o
P2002de uma unicidade de negócio vazar como erro cru (viraria 500).
Contexto adicional
domain/ports/repository.interface.ts (Repository<T, ID>) e
infrastructure/database/database.provider.ts (RepositoryFactory, PrismaRepository<T>,
InMemoryRepository<T>) são o caminho genérico legado, usado só pelo switch de
DATABASE_PROVIDER. Nenhum agregado os utiliza — não os use para código novo.
Portas que não são repositório
A autenticação precisa de serviços externos além de persistência. As portas ficam em
domain/ports/ sem sufixo .repository e são implementadas por adapters em
infrastructure/auth/ (os únicos que podem importar argon2/jose — regra
auth-crypto-only-in-infra):
Porta (domain/ports/) |
Interface | Adapter (infrastructure/auth/) |
|---|---|---|
password-hasher.ts |
PasswordHasher |
Argon2PasswordHasher (argon2-password-hasher.ts) |
access-token-service.ts |
AccessTokenService |
JoseAccessTokenService |
refresh-token-generator.ts |
RefreshTokenGenerator |
CryptoRefreshTokenGenerator |
auth-event-logger.ts |
AuthEventLogger |
nenhum: o Logger de application/ports satisfaz |
AuthEventLogger é a porta mínima de log que o domínio enxerga (o domínio não pode importar
application/ports/logger.interface.ts); o container passa o próprio Logger, que a satisfaz
estruturalmente. Os adapters recebem a fatia de authConfig pelo construtor e não leem env.