Minha primeira API em NestJS
Minha Primeira API com NestJS: do zero até autenticação, Docker, PostgreSQL e CRUD
Nos últimos dias comecei uma jornada nova: desenvolver uma API com NestJS.
Eu já tenho uma boa base em JavaScript e, no meu dia a dia, costumo trabalhar mais com Spring Boot. Então a ideia não era simplesmente sair copiando e colando código até funcionar. Eu queria entender como o Nest pensa, como ele organiza uma aplicação e como eu poderia comparar isso com o jeito que eu já conheço no ecossistema Java.
O projeto escolhido foi uma API de lembretes que meu cunhado me desafiou a um tempo atrás. A ideia parecia simples na superfície:
- cadastrar usuários;
- fazer login;
- gerar token JWT;
- proteger rotas;
- criar, listar, editar, concluir e deletar lembretes;
- associar cada lembrete ao usuário autenticado;
- usar PostgreSQL com Docker;
- persistir tudo usando TypeORM.
Simples, né? A famosa frase que sempre antecede algumas boas horas olhando erro no terminal.
O começo: entendendo como o Nest organiza as coisas
A primeira coisa que precisei entender foi que NestJS não é só “Express com classe”.
Ele tem uma estrutura bem definida, e isso foi uma das partes que mais me lembrou Spring Boot.
No Nest, a aplicação é organizada em:
ModuleControllerServiceDTOEntityGuardStrategy
Quando olhei para isso, logo fiz a ponte mental com Spring Boot:
Controllerlembra bastante os controllers REST do Spring.Servicetem praticamente o mesmo papel: concentrar regra de negócio.Entitylembra as entidades JPA.Repository, no TypeORM, lembra bastante os repositories do Spring Data JPA.- DTO com validação lembra o uso de Bean Validation com
@Valid,@NotBlank,@Email, etc. - Guards e Strategies lembram um pouco a camada de segurança do Spring Security.
A diferença é que no Nest tudo acontece no mundo TypeScript, com decorators como:
@Controller()
@Injectable()
@Module()
@Get()
@Post()
Enquanto no Spring Boot eu estaria usando algo como:
@RestController
@Service
@Configuration
@GetMapping
@PostMapping
A sensação foi bem familiar. Mudam as ferramentas, mas o raciocínio arquitetural é muito parecido.
Controller fino, Service com regra de negócio
Uma das primeiras lições foi reforçar algo que também vale muito no Spring Boot: controller não deve virar depósito de regra de negócio.
No começo criamos uma rota simples de cadastro:
POST /auth/register
A ideia era fazer o controller apenas receber a requisição e chamar o service.
O controller ficou responsável por lidar com HTTP. O service ficou responsável por decidir o que fazer.
Esse padrão é bem parecido com o que eu já uso no Spring:
Controller recebe a requisição
Service processa a regra
Repository conversa com o banco
No Nest ficou assim:
AuthController
-> AuthService
-> UsersService
-> Repository<User>
Esse fluxo foi importante porque me ajudou a pensar no Nest não como um framework mágico, mas como uma forma organizada de separar responsabilidades.
DTOs e validação: aqui comecei a ver o Nest brilhar
Depois veio a parte dos DTOs.
No começo usamos any, só para fazer o fluxo funcionar. Mas rapidamente ficou claro que isso era perigoso.
Criamos um RegisterDto com validações:
export class RegisterDto {
@IsString()
@IsNotEmpty()
name: string;
@IsEmail()
email: string;
@IsString()
@MinLength(6)
password: string;
}
E ativamos o ValidationPipe global.
Essa parte me lembrou muito o uso de DTOs no Spring Boot com Bean Validation:
@NotBlank
@Email
@Size(min = 6)
A ideia é a mesma: validar os dados na entrada da aplicação, antes que eles cheguem na regra de negócio.
O que eu gostei no Nest é que, depois de configurar o pipe global, os DTOs passam a funcionar de forma muito natural. A aplicação rejeita campos inválidos, campos extras e dados fora do contrato esperado.
Autenticação: bcrypt, JWT e aquele momento “agora ficou sério”
Depois entramos na autenticação.
Primeiro veio o hash de senha com bcryptjs.
Aqui precisei reforçar um conceito importante: hash não é criptografia reversível. A senha não é “descriptografada” no login.
O fluxo correto é:
Cadastro:
senha pura -> hash -> salva no banco
Login:
senha digitada + hash salvo -> bcrypt.compare() -> true ou false
Depois implementamos JWT.
O login passou a gerar um token com um payload parecido com:
{
sub: user.id,
email: user.email,
}
Também aprendi que sub é uma convenção do JWT para representar o “subject”, normalmente o id do usuário.
Essa parte lembra bastante Spring Security com JWT. No Spring eu teria filtros, security config, talvez um JwtTokenProvider. No Nest, usamos:
JwtModuleJwtServiceJwtStrategyJwtAuthGuard
A JwtStrategy valida o token e injeta o usuário autenticado na requisição. O JwtAuthGuard protege as rotas.
No final, para acessar uma rota protegida, o cliente precisa mandar:
Authorization: Bearer <token>
Criando meu próprio CurrentUser
Uma parte que achei bem interessante foi quando removemos o uso de:
@Req() req: any
e criamos um decorator próprio:
@CurrentUser() user: AuthUser
Isso deixou os controllers muito mais limpos.
Antes:
req.user.userId
Depois:
user.userId
Esse tipo de melhoria me lembrou bastante quando, no Spring, criamos formas mais elegantes de acessar o usuário autenticado em vez de ficar espalhando lógica de segurança por todo lado.
Também esbarrei em um erro bem específico do TypeScript:
A type referenced in a decorated signature must be imported with 'import type'
Foi aí que aprendi melhor a diferença entre import de runtime e import apenas de tipo:
import type { AuthUser } from './types/auth-user.type';
Pequeno detalhe, mas bem importante em projetos TypeScript modernos.
PostgreSQL com Docker: nada de instalar banco local
Uma decisão que sempre tomo foi: não quero instalar PostgreSQL direto na minha máquina.
Então usamos Docker Compose.
Criamos um serviço postgres e configuramos o Nest para conectar com TypeORM.
Aqui teve outro aprendizado importante:
Quando rodo a API localmente com:
npm run start:dev
o banco está acessível em:
DB_HOST=localhost
Mas quando a API roda dentro do Docker Compose, localhost passa a ser o próprio container da API. Então o host do banco vira o nome do serviço:
DB_HOST=postgres
Esse detalhe é bem importante e costuma confundir no começo.
Variáveis de ambiente e segurança no Docker Compose
Outro ponto de atenção é sobre como gerenciar variáveis de ambiente de forma segura.
No começo, as credenciais estavam diretamente no docker-compose.yml:
environment:
JWT_SECRET: dev-secret
DB_PASSWORD: postgres
Isso é problemático: qualquer pessoa que acessa o repositório vê as credenciais expostas.
A solução correta foi usar env_file no compose e manter os valores reais apenas no .env, que está no .gitignore:
services:
api:
env_file:
- .env
postgres:
environment:
POSTGRES_USER: ${DB_USERNAME}
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_DB: ${DB_DATABASE}
O .env.example vai para o repositório com valores fictícios, servindo como documentação:
JWT_SECRET=troque_por_um_valor_secreto_longo
DB_PASSWORD=troque_por_uma_senha_forte
O .env real fica apenas na máquina de quem desenvolve.
Container sem usuário root
Outro aprendizado que tive com o desafio-itau importante e coloquei em pratica aqui novamente foi sobre o Dockerfile. Por padrão, processos dentro de um container Docker rodam como root, o que não é uma boa prática de segurança.
A solução foi criar um usuário sem privilégios dentro do container:
FROM node:22-slim
WORKDIR /app
COPY package*.json ./
RUN addgroup --system appgroup && adduser --system --ingroup appgroup appuser
RUN npm ci
COPY . .
RUN npm run build
RUN chown -R appuser:appgroup /app
USER appuser
CMD ["npm", "run", "start:prod"]
Assim, se alguém explorar alguma vulnerabilidade no processo Node, fica preso em um usuário sem acesso ao sistema host.
TypeORM: Entity, Repository e uma sensação de JPA
Quando criamos a UserEntity, a semelhança com JPA ficou muito clara.
No TypeORM:
@Entity('users')
export class User {
@PrimaryGeneratedColumn()
id: number;
@Column({ unique: true })
email: string;
}
No Spring/JPA seria algo como:
@Entity
public class User {
@Id
@GeneratedValue
private Long id;
@Column(unique = true)
private String email;
}
A ideia é praticamente a mesma: uma classe representa uma tabela.
Vale destacar o unique: true no campo email. Sem essa constraint no banco, mesmo que o código verifique duplicidade via findByEmail, uma condição de corrida poderia permitir dois cadastros simultâneos com o mesmo email. Ter a constraint no banco é a garantia real.
Também usamos repository:
Repository<User>
para criar, buscar e salvar usuários.
No Spring Boot, eu pensaria em algo como:
UserRepository extends JpaRepository<User, Long>
Essa foi uma das partes em que me senti mais em casa.
Lembretes e relacionamento com usuário
Depois criamos a entidade de lembretes.
Cada lembrete pertence a um usuário:
@ManyToOne(() => User)
user: User;
Isso também lembra bastante relacionamentos JPA como @ManyToOne.
A regra principal era: um usuário só pode acessar os próprios lembretes.
Então todas as buscas, updates e deletes filtram pelo id do lembrete e pelo id do usuário autenticado.
Isso foi uma lição importante de segurança:
A identidade do usuário vem do token, não do body da requisição.
Ou seja, o cliente não manda userId no body para criar um lembrete. O backend pega o usuário autenticado pelo JWT.
Se o userId viesse do body, qualquer pessoa poderia tentar criar ou alterar dados em nome de outro usuário.
CRUD completo e rotas protegidas
No fim, chegamos ao CRUD completo:
POST /api/reminders
GET /api/reminders
PATCH /api/reminders/:id/complete
PUT /api/reminders/:id
DELETE /api/reminders/:id
Todas as rotas protegidas com JWT.
Também adicionamos ParseIntPipe para transformar e validar ids da URL:
@Param('id', ParseIntPipe) id: number
Isso evita aquele problema clássico:
/reminders/abc
virar NaN dentro da aplicação.
Mais uma vez, isso me lembrou bastante a ideia de deixar o framework validar e transformar dados antes de chegar na regra de negócio.
Segurança além do JWT
Depois de ter o fluxo funcionando, surgiu uma dúvida natural: o que mais posso fazer para deixar essa API mais segura?
Foram três adições que mudaram bastante o nível de maturidade do projeto.
Rate Limiting
Sem proteção, qualquer pessoa pode tentar fazer brute force no endpoint de login enviando milhares de requisições por segundo.
A solução foi o @nestjs/throttler, que limita quantas requisições um mesmo IP pode fazer em um intervalo de tempo:
@Post('login')
@Throttle({ default: { ttl: 60000, limit: 5 } })
login(@Body() loginDto: LoginDto) {
return this.authService.login(loginDto);
}
Aqui: máximo de 5 tentativas de login por minuto por IP.
CORS
CORS (Cross-Origin Resource Sharing) controla quais origens podem fazer requisições para a API pelo browser.
Sem isso, qualquer site poderia chamar a API em nome do usuário logado.
app.enableCors({
origin: process.env.ALLOWED_ORIGINS ?? 'http://localhost:5173',
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
});
Vale lembrar: CORS é uma proteção do browser. Ferramentas como Postman ou curl ignoram. A proteção vale para requests originados de outros sites.
Helmet
Helmet adiciona headers HTTP de segurança automáticos nas respostas, protegendo contra ataques como Clickjacking, MIME sniffing e outros.
import helmet from 'helmet';
app.use(helmet());
Uma linha. Vários vetores de ataque cobertos.
synchronize condicional
Outro ponto importante foi o synchronize do TypeORM. Em desenvolvimento, é conveniente deixar ele ativo: o banco é atualizado automaticamente conforme as entities mudam.
Mas em produção isso é perigoso. Uma mudança de entity poderia destruir dados.
A solução foi torná-lo condicional:
synchronize: configService.get('NODE_ENV') !== 'production',
Em produção, o correto é usar migrations.
Prettier, ESLint e as brigas com indentação
Também teve a parte menos glamourosa, mas muito real: formatação.
No começo eu estava sofrendo um pouco com indentação, ESLint e Prettier. Então configuramos o fluxo com:
npm run format
npm run lint -- --fix
E a ideia de formatar ao salvar no editor.
Isso parece detalhe, mas muda muito a experiência. Quando o projeto cresce, não dá para perder energia mental ajustando espaço, quebra de linha e import manualmente.
O que mais se pareceu com Spring Boot?
Várias coisas.
A organização em camadas foi a mais clara:
Controller
Service
Repository
Entity
DTO
Isso conversa diretamente com o jeito que eu já penso em Spring Boot.
A injeção de dependência também é muito familiar. No Nest:
constructor(private readonly usersService: UsersService) {}
No Spring:
private final UsersService usersService;
A diferença é a sintaxe, mas a ideia é a mesma: o framework cria e injeta as dependências.
Outra semelhança forte foi a segurança. Guards e Strategies no Nest lembram o papel de filtros e configurações do Spring Security, embora a implementação seja diferente.
E, claro, TypeORM com entities e repositories lembra bastante JPA e Spring Data.
O que precisei aprender ou pesquisar
Durante este meu primeiro contato com o NestJS, precisei entender melhor:
- como o Nest CLI gera modules, controllers e services;
- diferença entre dependência normal e dependência de desenvolvimento;
- como usar DTOs com
class-validator; - como configurar
ValidationPipe; - como funciona hash com bcrypt;
- como gerar e validar JWT;
- como proteger rotas com
JwtAuthGuard; - por que
localhostmuda de significado dentro do Docker; - como isolar variáveis sensíveis com
env_fileno Docker Compose; - como rodar container sem usuário root;
- como configurar TypeORM com variáveis de ambiente;
- como criar entities e repositories;
- como relacionar lembretes com usuários;
- como adicionar constraint
uniqueno banco via TypeORM; - como usar rate limiting com
@nestjs/throttler; - como configurar CORS e Helmet;
- como lidar com
import typeno TypeScript; - como usar Prettier e ESLint para manter o código organizado.
Conclusão
No começo, NestJS parecia um pouco cheio de estrutura.
Mas depois que entendi o papel de cada peça, tudo começou a fazer sentido.
O Nest tem uma pegada muito parecida com Spring Boot na forma de organizar a aplicação. Para quem já vem do Spring, isso ajuda bastante. A diferença está mais na sintaxe, nos decorators do TypeScript e em algumas ferramentas específicas do ecossistema Node.
No fim, saí de um projeto vazio para uma API funcional com:
- autenticação JWT;
- senha hasheada com bcrypt;
- PostgreSQL;
- Docker com container rodando sem usuário root;
- variáveis de ambiente isoladas e seguras;
- TypeORM com constraint
uniqueno email; - CRUD completo de lembretes;
- rotas protegidas;
- validação de entrada com
class-validator; - relacionamento entre usuário e lembretes;
- rate limiting no login;
- CORS e Helmet configurados;
synchronizecondicional por ambiente.
Foi uma ótima primeira experiência com NestJS.
E o mais legal é que agora o framework já não parece uma caixa preta. Ele parece uma ferramenta bem organizada, com conceitos que eu já conhecia de outros lugares, só aplicados de um jeito mais TypeScript.
Próximo passo? Migrations para evoluir o schema com segurança e testes mais completos cobrindo os services e casos de borda.
Mas por enquanto, dá para dizer: minha primeira API com NestJS saiu do papel.