Arquitetura MVC
À medida que sua aplicação cresce, manter todo o código em um único arquivo se torna insustentável. Nesta aula, vamos aprender a organizar o código usando o padrão MVC (Model-View-Controller) adaptado para APIs.
O que é MVC?
MVC separa a aplicação em três camadas:
┌─────────────────────────────────────────────────────────────┐
│ REQUEST │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ CONTROLLER │
│ (Recebe request, valida, responde) │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ SERVICE │
│ (Lógica de negócio) │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ REPOSITORY │
│ (Acesso a dados) │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ MODEL │
│ (Estrutura dos dados) │
└─────────────────────────────────────────────────────────────┘
Responsabilidades
| Camada | Responsabilidade |
|---|---|
| Controller | Receber requisição, validar entrada, chamar service, enviar resposta |
| Service | Implementar regras de negócio, orquestrar operações |
| Repository | Abstrair acesso ao banco de dados |
| Model | Definir estrutura e tipos dos dados |
Estrutura de Pastas
src/
├── controllers/
│ └── user.controller.ts
├── services/
│ └── user.service.ts
├── repositories/
│ └── user.repository.ts
├── models/
│ └── user.model.ts
├── routes/
│ └── user.routes.ts
├── middlewares/
├── utils/
├── types/
├── app.ts
└── server.ts
Implementando MVC
Vamos implementar um módulo de usuários completo:
1. Model (Tipos)
typescript
// src/models/user.model.tsexport interface User { id: string; name: string; email: string; password: string; role: 'user' | 'admin'; createdAt: Date; updatedAt: Date;} export interface CreateUserDTO { name: string; email: string; password: string; role?: 'user' | 'admin';} export interface UpdateUserDTO { name?: string; email?: string; password?: string; role?: 'user' | 'admin';} // Usuário sem a senha (para respostas)export type UserResponse = Omit<User, 'password'>;2. Repository
typescript
// src/repositories/user.repository.tsimport { User, CreateUserDTO, UpdateUserDTO } from '../models/user.model';import { randomUUID } from 'crypto'; // Simulando banco de dados em memória (será substituído por Prisma)const users: User[] = []; export class UserRepository { async findAll(): Promise<User[]> { return users; } async findById(id: string): Promise<User | null> { return users.find(user => user.id === id) || null; } async findByEmail(email: string): Promise<User | null> { return users.find(user => user.email === email) || null; } async create(data: CreateUserDTO): Promise<User> { const user: User = { id: randomUUID(), ...data, role: data.role || 'user', createdAt: new Date(), updatedAt: new Date(), }; users.push(user); return user; } async update(id: string, data: UpdateUserDTO): Promise<User | null> { const index = users.findIndex(user => user.id === id); if (index === -1) return null; users[index] = { ...users[index], ...data, updatedAt: new Date(), }; return users[index]; } async delete(id: string): Promise<boolean> { const index = users.findIndex(user => user.id === id); if (index === -1) return false; users.splice(index, 1); return true; }}3. Service
typescript
// src/services/user.service.tsimport { hash } from 'bcrypt';import { UserRepository } from '../repositories/user.repository';import { CreateUserDTO, UpdateUserDTO, User, UserResponse } from '../models/user.model';import { AppError } from '../middlewares/errorHandler.middleware'; export class UserService { constructor(private userRepository: UserRepository) {} private toUserResponse(user: User): UserResponse { const { password, ...userWithoutPassword } = user; return userWithoutPassword; } async findAll(): Promise<UserResponse[]> { const users = await this.userRepository.findAll(); return users.map(this.toUserResponse); } async findById(id: string): Promise<UserResponse> { const user = await this.userRepository.findById(id); if (!user) { throw new AppError(404, 'Usuário não encontrado'); } return this.toUserResponse(user); } async create(data: CreateUserDTO): Promise<UserResponse> { // Verificar se email já existe const existingUser = await this.userRepository.findByEmail(data.email); if (existingUser) { throw new AppError(409, 'Email já cadastrado'); } // Hash da senha const hashedPassword = await hash(data.password, 10); const user = await this.userRepository.create({ ...data, password: hashedPassword, }); return this.toUserResponse(user); } async update(id: string, data: UpdateUserDTO): Promise<UserResponse> { // Verificar se usuário existe const existingUser = await this.userRepository.findById(id); if (!existingUser) { throw new AppError(404, 'Usuário não encontrado'); } // Se está atualizando email, verificar duplicidade if (data.email && data.email !== existingUser.email) { const userWithEmail = await this.userRepository.findByEmail(data.email); if (userWithEmail) { throw new AppError(409, 'Email já está em uso'); } } // Hash da senha se foi alterada if (data.password) { data.password = await hash(data.password, 10); } const updatedUser = await this.userRepository.update(id, data); return this.toUserResponse(updatedUser!); } async delete(id: string): Promise<void> { const deleted = await this.userRepository.delete(id); if (!deleted) { throw new AppError(404, 'Usuário não encontrado'); } }}4. Controller
typescript
// src/controllers/user.controller.tsimport { Request, Response } from 'express';import { UserService } from '../services/user.service';import { CreateUserDTO, UpdateUserDTO } from '../models/user.model'; export class UserController { constructor(private userService: UserService) {} findAll = async (req: Request, res: Response): Promise<void> => { const users = await this.userService.findAll(); res.json({ success: true, data: users, }); }; findById = async (req: Request, res: Response): Promise<void> => { const { id } = req.params; const user = await this.userService.findById(id); res.json({ success: true, data: user, }); }; create = async (req: Request, res: Response): Promise<void> => { const data: CreateUserDTO = req.body; const user = await this.userService.create(data); res.status(201).json({ success: true, data: user, message: 'Usuário criado com sucesso', }); }; update = async (req: Request, res: Response): Promise<void> => { const { id } = req.params; const data: UpdateUserDTO = req.body; const user = await this.userService.update(id, data); res.json({ success: true, data: user, message: 'Usuário atualizado com sucesso', }); }; delete = async (req: Request, res: Response): Promise<void> => { const { id } = req.params; await this.userService.delete(id); res.status(204).send(); };}5. Routes
typescript
// src/routes/user.routes.tsimport { Router } from 'express';import { UserController } from '../controllers/user.controller';import { UserService } from '../services/user.service';import { UserRepository } from '../repositories/user.repository';import { asyncHandler } from '../utils/asyncHandler'; const router = Router(); // Injeção de dependências manualconst userRepository = new UserRepository();const userService = new UserService(userRepository);const userController = new UserController(userService); router.get('/', asyncHandler(userController.findAll));router.get('/:id', asyncHandler(userController.findById));router.post('/', asyncHandler(userController.create));router.put('/:id', asyncHandler(userController.update));router.delete('/:id', asyncHandler(userController.delete)); export default router;Injeção de Dependências
Para facilitar testes e manutenção, use injeção de dependências:
typescript
// src/container.tsimport { UserRepository } from './repositories/user.repository';import { UserService } from './services/user.service';import { UserController } from './controllers/user.controller'; // Instâncias compartilhadasconst userRepository = new UserRepository();const userService = new UserService(userRepository);const userController = new UserController(userService); export const container = { userRepository, userService, userController,};Por que usar essa arquitetura?
1. Testabilidade
typescript
// Fácil de mockar dependências em testesdescribe('UserService', () => { let userService: UserService; let mockRepository: jest.Mocked<UserRepository>; beforeEach(() => { mockRepository = { findAll: jest.fn(), findById: jest.fn(), findByEmail: jest.fn(), create: jest.fn(), update: jest.fn(), delete: jest.fn(), }; userService = new UserService(mockRepository); }); it('should throw error when user not found', async () => { mockRepository.findById.mockResolvedValue(null); await expect(userService.findById('invalid-id')) .rejects.toThrow('Usuário não encontrado'); });});2. Manutenibilidade
- Mudanças no banco afetam apenas o Repository
- Novas regras de negócio vão para o Service
- Controllers permanecem limpos e focados
3. Reusabilidade
typescript
// Mesmo service pode ser usado em diferentes controllersclass AdminController { constructor(private userService: UserService) {} async promoteToAdmin(req: Request, res: Response) { await this.userService.update(req.params.id, { role: 'admin' }); res.json({ message: 'Usuário promovido' }); }}Resumo
Nesta aula aprendemos:
- ✅ O padrão MVC adaptado para APIs
- ✅ Responsabilidades de cada camada
- ✅ Implementar Model, Repository, Service, Controller
- ✅ Organizar rotas com injeção de dependências
- ✅ Benefícios da arquitetura em camadas
Na próxima aula, vamos aprender Validação de Dados com Zod! ✅