🆚 NestJS vs Express
O Postiz original usa NestJS — e não por moda. Quando o app cresce para 8+ módulos (auth, posts, integrations, billing, queue, etc.), Express vira espaguete: você inventa sua própria estrutura, seu próprio DI, sua própria forma de testar. Nest entrega tudo isso pronto: decorators, injeção de dependência e módulos são primeira classe.
Compare a mesma rota nos dois frameworks. No Express, o controller carrega serviço, validação e resposta tudo junto. No Nest, cada coisa no seu lugar — e o PostsService é injetado automaticamente pelo container DI.
// apps/backend/src/posts/posts.controller.ts
import { Controller, Get, Post, Body, Param } from '@nestjs/common';
import { PostsService } from './posts.service';
import { CreatePostDto } from './dto/create-post.dto';
@Controller('posts')
export class PostsController {
constructor(private readonly posts: PostsService) {}
@Get()
list() {
return this.posts.findAll();
}
@Post()
create(@Body() dto: CreatePostDto) {
return this.posts.create(dto);
}
@Get(':id')
byId(@Param('id') id: string) {
return this.posts.findOne(id);
}
}
💡 Dica prática
Nest é opinionado de propósito. Não tente "expressar" um projeto Nest — abrace o padrão Module/Controller/Service. Quando você inventar uma terceira camada porque "fica mais limpo", está perdendo justamente o motivo de ter escolhido Nest.
Conceitos-chave
Resolve dependências de classes automaticamente.
@Controller, @Get, @Injectable dão semântica.
Unidade de organização — importa, exporta, encapsula.
Validação e auth como middlewares declarativos.
🗄️ Prisma + PostgreSQL
Prisma resolve três dores do TypeScript com SQL: tipagem ponta-a-ponta (o client é gerado a partir do schema.prisma), migrations versionadas e autocomplete em queries. Esqueça raw SQL para 90% dos casos.
O schema.prisma é fonte única da verdade do modelo de dados. Alterou um campo? prisma migrate dev gera a migration SQL e regenera o client.
// apps/backend/prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
id String @id @default(cuid())
email String @unique
password String
posts Post[]
createdAt DateTime @default(now())
}
model Post {
id String @id @default(cuid())
content String
scheduledAt DateTime?
publishedAt DateTime?
status String @default("draft")
authorId String
author User @relation(fields: [authorId], references: [id])
@@index([authorId, status])
}
# Init + primeira migration
pnpm dlx prisma init --datasource-provider postgresql
pnpm dlx prisma migrate dev --name init
pnpm dlx prisma generate
Conceitos-chave
DSL declarativa do modelo de dados.
Gera SQL versionado em prisma/migrations/.
Tipos TypeScript a partir do schema.
GUI web para inspecionar e editar dados.
📬 BullMQ + Redis
Agendamento de posts não pode viver no processo HTTP. Se o request termina antes do horário, o post some. A solução são filas: BullMQ guarda o job no Redis com delay, e um worker separado processa quando chega a hora.
Três peças: Queue (quem produz jobs), Worker (quem consome) e QueueEvents (quem observa). Worker pode rodar em outro processo, outro container, outra máquina.
// apps/backend/src/queue/publish.queue.ts
import { Queue, Worker } from 'bullmq';
import IORedis from 'ioredis';
const connection = new IORedis(process.env.REDIS_URL!, {
maxRetriesPerRequest: null,
});
export const publishQueue = new Queue('publish', { connection });
// Agendar post para daqui a 1 hora
await publishQueue.add(
'publish-post',
{ postId: 'cm123', provider: 'twitter' },
{
delay: 60 * 60 * 1000,
attempts: 5,
backoff: { type: 'exponential', delay: 2000 },
removeOnComplete: 1000,
removeOnFail: 5000,
}
);
// Worker (processo separado em produção)
new Worker(
'publish',
async (job) => {
const { postId, provider } = job.data;
return await publishToProvider(postId, provider);
},
{ connection, concurrency: 10 }
);
⚡ Dica prática
Sempre configure attempts e backoff: exponential. APIs sociais retornam rate-limit (429) com frequência — sem retry exponencial, você queima a janela inteira em 3 tentativas e perde o post.
✓ O que FAZER
- ✓Worker em processo separado (
apps/worker). - ✓
removeOnComplete/removeOnFailpara não inchar o Redis. - ✓Idempotência: jobs podem rodar 2x — não duplique o post.
- ✓Monitorar com Bull Board ou QueueEvents.
✗ O que NÃO fazer
- ✗Worker no mesmo processo HTTP em produção.
- ✗Esquecer
maxRetriesPerRequest: nullno ioredis. - ✗Jobs sem
attempts— uma falha e o post some. - ✗Guardar payload gigante no job — passe só o ID.
Conceitos-chave
Job que só executa após delay ms.
Espera crescente entre tentativas.
Jobs simultâneos por worker.
Jobs falhados ficam para inspeção manual.
📁 Estrutura de pastas monorepo
Backend, frontend e worker no mesmo repo, com código compartilhado em libs/. Tipos do Prisma, DTOs e validação zod ficam em um pacote consumido pelos três apps — sem duplicação, sem desincronização.
mkblogs/
├── apps/
│ ├── backend/ # NestJS API
│ │ ├── src/
│ │ │ ├── auth/
│ │ │ ├── posts/
│ │ │ ├── integrations/
│ │ │ └── main.ts
│ │ ├── prisma/
│ │ │ ├── schema.prisma
│ │ │ └── migrations/
│ │ └── package.json
│ ├── frontend/ # Next.js
│ │ ├── src/
│ │ │ ├── app/
│ │ │ └── components/
│ │ └── package.json
│ └── worker/ # BullMQ workers
│ ├── src/
│ │ └── publish.worker.ts
│ └── package.json
├── libs/
│ └── shared/ # tipos + zod schemas compartilhados
│ ├── src/
│ │ ├── dto/
│ │ └── env.schema.ts
│ └── package.json
├── pnpm-workspace.yaml
├── .env.example
└── package.json
📊 Por que três apps?
- apps/backend: API HTTP. Não toca em filas — só enfileira.
- apps/worker: processo que consome filas. Escala separado do HTTP.
- apps/frontend: Next.js. Fala só com o backend via REST/tRPC.
- libs/shared: DTOs, tipos, schemas zod. Importado por todos.
Conceitos-chave
Vários pacotes, um repositório git.
Apps rodam; libs são consumidas por apps.
Resolve deps locais sem publicar no npm.
Backend e frontend importam o mesmo DTO.
📦 pnpm workspaces
pnpm é o gerenciador certo para monorepo: instala dependências uma vez e linka via symlinks (node_modules 5x menor que npm), entende workspace:* nativamente e é mais rápido. Use npm ou yarn aqui só se for masoquista.
# pnpm-workspace.yaml
packages:
- 'apps/*'
- 'libs/*'
// package.json (raiz)
{
"name": "mkblogs",
"private": true,
"scripts": {
"dev:backend": "pnpm --filter backend dev",
"dev:frontend": "pnpm --filter frontend dev",
"dev:worker": "pnpm --filter worker dev",
"dev": "pnpm -r --parallel dev",
"build": "pnpm -r build",
"lint": "pnpm -r lint"
},
"devDependencies": {
"typescript": "^5.5.0",
"tsx": "^4.16.0"
}
}
// apps/backend/package.json (trecho)
{
"name": "backend",
"dependencies": {
"@nestjs/core": "^10.4.0",
"@nestjs/common": "^10.4.0",
"@prisma/client": "^5.18.0",
"bullmq": "^5.12.0",
"ioredis": "^5.4.0",
"zod": "^3.23.0",
"@mkblogs/shared": "workspace:*"
}
}
# Comandos úteis
pnpm install # instala tudo do workspace
pnpm --filter backend add @nestjs/jwt # adiciona dep só no backend
pnpm --filter worker dev # roda script de um pacote
pnpm -r build # build recursivo em todos
Conceitos-chave
Aponta para versão local do pacote.
Executa em pacotes específicos.
Roda o mesmo script em todos os pacotes.
Cada versão de pacote é armazenada uma vez.
🔐 Variáveis de ambiente
Config via .env não é suficiente. Sem validação, um typo em DATABASE_URL só estoura no primeiro request em produção. A solução: validar com zod no boot — se algo está errado, o app nem sobe.
// libs/shared/src/env.schema.ts
import { z } from 'zod';
export const envSchema = z.object({
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
PORT: z.coerce.number().int().positive().default(3000),
DATABASE_URL: z.string().url(),
REDIS_URL: z.string().url(),
JWT_SECRET: z.string().min(32, 'JWT_SECRET precisa de 32+ chars'),
JWT_EXPIRES_IN: z.string().default('7d'),
FRONTEND_URL: z.string().url(),
// OAuth providers (opcionais no dev)
TWITTER_CLIENT_ID: z.string().optional(),
TWITTER_CLIENT_SECRET: z.string().optional(),
});
export type Env = z.infer<typeof envSchema>;
export function loadEnv(): Env {
const parsed = envSchema.safeParse(process.env);
if (!parsed.success) {
console.error('❌ .env inválido:', parsed.error.flatten().fieldErrors);
process.exit(1);
}
return parsed.data;
}
# .env.example (commitado, sem segredos)
NODE_ENV=development
PORT=3000
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/mkblogs
REDIS_URL=redis://localhost:6379
JWT_SECRET= # gere com: openssl rand -hex 32
JWT_EXPIRES_IN=7d
FRONTEND_URL=http://localhost:3001
TWITTER_CLIENT_ID=
TWITTER_CLIENT_SECRET=
// apps/backend/src/main.ts
import { loadEnv } from '@mkblogs/shared';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
const env = loadEnv(); // valida ANTES de subir o Nest
async function bootstrap() {
const app = await NestFactory.create(AppModule);
await app.listen(env.PORT);
}
bootstrap();
🛡️ Dica prática
Valide o env antes do NestFactory.create. Se o schema falhar, o processo termina com exit code 1 e o orquestrador (Docker/Kubernetes) detecta — vale muito mais que descobrir o erro 30 min depois quando um usuário tenta logar.
Conceitos-chave
Validação com inferência de tipos TS.
Contrato versionado de configuração.
Converte string do env em número/bool.
Erro no boot > erro em runtime.
🎯 Resumo do Módulo
apps/backend, apps/frontend, apps/worker e libs/shared.workspace:*, --filter e -r dominam o fluxo diário..env.example commitado, fail fast no boot.Próximo Módulo:
5.2 — Auth, modelos de dados e primeiras rotas