Sistema de gestão de assinaturas para serviço de streaming. Implementa cadastro de usuários, criação de assinaturas, renovação automática e cancelamento.
- Java 17 + Spring Boot 3.2
- PostgreSQL 15 - Persistência de dados
- Redis 7 - Cache de assinaturas ativas
- RabbitMQ 3 - Mensageria para processamento assíncrono de renovações
- Flyway - Versionamento de schema do banco de dados
- JUnit 5 + Mockito + Testcontainers - Testes automatizados
- SpringDoc OpenAPI - Documentação da API (Swagger)
O projeto segue os princípios da Clean Architecture combinados com Domain-Driven Design (DDD), organizando o código em camadas com responsabilidades bem definidas:
src/main/java/br/com/subscriptionplay/
├── application/
│ ├── controller/ # Controllers REST
│ ├── request/ # DTOs de entrada
│ └── response/ # DTOs de saída
├── domain/
│ ├── model/ # Entidades e objetos de valor
│ ├── gateway/ # Interfaces (ports)
│ ├── exception/ # Exceções de domínio
│ └── usecase/ # Casos de uso
└── infra/
├── config/ # Configurações do Spring
├── persistence/ # JPA e repositórios
├── cache/ # Implementação Redis
├── messaging/ # Implementação RabbitMQ
└── jobs/ # Tarefas
A separação em camadas garante que o domínio permanece isolado de frameworks e bibliotecas externas. Os casos de uso dependem apenas de interfaces (gateways), e as implementações concretas são injetadas pelo Spring. Isso facilita:
- Testabilidade: Os casos de uso podem ser testados com mocks, sem necessidade de banco de dados ou mensageria.
- Flexibilidade: Trocar o PostgreSQL por outro banco, ou RabbitMQ por Kafka, requer apenas nova implementação do gateway.
- Manutenibilidade: Regras de negócio centralizadas no domínio, sem espalhamento por controllers ou repositórios.
O sistema utiliza lock pessimista (SELECT FOR UPDATE) para evitar que a mesma assinatura seja renovada simultaneamente por múltiplas instâncias da aplicação.
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT s FROM SubscriptionEntity s WHERE s.id = :id")
Optional<SubscriptionEntity> findByIdForUpdate(UUID id);Trade-off considerado: Em um cenário real com gateway de pagamento externo, manter o lock durante uma chamada HTTP seria problemático (a linha ficaria travada por segundos). A solução ideal seria:
- Adquirir lock e validar elegibilidade
- Liberar lock e chamar gateway de pagamento
- Adquirir novo lock e atualizar resultado
Para este desafio, como o PaymentGateway é um mock síncrono, o lock durante toda a operação é aceitável.
O RabbitMQ garante entrega "at least once", o que significa que uma mensagem pode ser entregue mais de uma vez. Para evitar renovações duplicadas, o sistema verifica se a assinatura já foi renovada:
if (subscription.wasAlreadyRenewedToday()) {
return RenewalResult.skipped("Subscription already renewed");
}Cada execução do job de renovação gera um identificador único que é propagado através das mensagens e logs:
[JOB-a1b2c3d4] Starting subscription renewal job
[a1b2c3d4-e5f6] Starting renewal for subscription: xxx-yyy
[a1b2c3d4-e5f6] Renewal completed successfully for subscription xxx-yyy
Isso permite rastrear o ciclo de vida completo de uma renovação em ambientes distribuídos.
As consultas de assinaturas ativas utilizam Redis como cache, seguindo o padrão cache-aside:
- Verifica se existe no cache
- Se não existir, busca no banco e armazena no cache
- Operações de escrita (cancelamento, suspensão) invalidam o cache
O TTL de 1 hora garante que dados obsoletos não permaneçam indefinidamente.
O job diário não processa as renovações diretamente. Ele publica mensagens no RabbitMQ, que são consumidas assincronamente:
Job (6h) → Publica N mensagens → RabbitMQ → Consumer processa uma a uma
Vantagens:
- O job termina rapidamente, independente do volume
- Falhas são isoladas por assinatura
- Mensagens com erro vão para DLQ para análise posterior
- Possibilidade de escalar consumidores horizontalmente
As interfaces no pacote domain/gateway definem contratos que o domínio espera, sem conhecer as implementações:
| Interface | Implementação | Responsabilidade |
|---|---|---|
SubscriptionGateway |
SubscriptionGatewayImpl |
Persistência JPA |
PaymentGateway |
PaymentGatewayMock |
Simulação de pagamento |
SubscriptionCacheGateway |
SubscriptionCacheGatewayImpl |
Cache Redis |
SubscriptionEventPublisher |
SubscriptionEventPublisherImpl |
Mensageria RabbitMQ |
Os campos status e plan são armazenados como VARCHAR no banco, não como tipo ENUM nativo do PostgreSQL.
Motivo: ENUMs no PostgreSQL são difíceis de modificar. Adicionar um valor é simples (ALTER TYPE ... ADD VALUE), mas remover ou renomear exige criar um novo tipo, migrar os dados e dropar o antigo.
Com VARCHAR, a validação acontece na camada de domínio através dos enums Java (Plan, SubscriptionStatus). O banco permanece flexível para evoluções, e as regras de negócio ficam onde devem estar: no domínio.
Trade-off aceito: Perdemos validação em nível de banco em troca de migrations mais simples.
Os planos (BASICO, PREMIUM, FAMILIA) estão definidos como enum Java, não como uma tabela plans no banco.
Motivo: O requisito especifica exatamente três planos com preços fixos. Criar uma tabela dinâmica adicionaria:
- Novas entidades e repositórios
- CRUD de planos com endpoints
- Validações de FK ao criar assinatura
- Complexidade sem necessidade imediata
Princípio aplicado: YAGNI (You Aren't Gonna Need It). A solução atende ao requisito sem over-engineering.
Evolução futura: Se surgir necessidade de planos dinâmicos (promoções, regionalização), a arquitetura permite extrair para uma tabela sem reescrever o domínio. O CreateSubscriptionUseCase passaria a buscar o plano via gateway ao invés de receber diretamente o enum.
Os testes de integração utilizam Testcontainers ao invés de banco H2 em memória ou infraestrutura compartilhada.
Motivo:
- Fidelidade: Testes rodam contra PostgreSQL e Redis reais, não simulações
- Isolamento: Cada execução tem ambiente limpo, sem interferência entre testes
- Simplicidade:
mvn testfunciona em qualquer máquina com Docker, sem configuração prévia
Trade-off aceito: Testes de integração são mais lentos (containers precisam subir), mas a confiabilidade compensa. Testes unitários continuam rápidos usando mocks.
Logs, exceções e mensagens de erro estão em inglês.
Motivo:
- Padrão da indústria
- Facilita busca por erros em Stack Overflow e documentações
- Compatível com equipes internacionais ou distribuídas (Principalmente consultorias)
- Mensagens de API seguem convenções REST globais
Exceções em português: Apenas os nomes dos planos (BASICO, PREMIUM, FAMILIA) e status (ATIVA, SUSPENSA, CANCELADA) permanecem em português pois são parte do domínio de negócio brasileiro e aparecem nos dados do banco.
A assinatura não armazena o preço pago no momento da contratação. Se o preço do plano mudar, não há histórico.
Motivo: Não estava no requisito. Em um sistema real, seria interessante adicionar um campo priceAtCreation na assinatura para auditoria e disputas de cobrança.
Evolução futura: Adicionar coluna price_at_creation na migration e capturar o valor no CreateSubscriptionUseCase.
- Java 17+
- Maven 3.8+
- Docker e Docker Compose
docker-compose up -dIsso iniciará:
- PostgreSQL na porta 5433 (5432 geralmente tem outros projetos :P)
- Redis na porta 6379
- RabbitMQ na porta 5672 (management UI na 15672)
mvn spring-boot:runA aplicação estará disponível em http://localhost:8080.
http://localhost:8080/swagger-ui.html
mvn testOs testes de integração utilizam Testcontainers, que sobem containers Docker automaticamente.
| Método | Endpoint | Descrição |
|---|---|---|
POST |
/api/users |
Criar usuário |
POST |
/api/subscriptions |
Criar assinatura |
GET |
/api/subscriptions/user/{userId}/active |
Buscar assinatura ativa |
POST |
/api/subscriptions/{id}/cancel |
Cancelar assinatura |
Criar usuário:
curl -X POST http://localhost:8080/api/users \
-H "Content-Type: application/json" \
-d '{"email": "usuario@email.com", "name": "João Silva"}'Criar assinatura:
curl -X POST http://localhost:8080/api/subscriptions \
-H "Content-Type: application/json" \
-d '{"userId": "uuid-do-usuario", "plan": "PREMIUM"}'Planos disponíveis: BASICO (R$ 19,90), PREMIUM (R$ 39,90), FAMILIA (R$ 59,90)
- Um usuário pode ter apenas uma assinatura ativa por vez
- Renovação automática: Executada diariamente às 6h para assinaturas vencendo no dia
- Suspensão após falhas: Três tentativas de pagamento falhadas suspendem a assinatura
- Cancelamento preserva acesso: Usuário continua utilizando o serviço até a data de expiração original
src/test/java/
├── domain/
│ ├── model/
│ │ └── SubscriptionTest.java # Testes da entidade de domínio
│ └── usecase/
│ ├── CancelSubscriptionUseCaseTest.java # Testes de cancelamento
│ ├── CreateSubscriptionUseCaseTest.java # Testes de criação
│ ├── GetActiveSubscriptionUseCaseTest.java # Testes de busca com cache
│ └── ProcessSubscriptionRenewalUseCaseTest.java # Testes de renovação
└── SubscriptionIntegrationTest.java # Testes de integração com Testcontainers
Cobertura dos testes (36 testes):
- Criação de assinatura com validações
- Renovação bem-sucedida e falhas de pagamento
- Suspensão após 3 falhas consecutivas
- Idempotência na renovação
- Cancelamento mantendo data de expiração
- Cache-aside (hit/miss/invalidação)
- Validações de domínio (status, planos)
Funcionalidades que poderiam ser adicionadas em uma evolução do sistema:
- Retry com backoff exponencial para falhas de pagamento
- Notificações por email/push quando a assinatura for suspensa
- Métricas e observabilidade com Micrometer e Prometheus
- Histórico de transações para auditoria