🏗️ Arquitetura

speech-ai-hexagonal · v12.1.0 · Hexagonal (Ports & Adapters)

Diagrama — Hexágono atual (v12.1.0)

┌────────────────────────────────────────────────────────────────────────┐
│                         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)          │                         │
│                   │                          │                         │
└───────────────────┴──────────────────────────┴─────────────────────────┘
      

Estrutura de pacotes

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
      

Portas e Adapters

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

Decisões de Arquitetura

Por que porta de entrada como interface?

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.

Por que SpeechToTextPort recebe filename e contentType?

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.

Por que RestClient e não WebClient?

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.

Por que MapStruct para mapeamento?

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.

Por que validação no use case e não no controller?

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.

Fluxo de execução — v1.0.0

    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..." }
          

Domínio de Transcrição

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.

Serviço de AudioHash

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.

Persistência e Migrations

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.

Camada de Cache

A arquitetura agora possui camada de cache distribuído desacoplada através da porta TranscriptionCachePort.

        Application
            ↓
        TranscriptionCachePort
            ↓
        RedisCacheAdapter
            ↓
        Redis
      

Arquitetura Orientada a Eventos

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.

Notification Bounded Context

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.