O projeto segue o padrão Hexagonal Architecture (Ports & Adapters), também conhecido como Clean Architecture.
O núcleo da aplicação (regras de negócio) fica isolado no centro. Tudo que é externo — banco, SQS, APIs — se conecta por meio de portas (interfaces) e adapters (implementações).
[SQS] [HTTP]
↓ ↓
adapter/in ← entrada
↓
usecase/in ← porta de entrada (interface)
↓
application/service ← regra de negócio
↓
usecase/out ← porta de saída (interface)
↓
adapter/out ← saída
↓
[PostgreSQL] [SNS] [Feign APIs]
Regra fundamental: o service nunca depende de uma implementação concreta.
Ele só conhece interfaces (usecase/out). Os adapters implementam essas interfaces.
br.com.jusfy
├── adapter
│ ├── in
│ │ ├── consumer/{dominio}/ ← @SqsListener
│ │ └── rest
│ │ ├── exception/handler/ ← ApiExceptionHandler + ApiError DTO
│ │ └── {dominio}/{operacao}/ ← Controller + Mapper + dto/
│ └── out
│ ├── feign/{servico}/ ← @FeignClient + dto/
│ ├── repository/{dominio}/ ← JpaRepository + Adapter
│ └── sns/ ← SnsPublisherClient
├── application
│ ├── exception/ ← Exceções de negócio
│ ├── model
│ │ ├── domain/{dominio}/ ← Records de evento/domínio
│ │ ├── entity/ ← Entidades JPA
│ │ └── enumeration/ ← Enums
│ └── service
│ ├── {dominio}/ ← Services de negócio
│ └── utils/ ← EventEnvelopeUtils, TraceIdSupport
├── config/ ← Beans Spring (SQS, SNS, Feign, Cache…)
└── usecase
├── in/{dominio}/ ← Inbound ports
└── out/{dominio}/ ← Outbound ports
O que dispara algo na aplicação.
| Tipo | Anotação | Responsabilidade |
|---|---|---|
| Consumer | @SqsListener |
Recebe mensagem SQS, delega para usecase/in |
| Controller | @RestController |
Recebe HTTP, delega para usecase/in |
Sem lógica de negócio. Só recebe, converte e delega.
Interfaces que definem o que a aplicação oferece.
// Quem implementa: Services
// Quem usa: Controllers e Consumers
public interface CreateSubscriptionUseCase {
Subscription create(CreateSubscriptionDomain domain);
}Onde a lógica de negócio vive. Implementa usecase/in e depende de usecase/out.
@Service
@RequiredArgsConstructor
@Slf4j
public class CreateSubscriptionService implements CreateSubscriptionUseCase {
private final SubscriptionRepositoryUseCase subscriptionRepositoryUseCase; // interface!
@Override
public Subscription create(CreateSubscriptionDomain domain) {
// lógica aqui
}
}Interfaces que definem o que a aplicação precisa de fora.
// Quem implementa: Adapters (repository, feign…)
// Quem usa: Services
public interface SubscriptionRepositoryUseCase {
Subscription save(Subscription subscription);
Optional<Subscription> findById(UUID id);
boolean existsById(UUID id);
}Implementam as interfaces de usecase/out, conectando a aplicação ao mundo externo.
| Tipo | Responsabilidade |
|---|---|
RepositoryAdapter |
Implementa RepositoryUseCase, chama JpaRepository |
FeignClient |
Chamadas HTTP para outros serviços |
SnsPublisherClient |
Publica eventos no AWS SNS |
RuntimeException
└── BusinessException ← exceção base (genérica)
└── SubscriptionAlreadyExistsException
└── {NovaExcecaoEspecifica}Exception
1. Criar a classe em application/exception/:
public class SubscriptionNotFoundException extends BusinessException {
public SubscriptionNotFoundException(String message) {
super(message);
}
}2. Lançar no service:
return subscriptionRepositoryUseCase.findById(id)
.orElseThrow(() -> new SubscriptionNotFoundException("Subscription not found: " + id));3. Mapear no ApiExceptionHandler:
@ExceptionHandler(SubscriptionNotFoundException.class)
public ResponseEntity<ApiError> handleSubscriptionNotFound(
SubscriptionNotFoundException ex, HttpServletRequest request) {
HttpStatus status = HttpStatus.NOT_FOUND;
ApiError body = ApiError.builder()
.timestamp(Instant.now())
.status(status.value())
.error("SUBSCRIPTION_NOT_FOUND")
.message(ex.getMessage())
.path(request.getRequestURI())
.build();
return ResponseEntity.status(status).body(body);
}{
"timestamp": "2026-03-16T10:00:00Z",
"status": 404,
"error": "SUBSCRIPTION_NOT_FOUND",
"message": "Subscription not found: 550e8400-...",
"path": "/subscriptions/550e8400-..."
}| Classe | Responsabilidade |
|---|---|
GsonConfig |
Bean Gson com serialização de LocalDateTime |
AwsSqsClientConfig |
SQS async client |
SnsConfig |
SNS client |
CacheConfig |
Caffeine — cache de token (50 min) |
AuthenticatorFeignConfig |
Logging e content-type do Feign |
JusPayFeignConfig |
Interceptor Bearer token para JusPay |
TraceIdMdcFilter |
Propaga traceId em requisições HTTP |
SqsListenerTraceIdAspect |
AOP — extrai traceId de mensagens SQS |
- PostgreSQL com migrations via Flyway
- Arquivos em
src/main/resources/db/migrations/ - Nomenclatura:
V{numero}__{descricao}.sql
-- V1__create_subscriptions_table.sql
CREATE TABLE subscriptions (
id UUID PRIMARY KEY,
customer_id UUID NOT NULL,
status VARCHAR(50) NOT NULL,
cycle_start_date TIMESTAMP,
cycle_end_date TIMESTAMP,
cycle_number INT NOT NULL,
cancelled_at TIMESTAMP,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL
);Mínimo exigido pelo JaCoCo: 89% de linhas cobertas.
./mvnw verify # roda testes + verifica cobertura