Como adicionar um módulo novo
Estrutura completa para criar um novo domínio de negócio (controller, service, repo, schema, routes).
Adicionar um módulo novo segue a estrutura de Clean Architecture lite + DDD usada nos existentes. Como exemplo, vamos imaginar um módulo wishlist (lista de desejo de items específicos com prioridade).
1. Crie a pasta e os arquivos básicos
src/modules/wishlist/
├── wishlist.routes.ts
├── wishlist.controller.ts
├── wishlist.service.ts
├── wishlist.repository.ts
└── wishlist.schema.tsE o contract em:
src/shared/contracts/
└── wishlist.repository.contract.ts2. Defina o schema Zod
src/modules/wishlist/wishlist.schema.ts:
import { z } from 'zod'
export const addWishlistItemSchema = z.object({
itemId: z.string().uuid().optional(),
externalUrl: z.string().url().optional(),
notes: z.string().max(500).optional(),
priority: z.enum(['low', 'medium', 'high']).default('medium'),
}).refine(
(data) => Boolean(data.itemId) !== Boolean(data.externalUrl),
{ message: 'Forneça itemId OU externalUrl, não ambos' }
)
export const updateWishlistItemSchema = z.object({
notes: z.string().max(500).nullable().optional(),
priority: z.enum(['low', 'medium', 'high']).optional(),
})
export type AddWishlistItemInput = z.infer<typeof addWishlistItemSchema>
export type UpdateWishlistItemInput = z.infer<typeof updateWishlistItemSchema>3. Adicione tabelas em schema.ts
src/shared/infra/database/schema.ts:
export const wishlistPriorityEnum = pgEnum('wishlist_priority', ['low', 'medium', 'high'])
export const wishlistItems = pgTable('wishlist_items', {
id: uuid('id').defaultRandom().primaryKey(),
userId: uuid('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
itemId: uuid('item_id').references(() => items.id, { onDelete: 'set null' }),
externalUrl: varchar('external_url'),
notes: text('notes'),
priority: wishlistPriorityEnum('priority').notNull().default('medium'),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
}, (table) => ({
userPriorityIdx: index('wishlist_user_priority_idx').on(table.userId, table.priority, table.createdAt),
}))4. Gere migration
cd ~/workspace_ssh/geek-social-api
npm run db:generateDrizzle cria 0035_wishlist.sql (ou similar). Revise o SQL gerado — verificar se enum + tabela estão OK.
5. Defina o contract
src/shared/contracts/wishlist.repository.contract.ts:
export interface IWishlistRepository {
add(userId: string, input: AddWishlistItemInput): Promise<WishlistItem>
list(userId: string): Promise<WishlistItem[]>
update(id: string, input: UpdateWishlistItemInput): Promise<WishlistItem | null>
remove(id: string): Promise<void>
findById(id: string): Promise<WishlistItem | null>
}
export type WishlistItem = {
id: string
userId: string
itemId: string | null
externalUrl: string | null
notes: string | null
priority: 'low' | 'medium' | 'high'
createdAt: Date
updatedAt: Date
}6. Implemente o repository
src/modules/wishlist/wishlist.repository.ts:
import { eq, desc } from 'drizzle-orm'
import { wishlistItems } from '../../shared/infra/database/schema.js'
import type { IWishlistRepository } from '../../shared/contracts/wishlist.repository.contract.js'
export class WishlistRepository implements IWishlistRepository {
constructor(private readonly db: NodePgDatabase) {}
async add(userId, input) { /* INSERT ... RETURNING */ }
async list(userId) { /* SELECT ... ORDER BY priority DESC, created_at DESC */ }
async update(id, input) { /* UPDATE ... RETURNING */ }
async remove(id) { /* DELETE */ }
async findById(id) { /* SELECT ... LIMIT 1 */ }
}7. Crie o service
src/modules/wishlist/wishlist.service.ts:
export class WishlistError extends Error {
constructor(public readonly code: string) { super(code); this.name = 'WishlistError' }
}
export class WishlistService {
constructor(private readonly repo: IWishlistRepository) {}
async add(userId: string, input: AddWishlistItemInput) {
return this.repo.add(userId, input)
}
async list(userId: string) {
return this.repo.list(userId)
}
async update(userId: string, id: string, input: UpdateWishlistItemInput) {
const existing = await this.repo.findById(id)
if (!existing) throw new WishlistError('NOT_FOUND')
if (existing.userId !== userId) throw new WishlistError('NOT_AUTHORIZED')
return this.repo.update(id, input)
}
async remove(userId: string, id: string) {
const existing = await this.repo.findById(id)
if (!existing) throw new WishlistError('NOT_FOUND')
if (existing.userId !== userId) throw new WishlistError('NOT_AUTHORIZED')
await this.repo.remove(id)
}
}8. Crie o controller
src/modules/wishlist/wishlist.controller.ts:
const STATUS_BY_CODE: Record<string, number> = {
NOT_FOUND: 404,
NOT_AUTHORIZED: 403,
}
export class WishlistController {
constructor(private readonly service: WishlistService) {}
private handleError(err: unknown, reply: FastifyReply) {
if (err instanceof WishlistError) {
return reply.status(STATUS_BY_CODE[err.code] ?? 400).send({ error: err.code })
}
throw err
}
async add(request: FastifyRequest<{ Body: AddWishlistItemInput }>, reply: FastifyReply) {
const { userId } = request.user as AccessTokenClaims
const item = await this.service.add(userId, request.body)
return reply.status(201).send(item)
}
async list(request: FastifyRequest, reply: FastifyReply) {
const { userId } = request.user as AccessTokenClaims
return reply.send(await this.service.list(userId))
}
// ... update, remove
}9. Registre as rotas
src/modules/wishlist/wishlist.routes.ts:
import type { FastifyPluginAsyncZod } from 'fastify-type-provider-zod'
export const wishlistRoutes: FastifyPluginAsyncZod<{ wishlistService: WishlistService }> = async (app, options) => {
const ctrl = new WishlistController(options.wishlistService)
const noContent = z.void()
const idParam = z.object({ id: z.string().uuid() })
app.post('/', {
schema: {
operationId: 'wishlist_add',
tags: ['Wishlist'],
summary: 'Adicionar item à wishlist',
security: [{ accessToken: [] }],
body: addWishlistItemSchema,
},
preHandler: [authenticate],
handler: ctrl.add.bind(ctrl),
})
app.get('/', {
schema: {
operationId: 'wishlist_list',
tags: ['Wishlist'],
summary: 'Listar minha wishlist',
security: [{ accessToken: [] }],
},
preHandler: [authenticate],
handler: ctrl.list.bind(ctrl),
})
app.patch('/:id', {
schema: {
operationId: 'wishlist_update',
tags: ['Wishlist'],
summary: 'Editar item da wishlist',
security: [{ accessToken: [] }],
params: idParam,
body: updateWishlistItemSchema,
},
preHandler: [authenticate],
handler: ctrl.update.bind(ctrl),
})
app.delete('/:id', {
schema: {
operationId: 'wishlist_remove',
tags: ['Wishlist'],
summary: 'Remover da wishlist',
security: [{ accessToken: [] }],
params: idParam,
response: { 204: noContent },
},
preHandler: [authenticate],
handler: ctrl.remove.bind(ctrl),
})
}10. Wire up em app.ts
src/app.ts:
import { WishlistRepository } from './modules/wishlist/wishlist.repository.js'
import { WishlistService } from './modules/wishlist/wishlist.service.js'
import { wishlistRoutes } from './modules/wishlist/wishlist.routes.js'
// dentro de buildApp(), após criar db e antes de register rotas:
const wishlistRepository = new WishlistRepository(db)
const wishlistService = new WishlistService(wishlistRepository)
await app.register(wishlistRoutes, { prefix: '/wishlist', wishlistService })11. Atualize OpenAPI tags
app.ts — adicione tag à config do swagger:
tags: [
// ... existentes
{ name: 'Wishlist', description: 'Lista de desejos do usuário' },
],12. Crie a página do módulo
content/docs/modules/wishlist.mdx — siga o template dos outros módulos (visão geral, entidades, endpoints, fluxos com Mermaid, edge cases, dependências).
13. Adicione na sidebar
content/docs/api/meta.json— adicionewishlistcontent/docs/modules/meta.json— adicionewishlist
14. Crie tabela de docs
Os 28 já existentes têm prose manual. Para a nova wishlist_items:
npm run export:dbml && npm run sync && npm run gen— cria stub- Edite
content/docs/data-model/tables/wishlist_items.mdxcom prose detalhada
15. Atualize códigos de erro
content/docs/reference/codigos-erro.mdx — adicione seção Wishlist com NOT_FOUND, NOT_AUTHORIZED.
16. Atualize ER diagram
content/docs/data-model/er-diagram.mdx — adicione wishlist_items no diagrama global e crie diagrama por domínio se faz sentido.
17. Testes
- Unit:
wishlist.service.test.ts - Integration:
wishlist.repository.test.ts(Testcontainers) - E2E: cobre add → list → update → remove
Checklist
- Schema Zod
- Migration aplicada (
db:migrate) - Contract em
shared/contracts/ - Repository implementado
- Service com lógica + ownership checks
- Controller com STATUS_BY_CODE
- Routes com FastifyPluginAsyncZod
- Wire up em
app.ts - Tag adicionada no OpenAPI config
- Página do módulo (
modules/<nome>.mdx) - Códigos de erro documentados
- Tabela documentada
- ER diagram atualizado
- sidebar atualizada (api + modules)
- Testes (unit + integration + e2e)
-
tsc --noEmitpassa -
npm run export:all && cd ../geek-social-docs && npm run sync && npm run gen