Stack Tecnológica - Sport Tech Club
Plataforma SaaS Multi-tenant para Gestão de Clubes Esportivos
Arquitetura Event-Driven | Clean Architecture | DDD | Real-time | IoT-enabled
📋 Sumário
- Visão Geral
- Stack Detalhada
- Arquitetura de Serviços
- Estrutura do Monorepo
- Padrões de Código
- Estratégia de Testes
- Deploy & CI/CD
- Observabilidade
- Segurança
- Performance & Escalabilidade
🎯 Visão Geral
Objetivos da Plataforma
- SaaS Multi-tenant: Isolamento de dados, configuração por tenant
- Real-time First: Placar ao vivo, presença, notificações push
- IoT-enabled: Tablets, botões físicos, sensores, beacons
- Event-Driven: Comunicação assíncrona entre serviços
- Mobile-ready: PWA responsivo/adaptativo
- Escalável: Suporta crescimento de usuários e clubes
- DDD/Clean Architecture: Código manutenível e testável
Características Técnicas
┌─────────────────────────────────────────────────────────────┐
│ SPORT TECH CLUB │
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ Web App │ │ Admin Panel │ │ Mobile PWA │ │
│ │ (Vue.js) │ │ (Vue.js) │ │ (Vue.js) │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
│ │ │ │ │
│ └──────────────────┴──────────────────┘ │
│ │ │
│ ┌────────▼────────┐ │
│ │ API Gateway │ │
│ │ (NestJS) │ │
│ └────────┬────────┘ │
│ ┌────────────────┼────────────────┐ │
│ │ │ │ │
│ ┌──────▼──────┐ ┌─────▼─────┐ ┌──────▼──────┐ │
│ │ Core API │ │ IoT Service│ │Media Service│ │
│ │ (NestJS) │ │ (NestJS) │ │ (Node.js) │ │
│ └──────┬──────┘ └─────┬─────┘ └──────┬──────┘ │
│ │ │ │ │
│ ┌──────▼────────────────▼────────────────▼──────┐ │
│ │ Message Bus (RabbitMQ/Redis) │ │
│ └────────────────────────────────────────────────┘ │
│ │ │ │ │
│ ┌──────▼──────┐ ┌─────▼─────┐ ┌──────▼──────┐ │
│ │ PostgreSQL │ │ Redis │ │ S3 │ │
│ │ (Primary DB)│ │ (Cache) │ │ (Storage) │ │
│ └─────────────┘ └───────────┘ └─────────────┘ │
└─────────────────────────────────────────────────────────────┘🛠 Stack Detalhada
Backend
Runtime & Framework
yaml
Runtime:
- Node.js: v20.x LTS
- TypeScript: ^5.3.0
Framework:
- NestJS: ^10.0.0
Motivo: |
- Arquitetura modular (DDD-friendly)
- Dependency Injection nativo
- Decorators para validação, guards, interceptors
- Suporte a WebSockets, GraphQL, CQRS
- Ecossistema maduro
ORM:
- Prisma: ^5.0.0
Motivo: |
- Type-safe queries
- Migrations automáticas
- Introspection de schema
- Melhor performance que TypeORM
- Developer Experience superior
Database:
- PostgreSQL: 15.x
Motivo: |
- ACID compliance
- JSON/JSONB para dados flexíveis
- Full-text search
- Row-level security para multi-tenancy
- Extensões (PostGIS, pg_trgm)
Cache:
- Redis: 7.x
Motivo: |
- Cache de sessões
- Rate limiting
- Pub/Sub para real-time
- Locks distribuídos
Message Queue:
- RabbitMQ: 3.12.x
Motivo: |
- Message routing complexo
- Dead letter queues
- Priorização de mensagens
- Garantias de entrega
- Redis Streams (alternativa):
Motivo: |
- Menor complexidade operacional
- Consumer groups
- Persistência configurável
Real-time:
- Socket.io: ^4.6.0
Motivo: |
- Fallback automático (WebSocket → Polling)
- Rooms e namespaces
- Reconexão automática
- Broadcasting eficiente
API:
- REST: NestJS Controllers
- GraphQL: @nestjs/graphql (opcional para casos complexos)Bibliotecas Essenciais
json
{
"dependencies": {
"@nestjs/core": "^10.0.0",
"@nestjs/common": "^10.0.0",
"@nestjs/platform-express": "^10.0.0",
"@nestjs/platform-socket.io": "^10.0.0",
"@nestjs/config": "^3.0.0",
"@nestjs/jwt": "^10.0.0",
"@nestjs/passport": "^10.0.0",
"@nestjs/swagger": "^7.0.0",
"@nestjs/bull": "^10.0.0",
"@prisma/client": "^5.0.0",
"prisma": "^5.0.0",
"class-validator": "^0.14.0",
"class-transformer": "^0.5.1",
"helmet": "^7.0.0",
"bcrypt": "^5.1.0",
"uuid": "^9.0.0",
"date-fns": "^2.30.0",
"ioredis": "^5.3.0",
"amqplib": "^0.10.0",
"socket.io": "^4.6.0"
},
"devDependencies": {
"@types/node": "^20.0.0",
"@typescript-eslint/eslint-plugin": "^6.0.0",
"@typescript-eslint/parser": "^6.0.0",
"eslint": "^8.0.0",
"prettier": "^3.0.0",
"jest": "^29.0.0",
"@nestjs/testing": "^10.0.0"
}
}Frontend
Framework & Build
yaml
Framework:
- Vue.js: 3.4.x (Composition API)
Motivo: |
- Performance superior ao Vue 2
- TypeScript first-class support
- Composition API (reutilização de lógica)
- Menor bundle size que React
- Curva de aprendizado suave
Build Tool:
- Vite: ^5.0.0
Motivo: |
- HMR instantâneo
- Build otimizado (Rollup)
- Zero-config para TypeScript
- Plugin ecosystem
State Management:
- Pinia: ^2.1.0
Motivo: |
- Store oficial do Vue 3
- TypeScript inference automático
- DevTools integration
- Modular e simples
UI Framework:
- Bootstrap 5: ^5.3.0
Motivo: |
- Componentes prontos e testados
- Grid system responsivo
- Customização via SASS
- Menor que Vuetify
- Alternativa: Vuetify 3 (Material Design)
Styling:
- SASS/SCSS
- CSS Modules
- Design Tokens (variáveis de tema)
Mobile:
- PWA (Progressive Web App)
- Service Workers
- App Manifest
- Offline-first
- Install prompt
- Push notificationsBibliotecas Frontend
json
{
"dependencies": {
"vue": "^3.4.0",
"vue-router": "^4.2.0",
"pinia": "^2.1.0",
"axios": "^1.6.0",
"socket.io-client": "^4.6.0",
"vee-validate": "^4.12.0",
"yup": "^1.3.0",
"bootstrap": "^5.3.0",
"bootstrap-icons": "^1.11.0",
"chart.js": "^4.4.0",
"vue-chartjs": "^5.3.0",
"date-fns": "^2.30.0",
"@vueuse/core": "^10.7.0",
"vue-toastification": "^2.0.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.0",
"vite": "^5.0.0",
"vite-plugin-pwa": "^0.17.0",
"typescript": "^5.3.0",
"sass": "^1.69.0",
"@types/node": "^20.0.0",
"eslint": "^8.0.0",
"eslint-plugin-vue": "^9.0.0",
"prettier": "^3.0.0",
"vitest": "^1.0.0",
"@vue/test-utils": "^2.4.0"
}
}DevOps & Infraestrutura
yaml
Containers:
- Docker: 24.x
- Docker Compose: 2.x
- Multi-stage builds
- Distroless images para produção
Orchestration:
- Docker Swarm (MVP/Stage 1)
- Kubernetes (Stage 2+)
- Helm Charts
- Horizontal Pod Autoscaling
- Ingress NGINX
CI/CD:
- GitHub Actions
- Workflows: test, build, deploy
- Matrix strategy para múltiplas plataformas
- Secrets management
- Cache de dependências
Cloud Provider:
- AWS (recomendado)
- EC2 / ECS / EKS
- RDS PostgreSQL
- ElastiCache Redis
- S3 + CloudFront
- Route 53
- AWS IoT Core
- GCP (alternativa)
- GKE
- Cloud SQL
- Memorystore
- Cloud Storage + CDN
- Cloud IoT Core
Infrastructure as Code:
- Terraform: ^1.6.0
- Módulos reutilizáveis
- State remoto (S3 + DynamoDB)
- Workspaces por ambiente
Monitoring:
- Prometheus: Coleta de métricas
- Grafana: Dashboards e alertas
- Node Exporter: Métricas de sistema
- Redis Exporter: Métricas de cache
Logging:
- ELK Stack (self-hosted)
- Elasticsearch: Armazenamento
- Logstash: Pipeline
- Kibana: Visualização
- AWS CloudWatch (managed)
APM:
- OpenTelemetry: Instrumentação
- Jaeger: Distributed tracing
- Tempo (alternativa do Grafana)IoT Stack
yaml
Protocolos:
- MQTT: Telemetria de sensores
- HTTP/REST: Comandos síncronos
- WebSocket: Streaming de dados
Edge Devices:
- Raspberry Pi 4: Tablets, displays
- ESP32: Botões, sensores
- Arduino: Protótipos
Cloud IoT:
- AWS IoT Core
- Thing Registry
- Device Shadows
- Rules Engine
- MQTT Broker
- Mosquitto (self-hosted)
- Bridge com cloud
Bibliotecas:
- MQTT.js: Cliente Node.js
- paho-mqtt: Cliente Python (edge)
Security:
- X.509 certificates
- IAM policies
- TLS mutual authenticationSegurança
yaml
Authentication:
- Keycloak: 23.x
- OAuth 2.0 / OpenID Connect
- Social login (Google, Facebook)
- Multi-factor authentication
- User Federation (LDAP)
- JWT tokens
- Access token: 15min
- Refresh token: 7 dias
- Token rotation
Authorization:
- Role-Based Access Control (RBAC)
- Attribute-Based Access Control (ABAC)
- Multi-tenant isolation
API Security:
- Helmet: HTTP headers
- CORS: Cross-origin configuration
- Rate Limiting: Redis + Express
- Input Validation: class-validator
- SQL Injection: Prisma parameterized queries
- XSS: CSP headers
WAF:
- AWS WAF
- ModSecurity (self-hosted)
Secrets Management:
- AWS Secrets Manager
- HashiCorp Vault (alternativa)
- Environment variables para devMídia & Storage
yaml
Storage:
- S3: Vídeos, imagens, documentos
- CloudFront: CDN global
- Lifecycle policies: Migração para Glacier
Video Processing:
- AWS MediaConvert: Transcoding
- FFmpeg (self-hosted): Alternativa
- Adaptive Bitrate Streaming (HLS/DASH)
Image Processing:
- Sharp: Resize, crop, optimize
- AWS Lambda: Processamento serverless
- Thumbor (alternativa)🏗 Arquitetura de Serviços
Visão Macro (Event-Driven)
┌────────────────────────────────────────────────────────────────┐
│ CLIENTS │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ Web App │ │ Admin Panel │ │ IoT Devices │ │
│ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │
└─────────┼──────────────────┼──────────────────┼────────────────┘
│ │ │
│ │ │
┌─────────▼──────────────────▼──────────────────▼────────────────┐
│ API GATEWAY │
│ - Authentication (JWT) │
│ - Rate Limiting │
│ - Request Routing │
│ - Load Balancing │
└─────────┬────────────────────────────────────────────┬─────────┘
│ │
┌─────────▼─────────────────────────────────────────────▼─────────┐
│ MICROSERVICES LAYER │
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ Core API │ │ IoT Service │ │Media Service│ │
│ │ │ │ │ │ │ │
│ │ - Accounts │ │ - Devices │ │ - Videos │ │
│ │ - Clubs │ │ - Telemetry │ │ - Images │ │
│ │ - Members │ │ - Commands │ │ - Transcode │ │
│ │ - Events │ └─────┬───────┘ └─────┬───────┘ │
│ │ - Bookings │ │ │ │
│ └─────┬───────┘ │ │ │
│ │ │ │ │
│ ┌─────▼─────────┐ ┌───▼──────────┐ ┌──▼──────────┐ │
│ │Notification │ │ Analytics │ │ Billing │ │
│ │ Service │ │ Service │ │ Service │ │
│ └─────┬─────────┘ └───┬──────────┘ └──┬──────────┘ │
└────────┼────────────────┼─────────────────┼───────────────────┘
│ │ │
┌────────▼────────────────▼─────────────────▼───────────────────┐
│ MESSAGE BUS (RabbitMQ) │
│ │
│ Exchanges: │
│ - club.events → [event.created, event.updated] │
│ - member.events → [member.joined, member.checkin] │
│ - iot.events → [device.telemetry, button.pressed] │
│ - media.events → [video.uploaded, video.transcoded] │
└────────────────────────────────────────────────────────────────┘
│ │ │
┌────────▼────────────────▼─────────────────▼───────────────────┐
│ DATA LAYER │
│ │
│ ┌───────────┐ ┌───────────┐ ┌───────────┐ ┌──────────┐ │
│ │PostgreSQL │ │ Redis │ │ S3 │ │Time-series│ │
│ │ │ │ │ │ │ │ (TimescaleDB)│
│ │ - Accounts│ │ - Cache │ │ - Media │ │ - IoT Data│ │
│ │ - Clubs │ │ - Sessions│ │ - Assets │ │ - Metrics │ │
│ │ - Members │ │ - Pub/Sub │ │ │ │ │ │
│ └───────────┘ └───────────┘ └───────────┘ └──────────┘ │
└────────────────────────────────────────────────────────────────┘Comunicação entre Serviços
typescript
// Event-Driven (Assíncrono)
// Caso: Membro faz check-in → Notificação + Atualização de Analytics
// 1. Core API publica evento
await eventBus.publish('member.events', {
type: 'member.checkedin',
payload: {
memberId: '123',
clubId: '456',
timestamp: new Date(),
},
});
// 2. Notification Service consome
@EventPattern('member.checkedin')
async handleMemberCheckin(data: MemberCheckinEvent) {
await this.sendNotification({
userId: data.memberId,
title: 'Check-in realizado!',
body: 'Bem-vindo ao clube',
});
}
// 3. Analytics Service consome
@EventPattern('member.checkedin')
async handleMemberCheckin(data: MemberCheckinEvent) {
await this.incrementMetric('daily_checkins', {
clubId: data.clubId,
date: data.timestamp,
});
}
// Request-Response (Síncrono)
// Caso: Validar dispositivo IoT antes de aceitar comando
// Core API chama IoT Service
const device = await this.iotService.validateDevice(deviceId);
if (!device.active) {
throw new ForbiddenException('Device inactive');
}Padrões de Integração
yaml
Padrão Saga (Transações Distribuídas):
Cenário: Criar evento → Reservar quadra → Enviar notificações
Fluxo:
1. EventService.createEvent() → Salva evento
2. Publica 'event.created' → RabbitMQ
3. BookingService consome → Tenta reservar quadra
- Sucesso: Publica 'booking.confirmed'
- Falha: Publica 'booking.failed' (compensa evento)
4. NotificationService consome 'booking.confirmed'
→ Envia notificações
Padrão CQRS (Command Query Responsibility Segregation):
Write Model: PostgreSQL (source of truth)
Read Model: Redis + Elasticsearch (projeções)
Exemplo:
- Comando: CreateClub → Escreve em PostgreSQL
- Query: GetClubDetails → Lê de Redis cache
- Query: SearchClubs → Lê de Elasticsearch
Padrão API Gateway:
- Single entry point
- Authentication/Authorization
- Request aggregation
- Protocol translation (REST → gRPC)📁 Estrutura do Monorepo
Organização de Workspaces
sport-tech-club/
├── .github/
│ └── workflows/
│ ├── ci.yml # Testes, lint
│ ├── cd-api.yml # Deploy da API
│ └── cd-web.yml # Deploy do frontend
│
├── apps/
│ ├── api/ # NestJS - API Principal
│ │ ├── src/
│ │ │ ├── modules/
│ │ │ │ ├── accounts/ # Contexto: Contas
│ │ │ │ │ ├── domain/
│ │ │ │ │ │ ├── entities/
│ │ │ │ │ │ │ ├── account.entity.ts
│ │ │ │ │ │ │ └── subscription.entity.ts
│ │ │ │ │ │ ├── repositories/
│ │ │ │ │ │ │ └── account.repository.ts
│ │ │ │ │ │ ├── services/
│ │ │ │ │ │ │ └── subscription.service.ts
│ │ │ │ │ │ └── events/
│ │ │ │ │ │ └── account-created.event.ts
│ │ │ │ │ ├── application/
│ │ │ │ │ │ ├── use-cases/
│ │ │ │ │ │ │ ├── create-account.use-case.ts
│ │ │ │ │ │ │ └── upgrade-plan.use-case.ts
│ │ │ │ │ │ ├── dtos/
│ │ │ │ │ │ │ ├── create-account.dto.ts
│ │ │ │ │ │ │ └── account-response.dto.ts
│ │ │ │ │ │ └── mappers/
│ │ │ │ │ │ └── account.mapper.ts
│ │ │ │ │ ├── infrastructure/
│ │ │ │ │ │ ├── repositories/
│ │ │ │ │ │ │ └── prisma-account.repository.ts
│ │ │ │ │ │ └── services/
│ │ │ │ │ │ └── stripe-payment.service.ts
│ │ │ │ │ └── presentation/
│ │ │ │ │ ├── controllers/
│ │ │ │ │ │ └── accounts.controller.ts
│ │ │ │ │ └── guards/
│ │ │ │ │ └── account-owner.guard.ts
│ │ │ │ │
│ │ │ │ ├── clubs/ # Contexto: Clubes
│ │ │ │ ├── members/ # Contexto: Membros
│ │ │ │ ├── events/ # Contexto: Eventos
│ │ │ │ ├── bookings/ # Contexto: Reservas
│ │ │ │ └── shared/ # Infraestrutura compartilhada
│ │ │ │ ├── guards/
│ │ │ │ ├── interceptors/
│ │ │ │ ├── filters/
│ │ │ │ └── decorators/
│ │ │ │
│ │ │ ├── config/
│ │ │ │ ├── database.config.ts
│ │ │ │ ├── redis.config.ts
│ │ │ │ └── rabbitmq.config.ts
│ │ │ │
│ │ │ ├── common/
│ │ │ │ ├── interfaces/
│ │ │ │ ├── types/
│ │ │ │ └── constants/
│ │ │ │
│ │ │ ├── app.module.ts
│ │ │ └── main.ts
│ │ │
│ │ ├── prisma/
│ │ │ ├── schema.prisma
│ │ │ ├── seed.ts
│ │ │ └── migrations/
│ │ │
│ │ ├── test/
│ │ │ ├── unit/
│ │ │ ├── integration/
│ │ │ └── e2e/
│ │ │
│ │ ├── Dockerfile
│ │ ├── package.json
│ │ └── tsconfig.json
│ │
│ ├── web/ # Vue.js - Aplicação Web
│ │ ├── src/
│ │ │ ├── modules/
│ │ │ │ ├── auth/
│ │ │ │ │ ├── composables/
│ │ │ │ │ │ └── useAuth.ts
│ │ │ │ │ ├── stores/
│ │ │ │ │ │ └── authStore.ts
│ │ │ │ │ ├── views/
│ │ │ │ │ │ ├── LoginView.vue
│ │ │ │ │ │ └── RegisterView.vue
│ │ │ │ │ └── router/
│ │ │ │ │ └── authRoutes.ts
│ │ │ │ │
│ │ │ │ ├── clubs/
│ │ │ │ ├── members/
│ │ │ │ ├── events/
│ │ │ │ └── bookings/
│ │ │ │
│ │ │ ├── shared/
│ │ │ │ ├── components/
│ │ │ │ │ ├── AppHeader.vue
│ │ │ │ │ ├── AppSidebar.vue
│ │ │ │ │ └── DataTable.vue
│ │ │ │ ├── composables/
│ │ │ │ │ ├── useApi.ts
│ │ │ │ │ ├── useWebSocket.ts
│ │ │ │ │ └── useNotifications.ts
│ │ │ │ └── utils/
│ │ │ │ ├── validators.ts
│ │ │ │ └── formatters.ts
│ │ │ │
│ │ │ ├── layouts/
│ │ │ │ ├── DefaultLayout.vue
│ │ │ │ └── AuthLayout.vue
│ │ │ │
│ │ │ ├── router/
│ │ │ │ └── index.ts
│ │ │ │
│ │ │ ├── stores/
│ │ │ │ └── index.ts
│ │ │ │
│ │ │ ├── assets/
│ │ │ ├── styles/
│ │ │ │ ├── variables.scss
│ │ │ │ └── main.scss
│ │ │ │
│ │ │ ├── App.vue
│ │ │ └── main.ts
│ │ │
│ │ ├── public/
│ │ │ ├── manifest.json # PWA manifest
│ │ │ └── service-worker.js # Service worker
│ │ │
│ │ ├── Dockerfile
│ │ ├── vite.config.ts
│ │ ├── package.json
│ │ └── tsconfig.json
│ │
│ └── admin/ # Vue.js - Painel Admin
│ └── (estrutura similar ao web)
│
├── services/
│ ├── iot-gateway/ # NestJS - Gateway IoT
│ │ ├── src/
│ │ │ ├── mqtt/
│ │ │ │ ├── mqtt.service.ts
│ │ │ │ └── mqtt.controller.ts
│ │ │ ├── devices/
│ │ │ │ ├── device.repository.ts
│ │ │ │ └── device.service.ts
│ │ │ ├── telemetry/
│ │ │ │ ├── telemetry.processor.ts
│ │ │ │ └── telemetry.storage.ts
│ │ │ └── main.ts
│ │ └── Dockerfile
│ │
│ ├── media-service/ # Node.js - Processamento de Mídia
│ │ ├── src/
│ │ │ ├── upload/
│ │ │ ├── transcode/
│ │ │ ├── storage/
│ │ │ └── main.ts
│ │ └── Dockerfile
│ │
│ └── notification-service/ # NestJS - Notificações
│ ├── src/
│ │ ├── push/
│ │ ├── email/
│ │ ├── sms/
│ │ └── main.ts
│ └── Dockerfile
│
├── libs/
│ ├── shared/ # Código compartilhado
│ │ ├── src/
│ │ │ ├── dtos/ # DTOs compartilhados
│ │ │ │ ├── account.dto.ts
│ │ │ │ ├── club.dto.ts
│ │ │ │ └── member.dto.ts
│ │ │ ├── types/ # Types/Interfaces
│ │ │ │ ├── auth.types.ts
│ │ │ │ └── pagination.types.ts
│ │ │ ├── utils/ # Utilitários
│ │ │ │ ├── date.utils.ts
│ │ │ │ └── string.utils.ts
│ │ │ ├── constants/
│ │ │ │ └── roles.constants.ts
│ │ │ └── index.ts
│ │ ├── package.json
│ │ └── tsconfig.json
│ │
│ ├── domain/ # Domain entities compartilhadas
│ │ └── src/
│ │ ├── entities/
│ │ ├── value-objects/
│ │ └── events/
│ │
│ └── ui/ # Componentes UI compartilhados
│ └── src/
│ ├── components/
│ │ ├── Button.vue
│ │ ├── Input.vue
│ │ └── Modal.vue
│ ├── composables/
│ └── styles/
│
├── infrastructure/
│ ├── docker/
│ │ ├── docker-compose.yml # Dev environment
│ │ ├── docker-compose.prod.yml # Production
│ │ └── nginx/
│ │ └── nginx.conf
│ │
│ ├── k8s/ # Kubernetes manifests
│ │ ├── namespaces/
│ │ ├── deployments/
│ │ │ ├── api-deployment.yml
│ │ │ ├── web-deployment.yml
│ │ │ └── iot-deployment.yml
│ │ ├── services/
│ │ ├── ingress/
│ │ └── configmaps/
│ │
│ └── terraform/ # Infrastructure as Code
│ ├── modules/
│ │ ├── networking/
│ │ ├── compute/
│ │ ├── database/
│ │ └── storage/
│ ├── environments/
│ │ ├── dev/
│ │ ├── staging/
│ │ └── production/
│ └── main.tf
│
├── docs/
│ ├── architecture/
│ │ ├── ADR/ # Architecture Decision Records
│ │ ├── diagrams/
│ │ └── TECH-STACK.md
│ ├── api/
│ │ └── openapi.yml
│ └── guides/
│ ├── CONTRIBUTING.md
│ └── DEPLOYMENT.md
│
├── scripts/
│ ├── setup-dev.sh
│ ├── seed-database.ts
│ └── generate-types.ts
│
├── .editorconfig
├── .gitignore
├── .prettierrc
├── .eslintrc.js
├── package.json # Root package.json (workspaces)
├── pnpm-workspace.yaml # PNPM workspaces
└── README.mdWorkspaces Configuration
yaml
# pnpm-workspace.yaml
packages:
- 'apps/*'
- 'services/*'
- 'libs/*'json
// package.json (root)
{
"name": "sport-tech-club",
"version": "1.0.0",
"private": true,
"workspaces": [
"apps/*",
"services/*",
"libs/*"
],
"scripts": {
"dev:api": "pnpm --filter api dev",
"dev:web": "pnpm --filter web dev",
"dev:all": "concurrently \"pnpm dev:api\" \"pnpm dev:web\"",
"build": "pnpm -r build",
"test": "pnpm -r test",
"lint": "pnpm -r lint",
"format": "prettier --write \"**/*.{ts,tsx,vue,md}\"",
"typecheck": "pnpm -r typecheck",
"docker:up": "docker-compose -f infrastructure/docker/docker-compose.yml up -d",
"docker:down": "docker-compose -f infrastructure/docker/docker-compose.yml down"
},
"devDependencies": {
"concurrently": "^8.2.0",
"prettier": "^3.0.0",
"typescript": "^5.3.0"
}
}🎨 Padrões de Código
Backend (NestJS + DDD)
Entity (Domain Layer)
typescript
// libs/domain/src/entities/member.entity.ts
export class Member {
private constructor(
public readonly id: string,
private _name: string,
private _email: Email,
private _status: MemberStatus,
private _joinedAt: Date,
private _expiresAt: Date | null
) {}
static create(props: CreateMemberProps): Member {
// Validações de domínio
if (!props.name || props.name.trim().length < 3) {
throw new DomainError('Member name must be at least 3 characters');
}
const email = Email.create(props.email);
const id = crypto.randomUUID();
return new Member(
id,
props.name.trim(),
email,
MemberStatus.ACTIVE,
new Date(),
null
);
}
// Getters
get name(): string {
return this._name;
}
get email(): Email {
return this._email;
}
get status(): MemberStatus {
return this._status;
}
get isActive(): boolean {
return this._status === MemberStatus.ACTIVE;
}
get isExpired(): boolean {
return this._expiresAt !== null && this._expiresAt < new Date();
}
// Comportamentos de domínio
changeName(newName: string): void {
if (!newName || newName.trim().length < 3) {
throw new DomainError('Invalid name');
}
this._name = newName.trim();
}
suspend(): void {
if (this._status === MemberStatus.SUSPENDED) {
throw new DomainError('Member is already suspended');
}
this._status = MemberStatus.SUSPENDED;
}
reactivate(): void {
if (this._status === MemberStatus.ACTIVE) {
throw new DomainError('Member is already active');
}
this._status = MemberStatus.ACTIVE;
}
renew(durationDays: number): void {
const newExpiry = addDays(new Date(), durationDays);
this._expiresAt = newExpiry;
this._status = MemberStatus.ACTIVE;
}
}
// Value Object
export class Email {
private constructor(private readonly value: string) {}
static create(email: string): Email {
if (!this.isValid(email)) {
throw new DomainError('Invalid email format');
}
return new Email(email.toLowerCase().trim());
}
private static isValid(email: string): boolean {
const regex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return regex.test(email);
}
toString(): string {
return this.value;
}
equals(other: Email): boolean {
return this.value === other.value;
}
}
// Domain Event
export class MemberCreatedEvent implements DomainEvent {
readonly occurredAt = new Date();
readonly eventType = 'member.created';
constructor(
public readonly memberId: string,
public readonly clubId: string,
public readonly name: string,
public readonly email: string
) {}
}Use Case (Application Layer)
typescript
// apps/api/src/modules/members/application/use-cases/create-member.use-case.ts
@Injectable()
export class CreateMemberUseCase {
constructor(
private readonly memberRepository: MemberRepository,
private readonly clubRepository: ClubRepository,
private readonly eventBus: EventBus,
private readonly logger: Logger
) {}
async execute(dto: CreateMemberDto): Promise<MemberResponseDto> {
// 1. Validar clube existe
const club = await this.clubRepository.findById(dto.clubId);
if (!club) {
throw new NotFoundException(`Club ${dto.clubId} not found`);
}
// 2. Validar email único
const existingMember = await this.memberRepository.findByEmail(
dto.email,
dto.clubId
);
if (existingMember) {
throw new ConflictException('Email already registered in this club');
}
// 3. Criar entidade de domínio
const member = Member.create({
name: dto.name,
email: dto.email,
clubId: dto.clubId,
});
// 4. Persistir
await this.memberRepository.save(member);
// 5. Publicar evento
const event = new MemberCreatedEvent(
member.id,
dto.clubId,
member.name,
member.email.toString()
);
await this.eventBus.publish(event);
this.logger.log(`Member created: ${member.id}`);
// 6. Retornar DTO
return MemberMapper.toResponseDto(member);
}
}Controller (Presentation Layer)
typescript
// apps/api/src/modules/members/presentation/controllers/members.controller.ts
@Controller('members')
@ApiTags('members')
@UseGuards(JwtAuthGuard, TenantGuard)
export class MembersController {
constructor(
private readonly createMemberUseCase: CreateMemberUseCase,
private readonly listMembersUseCase: ListMembersUseCase
) {}
@Post()
@ApiOperation({ summary: 'Create new member' })
@ApiResponse({ status: 201, type: MemberResponseDto })
@ApiResponse({ status: 400, description: 'Bad Request' })
@ApiResponse({ status: 409, description: 'Email already exists' })
async create(
@Body() dto: CreateMemberDto,
@CurrentUser() user: UserPayload
): Promise<MemberResponseDto> {
return this.createMemberUseCase.execute(dto);
}
@Get()
@ApiOperation({ summary: 'List members' })
@ApiResponse({ status: 200, type: [MemberResponseDto] })
async list(
@Query() query: ListMembersQueryDto,
@CurrentUser() user: UserPayload
): Promise<PaginatedResponseDto<MemberResponseDto>> {
return this.listMembersUseCase.execute(query);
}
@Get(':id')
@ApiOperation({ summary: 'Get member by ID' })
async findOne(@Param('id', ParseUUIDPipe) id: string): Promise<MemberResponseDto> {
// ...
}
}Repository Pattern
typescript
// apps/api/src/modules/members/domain/repositories/member.repository.ts
export interface MemberRepository {
findById(id: string): Promise<Member | null>;
findByEmail(email: string, clubId: string): Promise<Member | null>;
findByClubId(clubId: string, options: PaginationOptions): Promise<PaginatedResult<Member>>;
save(member: Member): Promise<void>;
delete(id: string): Promise<void>;
}
// apps/api/src/modules/members/infrastructure/repositories/prisma-member.repository.ts
@Injectable()
export class PrismaMemberRepository implements MemberRepository {
constructor(private readonly prisma: PrismaService) {}
async findById(id: string): Promise<Member | null> {
const data = await this.prisma.member.findUnique({
where: { id },
include: { club: true },
});
return data ? MemberMapper.toDomain(data) : null;
}
async save(member: Member): Promise<void> {
const data = MemberMapper.toPersistence(member);
await this.prisma.member.upsert({
where: { id: member.id },
create: data,
update: data,
});
}
async delete(id: string): Promise<void> {
await this.prisma.member.delete({ where: { id } });
}
}Frontend (Vue.js 3 + Composition API)
Composable (Reusable Logic)
typescript
// apps/web/src/shared/composables/useApi.ts
export function useApi<T>(url: string, options?: UseApiOptions) {
const data = ref<T | null>(null);
const error = ref<Error | null>(null);
const loading = ref(false);
const execute = async (config?: AxiosRequestConfig) => {
loading.value = true;
error.value = null;
try {
const response = await api.get<T>(url, config);
data.value = response.data;
} catch (err) {
error.value = err as Error;
throw err;
} finally {
loading.value = false;
}
};
if (options?.immediate) {
execute();
}
return { data, error, loading, execute };
}
// apps/web/src/modules/members/composables/useMembers.ts
export function useMembers(clubId: string) {
const { data: members, loading, error, execute } = useApi<Member[]>(
`/api/clubs/${clubId}/members`
);
const createMember = async (dto: CreateMemberDto) => {
await api.post(`/api/clubs/${clubId}/members`, dto);
await execute(); // Refresh list
};
const deleteMember = async (memberId: string) => {
await api.delete(`/api/members/${memberId}`);
await execute();
};
return {
members,
loading,
error,
createMember,
deleteMember,
refresh: execute,
};
}Component (Vue SFC)
vue
<!-- apps/web/src/modules/members/views/MembersListView.vue -->
<template>
<div class="members-list">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1>Members</h1>
<button class="btn btn-primary" @click="showCreateModal = true">
<i class="bi bi-plus"></i> Add Member
</button>
</div>
<div v-if="loading" class="text-center">
<div class="spinner-border" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>
<div v-else-if="error" class="alert alert-danger">
{{ error.message }}
</div>
<div v-else>
<DataTable
:items="members"
:columns="columns"
@row-click="handleRowClick"
/>
</div>
<CreateMemberModal
v-model="showCreateModal"
:club-id="clubId"
@created="handleMemberCreated"
/>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue';
import { useRoute } from 'vue-router';
import { useMembers } from '../composables/useMembers';
import { useToast } from 'vue-toastification';
import DataTable from '@/shared/components/DataTable.vue';
import CreateMemberModal from '../components/CreateMemberModal.vue';
const route = useRoute();
const toast = useToast();
const clubId = computed(() => route.params.clubId as string);
const showCreateModal = ref(false);
const { members, loading, error, refresh } = useMembers(clubId.value);
const columns = [
{ key: 'name', label: 'Name', sortable: true },
{ key: 'email', label: 'Email' },
{ key: 'status', label: 'Status', formatter: formatStatus },
{ key: 'joinedAt', label: 'Joined', formatter: formatDate },
];
const handleRowClick = (member: Member) => {
router.push({ name: 'member-detail', params: { id: member.id } });
};
const handleMemberCreated = () => {
showCreateModal.value = false;
toast.success('Member created successfully');
refresh();
};
function formatStatus(value: string): string {
const badges = {
ACTIVE: '<span class="badge bg-success">Active</span>',
SUSPENDED: '<span class="badge bg-warning">Suspended</span>',
EXPIRED: '<span class="badge bg-danger">Expired</span>',
};
return badges[value] || value;
}
function formatDate(value: string): string {
return new Date(value).toLocaleDateString();
}
</script>
<style scoped lang="scss">
.members-list {
padding: 2rem;
}
</style>Store (Pinia)
typescript
// apps/web/src/modules/auth/stores/authStore.ts
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
import { api } from '@/shared/services/api';
export const useAuthStore = defineStore('auth', () => {
// State
const user = ref<User | null>(null);
const token = ref<string | null>(localStorage.getItem('token'));
// Getters
const isAuthenticated = computed(() => token.value !== null);
const userRole = computed(() => user.value?.role || 'guest');
// Actions
async function login(email: string, password: string) {
const response = await api.post<AuthResponse>('/auth/login', {
email,
password,
});
token.value = response.data.accessToken;
user.value = response.data.user;
localStorage.setItem('token', token.value);
api.defaults.headers.common['Authorization'] = `Bearer ${token.value}`;
}
async function logout() {
token.value = null;
user.value = null;
localStorage.removeItem('token');
delete api.defaults.headers.common['Authorization'];
}
async function fetchCurrentUser() {
if (!token.value) return;
const response = await api.get<User>('/auth/me');
user.value = response.data;
}
// Inicialização
if (token.value) {
api.defaults.headers.common['Authorization'] = `Bearer ${token.value}`;
fetchCurrentUser();
}
return {
// State
user,
token,
// Getters
isAuthenticated,
userRole,
// Actions
login,
logout,
fetchCurrentUser,
};
});Naming Conventions
typescript
// Classes: PascalCase
class MemberService {}
class CreateMemberUseCase {}
// Interfaces: PascalCase com 'I' prefix (opcional)
interface MemberRepository {}
interface IPaymentGateway {}
// Types: PascalCase
type MemberStatus = 'ACTIVE' | 'SUSPENDED' | 'EXPIRED';
type PaginationOptions = { page: number; limit: number };
// Constants: UPPER_SNAKE_CASE
const MAX_UPLOAD_SIZE = 5 * 1024 * 1024;
const DEFAULT_PAGE_SIZE = 20;
// Enums: PascalCase (enum) + UPPER_CASE (values)
enum UserRole {
ADMIN = 'ADMIN',
MANAGER = 'MANAGER',
MEMBER = 'MEMBER',
}
// Functions: camelCase
function calculateMembershipFee() {}
async function sendWelcomeEmail() {}
// Variables: camelCase
const memberCount = 10;
let isLoading = false;
// Private properties: underscore prefix
class Member {
private _email: string;
private _status: MemberStatus;
}
// Composables (Vue): use + PascalCase
function useAuth() {}
function useMembers() {}
// Components (Vue): PascalCase
// MemberCard.vue
// DataTable.vue
// CreateMemberModal.vue
// Files:
// - Components: PascalCase.vue (MemberCard.vue)
// - Composables: camelCase.ts (useMembers.ts)
// - Stores: camelCase.ts (authStore.ts)
// - Services: kebab-case.service.ts (payment.service.ts)
// - Use Cases: kebab-case.use-case.ts (create-member.use-case.ts)🧪 Estratégia de Testes
Pirâmide de Testes
/\
/E2E\ 10% - Fluxos críticos end-to-end
/______\
/ \
/Integration\ 20% - Integração entre camadas
/____________\
/ \
/ Unit Tests \ 70% - Lógica de domínio e negócio
/__________________\Unit Tests (Jest)
typescript
// apps/api/src/modules/members/domain/entities/member.entity.spec.ts
describe('Member Entity', () => {
describe('create', () => {
it('should create a valid member', () => {
const member = Member.create({
name: 'John Doe',
email: 'john@example.com',
clubId: 'club-123',
});
expect(member.id).toBeDefined();
expect(member.name).toBe('John Doe');
expect(member.email.toString()).toBe('john@example.com');
expect(member.status).toBe(MemberStatus.ACTIVE);
});
it('should throw if name is too short', () => {
expect(() =>
Member.create({
name: 'Jo',
email: 'john@example.com',
clubId: 'club-123',
})
).toThrow('Member name must be at least 3 characters');
});
it('should throw if email is invalid', () => {
expect(() =>
Member.create({
name: 'John Doe',
email: 'invalid-email',
clubId: 'club-123',
})
).toThrow('Invalid email format');
});
});
describe('suspend', () => {
it('should suspend active member', () => {
const member = createTestMember({ status: MemberStatus.ACTIVE });
member.suspend();
expect(member.status).toBe(MemberStatus.SUSPENDED);
});
it('should throw if member is already suspended', () => {
const member = createTestMember({ status: MemberStatus.SUSPENDED });
expect(() => member.suspend()).toThrow('Member is already suspended');
});
});
describe('renew', () => {
it('should extend expiration date', () => {
const member = createTestMember();
const before = new Date();
member.renew(30);
expect(member.expiresAt).toBeInstanceOf(Date);
expect(member.expiresAt!.getTime()).toBeGreaterThan(before.getTime());
});
});
});
// Test helpers
function createTestMember(overrides?: Partial<CreateMemberProps>): Member {
return Member.create({
name: 'Test Member',
email: 'test@example.com',
clubId: 'club-123',
...overrides,
});
}Integration Tests
typescript
// apps/api/test/integration/members.integration.spec.ts
describe('Members API Integration', () => {
let app: INestApplication;
let prisma: PrismaService;
let authToken: string;
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleRef.createNestApplication();
prisma = moduleRef.get<PrismaService>(PrismaService);
await app.init();
});
beforeEach(async () => {
// Clean database
await prisma.member.deleteMany();
await prisma.club.deleteMany();
await prisma.account.deleteMany();
// Setup test data
const account = await createTestAccount(prisma);
const club = await createTestClub(prisma, account.id);
// Get auth token
authToken = await getAuthToken(app, 'admin@example.com', 'password');
});
afterAll(async () => {
await prisma.$disconnect();
await app.close();
});
describe('POST /api/members', () => {
it('should create member with valid data', async () => {
const response = await request(app.getHttpServer())
.post('/api/members')
.set('Authorization', `Bearer ${authToken}`)
.send({
name: 'John Doe',
email: 'john@example.com',
clubId: 'club-123',
})
.expect(201);
expect(response.body).toMatchObject({
id: expect.any(String),
name: 'John Doe',
email: 'john@example.com',
status: 'ACTIVE',
});
// Verify in database
const member = await prisma.member.findUnique({
where: { id: response.body.id },
});
expect(member).toBeDefined();
expect(member!.name).toBe('John Doe');
});
it('should return 409 if email already exists', async () => {
// Create existing member
await createTestMember(prisma, {
email: 'john@example.com',
clubId: 'club-123',
});
const response = await request(app.getHttpServer())
.post('/api/members')
.set('Authorization', `Bearer ${authToken}`)
.send({
name: 'John Doe',
email: 'john@example.com',
clubId: 'club-123',
})
.expect(409);
expect(response.body.message).toContain('Email already registered');
});
it('should return 400 for invalid email', async () => {
await request(app.getHttpServer())
.post('/api/members')
.set('Authorization', `Bearer ${authToken}`)
.send({
name: 'John Doe',
email: 'invalid-email',
clubId: 'club-123',
})
.expect(400);
});
it('should return 401 without auth token', async () => {
await request(app.getHttpServer())
.post('/api/members')
.send({
name: 'John Doe',
email: 'john@example.com',
clubId: 'club-123',
})
.expect(401);
});
});
describe('GET /api/members', () => {
it('should list members with pagination', async () => {
// Create test members
await Promise.all([
createTestMember(prisma, { name: 'Member 1' }),
createTestMember(prisma, { name: 'Member 2' }),
createTestMember(prisma, { name: 'Member 3' }),
]);
const response = await request(app.getHttpServer())
.get('/api/members?page=1&limit=2')
.set('Authorization', `Bearer ${authToken}`)
.expect(200);
expect(response.body).toMatchObject({
data: expect.arrayContaining([
expect.objectContaining({ name: expect.any(String) }),
]),
meta: {
page: 1,
limit: 2,
total: 3,
totalPages: 2,
},
});
});
});
});E2E Tests (Playwright)
typescript
// apps/web/tests/e2e/members.e2e.spec.ts
import { test, expect } from '@playwright/test';
test.describe('Member Management', () => {
test.beforeEach(async ({ page }) => {
// Login
await page.goto('/login');
await page.fill('input[name="email"]', 'admin@example.com');
await page.fill('input[name="password"]', 'password123');
await page.click('button[type="submit"]');
// Wait for redirect
await page.waitForURL('/dashboard');
});
test('should create new member', async ({ page }) => {
// Navigate to members page
await page.goto('/members');
// Click create button
await page.click('button:has-text("Add Member")');
// Fill form
await page.fill('input[name="name"]', 'John Doe');
await page.fill('input[name="email"]', 'john@example.com');
await page.fill('input[name="phone"]', '+55 11 99999-9999');
// Submit
await page.click('button:has-text("Save")');
// Verify success toast
await expect(page.locator('.toast-success')).toContainText(
'Member created successfully'
);
// Verify member appears in list
await expect(page.locator('table')).toContainText('John Doe');
await expect(page.locator('table')).toContainText('john@example.com');
});
test('should display validation errors', async ({ page }) => {
await page.goto('/members');
await page.click('button:has-text("Add Member")');
// Try to submit empty form
await page.click('button:has-text("Save")');
// Verify error messages
await expect(page.locator('.error-message')).toContainText(
'Name is required'
);
await expect(page.locator('.error-message')).toContainText(
'Email is required'
);
});
test('should edit existing member', async ({ page }) => {
await page.goto('/members');
// Click edit button on first row
await page.click('table tbody tr:first-child button[aria-label="Edit"]');
// Change name
await page.fill('input[name="name"]', 'Jane Doe Updated');
await page.click('button:has-text("Save")');
// Verify update
await expect(page.locator('.toast-success')).toContainText(
'Member updated successfully'
);
await expect(page.locator('table')).toContainText('Jane Doe Updated');
});
test('should delete member', async ({ page }) => {
await page.goto('/members');
// Click delete button
await page.click('table tbody tr:first-child button[aria-label="Delete"]');
// Confirm deletion
await page.click('button:has-text("Confirm")');
// Verify deletion
await expect(page.locator('.toast-success')).toContainText(
'Member deleted successfully'
);
});
});Test Configuration
typescript
// jest.config.js (Backend)
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
roots: ['<rootDir>/src', '<rootDir>/test'],
testMatch: ['**/*.spec.ts', '**/*.test.ts'],
collectCoverageFrom: [
'src/**/*.ts',
'!src/**/*.dto.ts',
'!src/**/*.interface.ts',
'!src/main.ts',
],
coverageThresholds: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80,
},
},
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1',
'^@libs/(.*)$': '<rootDir>/../../libs/$1',
},
};
// vitest.config.ts (Frontend)
import { defineConfig } from 'vitest/config';
import vue from '@vitejs/plugin-vue';
export default defineConfig({
plugins: [vue()],
test: {
globals: true,
environment: 'jsdom',
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
exclude: ['node_modules/', 'dist/', '**/*.spec.ts'],
},
},
});🚀 Deploy & CI/CD
GitHub Actions Workflows
yaml
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main, develop]
pull_request:
branches: [main, develop]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v2
with:
version: 8
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- run: pnpm lint
- run: pnpm format:check
typecheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v2
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- run: pnpm typecheck
test-api:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:15
env:
POSTGRES_PASSWORD: postgres
POSTGRES_DB: sport_tech_test
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
redis:
image: redis:7
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 6379:6379
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v2
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- run: pnpm --filter api prisma:generate
- run: pnpm --filter api prisma:migrate:deploy
env:
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/sport_tech_test
- run: pnpm --filter api test:cov
env:
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/sport_tech_test
REDIS_URL: redis://localhost:6379
- uses: codecov/codecov-action@v3
with:
files: ./apps/api/coverage/lcov.info
flags: api
test-web:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v2
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- run: pnpm --filter web test:unit
- uses: codecov/codecov-action@v3
with:
files: ./apps/web/coverage/lcov.info
flags: web
e2e:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v2
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- run: npx playwright install --with-deps
- run: pnpm --filter web test:e2e
- uses: actions/upload-artifact@v3
if: failure()
with:
name: playwright-report
path: apps/web/playwright-report/
build:
runs-on: ubuntu-latest
needs: [lint, typecheck, test-api, test-web]
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v2
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- run: pnpm buildyaml
# .github/workflows/cd-api.yml
name: CD - API
on:
push:
branches: [main]
paths:
- 'apps/api/**'
- 'libs/**'
- '.github/workflows/cd-api.yml'
jobs:
deploy:
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@v4
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: us-east-1
- name: Login to Amazon ECR
id: login-ecr
uses: aws-actions/amazon-ecr-login@v2
- name: Build and push Docker image
env:
ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
ECR_REPOSITORY: sport-tech-api
IMAGE_TAG: ${{ github.sha }}
run: |
docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG \
-f apps/api/Dockerfile .
docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG
docker tag $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG \
$ECR_REGISTRY/$ECR_REPOSITORY:latest
docker push $ECR_REGISTRY/$ECR_REPOSITORY:latest
- name: Deploy to ECS
run: |
aws ecs update-service \
--cluster sport-tech-cluster \
--service sport-tech-api \
--force-new-deployment
- name: Run database migrations
run: |
# Execute migrations via ECS task
aws ecs run-task \
--cluster sport-tech-cluster \
--task-definition sport-tech-migrations \
--network-configuration "awsvpcConfiguration={subnets=[subnet-xxx],securityGroups=[sg-xxx]}" \
--launch-type FARGATE
- name: Notify deployment
uses: 8398a7/action-slack@v3
with:
status: ${{ job.status }}
text: 'API deployed to production'
webhook_url: ${{ secrets.SLACK_WEBHOOK }}
if: always()Dockerfile (Multi-stage)
dockerfile
# apps/api/Dockerfile
# Stage 1: Build
FROM node:20-alpine AS builder
WORKDIR /app
# Install pnpm
RUN npm install -g pnpm
# Copy package files
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY apps/api/package.json ./apps/api/
COPY libs/shared/package.json ./libs/shared/
# Install dependencies
RUN pnpm install --frozen-lockfile
# Copy source code
COPY apps/api ./apps/api
COPY libs/shared ./libs/shared
# Generate Prisma client
WORKDIR /app/apps/api
RUN pnpm prisma:generate
# Build application
RUN pnpm build
# Stage 2: Production
FROM node:20-alpine AS production
WORKDIR /app
# Install pnpm
RUN npm install -g pnpm
# Copy package files
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY apps/api/package.json ./apps/api/
COPY libs/shared/package.json ./libs/shared/
# Install production dependencies only
RUN pnpm install --frozen-lockfile --prod
# Copy built application from builder
COPY --from=builder /app/apps/api/dist ./apps/api/dist
COPY --from=builder /app/apps/api/prisma ./apps/api/prisma
COPY --from=builder /app/apps/api/node_modules/.prisma ./apps/api/node_modules/.prisma
# Create non-root user
RUN addgroup -g 1001 -S nodejs && \
adduser -S nestjs -u 1001
USER nestjs
WORKDIR /app/apps/api
EXPOSE 3000
CMD ["node", "dist/main.js"]Docker Compose (Development)
yaml
# infrastructure/docker/docker-compose.yml
version: '3.8'
services:
postgres:
image: postgres:15-alpine
container_name: sport-tech-postgres
environment:
POSTGRES_DB: sport_tech_dev
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports:
- '5432:5432'
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U postgres']
interval: 10s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
container_name: sport-tech-redis
ports:
- '6379:6379'
volumes:
- redis_data:/data
healthcheck:
test: ['CMD', 'redis-cli', 'ping']
interval: 10s
timeout: 3s
retries: 5
rabbitmq:
image: rabbitmq:3.12-management-alpine
container_name: sport-tech-rabbitmq
environment:
RABBITMQ_DEFAULT_USER: admin
RABBITMQ_DEFAULT_PASS: admin
ports:
- '5672:5672'
- '15672:15672'
volumes:
- rabbitmq_data:/var/lib/rabbitmq
healthcheck:
test: ['CMD', 'rabbitmq-diagnostics', 'ping']
interval: 10s
timeout: 5s
retries: 5
mailhog:
image: mailhog/mailhog
container_name: sport-tech-mailhog
ports:
- '1025:1025'
- '8025:8025'
volumes:
postgres_data:
redis_data:
rabbitmq_data:📊 Observabilidade
Prometheus Configuration
yaml
# infrastructure/monitoring/prometheus.yml
global:
scrape_interval: 15s
evaluation_interval: 15s
scrape_configs:
- job_name: 'api'
static_configs:
- targets: ['api:3000']
metrics_path: /metrics
- job_name: 'iot-gateway'
static_configs:
- targets: ['iot-gateway:3001']
- job_name: 'postgres'
static_configs:
- targets: ['postgres-exporter:9187']
- job_name: 'redis'
static_configs:
- targets: ['redis-exporter:9121']
- job_name: 'node'
static_configs:
- targets: ['node-exporter:9100']Grafana Dashboards
json
// infrastructure/monitoring/dashboards/api-metrics.json
{
"dashboard": {
"title": "Sport Tech Club - API Metrics",
"panels": [
{
"title": "Request Rate",
"targets": [
{
"expr": "rate(http_requests_total[5m])"
}
]
},
{
"title": "Response Time (p95)",
"targets": [
{
"expr": "histogram_quantile(0.95, rate(http_request_duration_seconds_bucket[5m]))"
}
]
},
{
"title": "Error Rate",
"targets": [
{
"expr": "rate(http_requests_total{status=~\"5..\"}[5m])"
}
]
}
]
}
}Application Metrics (NestJS)
typescript
// apps/api/src/shared/interceptors/metrics.interceptor.ts
import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';
import { Counter, Histogram } from 'prom-client';
@Injectable()
export class MetricsInterceptor implements NestInterceptor {
private readonly httpRequestsTotal = new Counter({
name: 'http_requests_total',
help: 'Total number of HTTP requests',
labelNames: ['method', 'route', 'status'],
});
private readonly httpRequestDuration = new Histogram({
name: 'http_request_duration_seconds',
help: 'HTTP request duration in seconds',
labelNames: ['method', 'route', 'status'],
buckets: [0.1, 0.5, 1, 2, 5],
});
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const request = context.switchToHttp().getRequest();
const { method, route } = request;
const start = Date.now();
return next.handle().pipe(
tap({
next: () => {
const response = context.switchToHttp().getResponse();
const duration = (Date.now() - start) / 1000;
this.httpRequestsTotal.inc({
method,
route: route.path,
status: response.statusCode,
});
this.httpRequestDuration.observe(
{ method, route: route.path, status: response.statusCode },
duration
);
},
error: (error) => {
const duration = (Date.now() - start) / 1000;
this.httpRequestsTotal.inc({
method,
route: route.path,
status: error.status || 500,
});
this.httpRequestDuration.observe(
{ method, route: route.path, status: error.status || 500 },
duration
);
},
})
);
}
}Logging (Winston)
typescript
// apps/api/src/config/logger.config.ts
import { WinstonModule } from 'nest-winston';
import * as winston from 'winston';
export const loggerConfig = WinstonModule.createLogger({
transports: [
new winston.transports.Console({
format: winston.format.combine(
winston.format.timestamp(),
winston.format.colorize(),
winston.format.printf(({ timestamp, level, message, context, ...meta }) => {
return `${timestamp} [${context}] ${level}: ${message} ${
Object.keys(meta).length ? JSON.stringify(meta, null, 2) : ''
}`;
})
),
}),
new winston.transports.File({
filename: 'logs/error.log',
level: 'error',
format: winston.format.combine(winston.format.timestamp(), winston.format.json()),
}),
new winston.transports.File({
filename: 'logs/combined.log',
format: winston.format.combine(winston.format.timestamp(), winston.format.json()),
}),
],
});Distributed Tracing (OpenTelemetry)
typescript
// apps/api/src/config/tracing.config.ts
import { NodeSDK } from '@opentelemetry/sdk-node';
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node';
import { JaegerExporter } from '@opentelemetry/exporter-jaeger';
const traceExporter = new JaegerExporter({
endpoint: process.env.JAEGER_ENDPOINT || 'http://localhost:14268/api/traces',
});
export const otelSDK = new NodeSDK({
traceExporter,
instrumentations: [
getNodeAutoInstrumentations({
'@opentelemetry/instrumentation-http': { enabled: true },
'@opentelemetry/instrumentation-express': { enabled: true },
'@opentelemetry/instrumentation-nestjs-core': { enabled: true },
'@opentelemetry/instrumentation-pg': { enabled: true },
'@opentelemetry/instrumentation-redis': { enabled: true },
}),
],
serviceName: 'sport-tech-api',
});
// Start tracing
otelSDK.start();
// Graceful shutdown
process.on('SIGTERM', () => {
otelSDK
.shutdown()
.then(() => console.log('Tracing terminated'))
.catch((error) => console.log('Error terminating tracing', error));
});🔒 Segurança
Helmet Configuration
typescript
// apps/api/src/main.ts
import helmet from 'helmet';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
// Security headers
app.use(
helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"],
scriptSrc: ["'self'"],
imgSrc: ["'self'", 'data:', 'https:'],
},
},
hsts: {
maxAge: 31536000,
includeSubDomains: true,
preload: true,
},
})
);
await app.listen(3000);
}CORS Configuration
typescript
// apps/api/src/config/cors.config.ts
export const corsConfig: CorsOptions = {
origin: (origin, callback) => {
const allowedOrigins = process.env.ALLOWED_ORIGINS?.split(',') || [];
if (!origin || allowedOrigins.includes(origin)) {
callback(null, true);
} else {
callback(new Error('Not allowed by CORS'));
}
},
credentials: true,
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization'],
exposedHeaders: ['X-Total-Count', 'X-Page-Count'],
};Rate Limiting
typescript
// apps/api/src/shared/guards/rate-limit.guard.ts
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Redis } from 'ioredis';
@Injectable()
export class RateLimitGuard implements CanActivate {
constructor(
private reflector: Reflector,
private redis: Redis
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const ip = request.ip;
const key = `rate_limit:${ip}`;
const limit = this.reflector.get<number>('rateLimit', context.getHandler()) || 100;
const window = 60; // 1 minute
const current = await this.redis.incr(key);
if (current === 1) {
await this.redis.expire(key, window);
}
if (current > limit) {
throw new TooManyRequestsException('Rate limit exceeded');
}
return true;
}
}
// Usage
@Controller('members')
@UseGuards(RateLimitGuard)
export class MembersController {
@Post()
@RateLimit(10) // 10 requests per minute
async create(@Body() dto: CreateMemberDto) {
// ...
}
}Input Validation
typescript
// apps/api/src/modules/members/dtos/create-member.dto.ts
import { IsEmail, IsString, MinLength, MaxLength, Matches } from 'class-validator';
export class CreateMemberDto {
@IsString()
@MinLength(3)
@MaxLength(100)
name: string;
@IsEmail()
email: string;
@IsString()
@Matches(/^\+?[1-9]\d{1,14}$/, {
message: 'Phone must be a valid E.164 format',
})
phone: string;
@IsString()
@IsUUID()
clubId: string;
}SQL Injection Prevention (Prisma)
typescript
// Prisma queries são parametrizadas automaticamente
const member = await prisma.member.findUnique({
where: { id: memberId }, // Safe
});
// RAW queries devem usar parameterized queries
const result = await prisma.$queryRaw`
SELECT * FROM members
WHERE email = ${email} AND club_id = ${clubId}
`;⚡ Performance & Escalabilidade
Caching Strategy
typescript
// apps/api/src/shared/decorators/cache.decorator.ts
export function Cache(ttl: number = 300) {
return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
const originalMethod = descriptor.value;
descriptor.value = async function (...args: any[]) {
const cacheKey = `cache:${propertyKey}:${JSON.stringify(args)}`;
const redis = this.redis; // Inject Redis
// Try to get from cache
const cached = await redis.get(cacheKey);
if (cached) {
return JSON.parse(cached);
}
// Execute method
const result = await originalMethod.apply(this, args);
// Store in cache
await redis.setex(cacheKey, ttl, JSON.stringify(result));
return result;
};
return descriptor;
};
}
// Usage
@Injectable()
export class ClubService {
constructor(private redis: Redis) {}
@Cache(600) // 10 minutes
async getClubDetails(clubId: string): Promise<ClubDetailsDto> {
// Expensive operation
return this.clubRepository.findById(clubId);
}
}Database Indexing
prisma
// apps/api/prisma/schema.prisma
model Member {
id String @id @default(uuid())
name String
email String
clubId String
status String
createdAt DateTime @default(now())
club Club @relation(fields: [clubId], references: [id])
@@unique([email, clubId])
@@index([clubId])
@@index([status])
@@index([createdAt])
@@map("members")
}Connection Pooling
typescript
// apps/api/src/config/database.config.ts
export const databaseConfig = {
url: process.env.DATABASE_URL,
pool: {
min: 2,
max: 10,
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 2000,
},
log: ['query', 'error'],
};Horizontal Scaling (Kubernetes HPA)
yaml
# infrastructure/k8s/deployments/api-hpa.yml
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: sport-tech-api-hpa
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: sport-tech-api
minReplicas: 2
maxReplicas: 10
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70
- type: Resource
resource:
name: memory
target:
type: Utilization
averageUtilization: 80📚 Referências
Documentação Oficial
- NestJS: https://docs.nestjs.com
- Prisma: https://www.prisma.io/docs
- Vue.js: https://vuejs.org/guide
- PostgreSQL: https://www.postgresql.org/docs
- Redis: https://redis.io/docs
- RabbitMQ: https://www.rabbitmq.com/documentation.html
- Docker: https://docs.docker.com
- Kubernetes: https://kubernetes.io/docs
Livros Recomendados
- Clean Architecture - Robert C. Martin
- Domain-Driven Design - Eric Evans
- Designing Data-Intensive Applications - Martin Kleppmann
- Building Microservices - Sam Newman
Atualizado em: 2026-01-09
Versão: 1.0.0
Mantenedor: Equipe Sport Tech Club