speech-ai-hexagonal · v12.1.0 · Hexagonal (Ports & Adapters)
┌────────────────────────────────────────────────────────────────────────┐
│ HEXÁGONO DE TRANSCRIÇÃO │
├───────────────────┬──────────────────────────┬─────────────────────────┤
│ ADAPTER IN │ APPLICATION │ ADAPTERS OUT │
│ (HTTP/REST) │ (domain + use case) │ (infraestrutura) │
├───────────────────┼──────────────────────────┼─────────────────────────┤
│ │ │ │
│ TranscriptionController │ SpeachesAdapter │
│ ↓ injeta │ │ (RestClient) │
│ TranscribeAudioPort ←── interface │ implements │
│ ↓ implementado por │ SpeechToTextPort │
│ TranscribeAudioUseCase │ │
│ ↓ valida + delega │ Fase 5 → │
│ SpeechToTextPort ──────────────────────────→ OpenAiSpeechAdapter │
│ │ │ (Spring AI) │
│ │ domain/model/ │ │
│ │ Transcription │ │
│ │ (zero Spring) │ │
│ │ │ │
└───────────────────┴──────────────────────────┴─────────────────────────┘
com.erichiroshi.speechaihexagonal/
├── SpeechAiHexagonalApplication.java
└── transcription/ ← bounded context
├── domain/ ← zero dependência de framework
│ ├── model/Transcription.java
│ ├── exception/
│ │ ├── AudioValidationException.java
│ │ └── SpeechToTextException.java
│ └── port/out/
│ └── SpeechToTextPort.java ← porta de saída
├── application/ ← orquestra domínio, sem infra
│ ├── TranscribeAudioUseCase.java ← use case
│ ├── port/in/
│ │ └── TranscribeAudioPort.java ← porta de entrada
│ ├── input/TranscriptionInput.java
│ ├── output/TranscriptionOutput.java
│ └── mapper/TranscriptionMapper.java ← domain → output
└── infrastructure/ ← adapters concretos
├── http/
│ ├── TranscriptionController.java ← adapter IN
│ ├── handler/GlobalExceptionHandler.java
│ ├── mapper/TranscriptionHttpMapper.java
│ └── response/TranscriptionResponse.java
└── speechtotext/speaches/
├── SpeachesAdapter.java ← adapter OUT
├── SpeachesProperties.java
├── config/RestClientConfig.java
├── mapper/SpeachesMapper.java
└── response/SpeachesResponse.java
| Porta | Tipo | Adapter (Fase 1) | Adapter Futuro | Status |
|---|---|---|---|---|
TranscribeAudioPort |
Entrada (driving) | TranscribeAudioUseCase |
— | ✓ v1.0.0 |
SpeechToTextPort |
Saída (driven) | SpeachesAdapter (Whisper local) |
OpenAiSpeechAdapter (Fase 5) |
✓ v1.0.0 |
TranscriptionCachePort |
Saída (driven) | — | RedisCacheAdapter (Fase 3) |
✓ Fase 3 |
EventPublisherPort |
Saída (driven) | — | RabbitMqEventPublisher (Fase 7) |
✓ Fase 7 |
LanguageModelPort |
Saída (driven) | — | OllamaLanguageModelAdapter (Fase 6) |
✓ Fase 6 |
TranscriptionGateway |
— | — | JpaTranscriptionAdapter |
✓ Fase 3 |
MetricsPort |
— | — | PrometheusMetricsAdapter |
✓ Fase 4 |
TracingPort |
— | — | OpenTelemetryTracingAdapter |
✓ Fase 4 |
ResiliencePort |
— | — | Resilience4jAdapter |
✓ Fase 5 |
CloudTranscriptionPort |
— | — | OpenAiWhisperAdapter |
✓ Fase 6 |
SummarizationPort |
— | — | OllamaSummarizationAdapter |
✓ Fase 7 |
TranscriptionEventPort |
— | — | RabbitMqEventAdapter |
✓ Fase 8 |
O TranscribeAudioPort é uma interface, não uma classe. O Controller injeta a porta, não o use
case concreto. Isso permite testar o Controller com um mock simples sem subir o contexto Spring completo.
Trade-off: mais verboso. Recompensa: Controller testável isoladamente com @WebMvcTest.
A porta foi projetada pensando na Fase 5: OpenAiSpeechAdapter precisa do filename para o
multipart e do contentType para validar o formato. Se a porta aceitasse apenas byte[], seria
necessário quebrar o contrato na Fase 5.
Trade-off: interface ligeiramente mais ampla. Recompensa: zero mudança no domínio na Fase 5.
RestClient (Spring 6.1+) é o substituto moderno e síncrono do RestTemplate. Para transcrição de
áudio, a chamada ao Speaches é naturalmente bloqueante (aguarda o processamento da GPU). Não há ganho real em
usar WebClient/reativo aqui.
Trade-off: sem backpressure reativo. Recompensa: código mais simples, sem dependência do WebFlux.
MapStruct gera código em tempo de compilação — zero reflexão em runtime, zero penalidade de performance. Cada
camada tem seu próprio mapper: SpeachesMapper (infra→domain), TranscriptionMapper
(domain→output), TranscriptionHttpMapper (output→response HTTP).
Trade-off: anotação de processamento adicional no build. Recompensa: mapeamentos tipados, detectáveis em compilação.
A validação de tamanho e Content-Type é regra de negócio do domínio de transcrição, não responsabilidade HTTP. Se amanhã existir um adapter de entrada via mensageria (RabbitMQ), a validação se aplicaria igualmente.
Trade-off: o Controller pode receber erros de domínio. Recompensa: regras centralizadas, testáveis sem contexto Spring.
POST /api/transcriptions (multipart/form-data)
│
▼
TranscriptionController
- extrai bytes do MultipartFile
- lança AudioValidationException se vazio
- cria TranscriptionInput(bytes, filename, contentType)
│
▼
TranscribeAudioUseCase.execute(input)
- valida: bytes não nulo/vazio
- valida: tamanho ≤ 5 MB
- valida: contentType ∈ {audio/wav, audio/mpeg, audio/mp4...}
│
▼
SpeechToTextPort.transcribe(bytes, filename, contentType)
│
▼
SpeachesAdapter (implementação)
- ByteArrayResource com filename correto
- MultiValueMap: file + model
- RestClient → POST http://speaches:8000/v1/audio/transcriptions
- onStatus(isError) → lança SpeechToTextException
- valida resposta não vazia → lança SpeechToTextException
- SpeachesMapper.toDomain(SpeachesResponse) → Transcription
│
▼
TranscriptionMapper.toOutput(Transcription) → TranscriptionOutput
│
▼
TranscriptionHttpMapper.toResponse(output) → TranscriptionResponse
│
▼
HTTP 200 { "audioTranscription": "texto transcrito..." }
A camada de domínio agora possui a entidade Transcription e portas SPI responsáveis por abstrair persistência sem acoplamento ao framework.
Domain
├── Transcription
└── TranscriptionGateway (SPI)
Adapters
└── Implementações futuras JPA/Cache
O domínio permanece isolado de Hibernate, PostgreSQL e Spring, preservando a independência arquitetural da camada de negócio.
A geração do hash SHA-256 foi isolada em um serviço reutilizável da camada de aplicação para evitar dependência criptográfica direta dentro do domínio.
Application
└── AudioHashService
└── SHA-256
A abordagem mantém a arquitetura hexagonal desacoplada e prepara a aplicação para deduplicação futura de transcrições.
A infraestrutura de persistência foi introduzida utilizando PostgreSQL, Spring Data JPA e Flyway mantendo a separação arquitetural entre domínio, portas e adapters.
Domain
└── Ports
└── Persistence SPI
└── JPA Adapter
└── PostgreSQL
O domínio permanece completamente desacoplado do Hibernate e da infraestrutura. Toda responsabilidade de persistência fica restrita aos adapters de saída.
A arquitetura agora possui camada de cache distribuído desacoplada através da porta TranscriptionCachePort.
Application
↓
TranscriptionCachePort
↓
RedisCacheAdapter
↓
Redis
O projeto agora utiliza RabbitMQ para comunicação assíncrona baseada em eventos de domínio.
TranscriptionService
↓
TranscriptionCompletedEvent
↓
RabbitMQ
↓
Consumers
A publicação ocorre através da porta EventPublisherPort preservando o isolamento entre domínio e infraestrutura.
O projeto agora possui um contexto dedicado para notificações orientadas a eventos.
TranscriptionCompletedEvent
↓
RabbitMQ
↓
NotificationConsumer
↓
SendNotificationUseCase
↓
Email | SMS | WhatsApp
Novos canais podem ser adicionados sem alteração das regras existentes.