In hexagonal architecture, ports are interfaces that define contracts, while adapters are concrete implementations that connect the application to external systems. This separation allows the core business logic to remain independent of technology choices.
Ports are defined in the core layer, while adapters are implemented in the infrastructure layer.
Implemented by:ProcessTelegramUpdateUseCaseInvoked by:TelegramPollingService (Scheduler)Purpose: Process incoming Telegram messages and generate AI responsesFlow:
The adapter uses Spring’s RestClient but the core only knows about the TelegramPort interface. We could swap to WebClient, Feign, or raw HTTP without changing the core.
Implemented by:OpenRouterAdapter (OpenRouter API client)Used by:ProcessTelegramUpdateUseCaseImplementation:
// src/main/java/com/acamus/telegrm/infrastructure/adapters/out/ai/OpenRouterAdapter.java@Componentpublic class OpenRouterAdapter implements AiGeneratorPort { private final RestClient restClient; private final String model; private final String systemPrompt; private final double temperature; private final int maxTokens; @Override public String generateResponse(String userInput) { try { ChatMessage systemMessage = new ChatMessage("system", systemPrompt, null); ChatMessage userMessage = new ChatMessage("user", userInput, null); OpenRouterRequest request = new OpenRouterRequest( model, List.of(systemMessage, userMessage), maxTokens, temperature ); OpenRouterResponse response = restClient.post() .uri("/chat/completions") .body(request) .retrieve() .body(OpenRouterResponse.class); if (response != null && !response.choices().isEmpty()) { return response.choices().getFirst().message().content(); } } catch (RestClientResponseException e) { return "La IA rechazó mi petición (HTTP " + e.getStatusCode() + ")."; } catch (ResourceAccessException e) { return "No puedo conectar con la IA (Error de red)."; } catch (Exception e) { return "Ocurrió un error interno: " + e.getMessage(); } return "El silencio del vacío cósmico me responde."; }}
The adapter handles all error scenarios and returns user-friendly messages. The use case doesn’t need to know about HTTP status codes or network errors.
// src/main/java/com/acamus/telegrm/infrastructure/adapters/in/web/auth/AuthController.java@RestController@RequestMapping("/api/auth")@RequiredArgsConstructorpublic class AuthController { private final RegisterUserPort registerUserPort; private final AuthenticateUserPort authenticateUserPort; @PostMapping("/register") public ResponseEntity<AuthResponse> register( @Valid @RequestBody RegisterRequest request) { RegisterUserCommand command = new RegisterUserCommand( request.name(), request.email(), request.password() ); String token = registerUserPort.register(command); return ResponseEntity.ok(new AuthResponse(token)); } @PostMapping("/login") public ResponseEntity<AuthResponse> login( @Valid @RequestBody LoginRequest request) { AuthenticateUserCommand command = new AuthenticateUserCommand( request.email(), request.password() ); String token = authenticateUserPort.authenticate(command); return ResponseEntity.ok(new AuthResponse(token)); }}
The controller converts DTOs (RegisterRequest) to commands (RegisterUserCommand) and invokes the port. It knows about HTTP and JSON, but the use case doesn’t.
ConversationController - Conversation Management
// src/main/java/com/acamus/telegrm/infrastructure/adapters/in/web/conversation/ConversationController.java@RestController@RequestMapping("/api/conversations")@RequiredArgsConstructor@PreAuthorize("isAuthenticated()")public class ConversationController { private final ListConversationsPort listConversationsPort; private final GetMessagesByConversationPort getMessagesByConversationPort; private final SendMessagePort sendMessagePort; @GetMapping public ResponseEntity<List<ConversationSummaryDto>> listConversations() { List<Conversation> conversations = listConversationsPort.listAll(); List<ConversationSummaryDto> dtos = conversations.stream() .map(this::toSummaryDto) .toList(); return ResponseEntity.ok(dtos); } @GetMapping("/{id}/messages") public ResponseEntity<List<MessageDto>> getMessages(@PathVariable String id) { List<Message> messages = getMessagesByConversationPort.getMessages(id); List<MessageDto> dtos = messages.stream() .map(this::toMessageDto) .toList(); return ResponseEntity.ok(dtos); } @PostMapping("/{id}/messages") public ResponseEntity<Void> sendMessage( @PathVariable String id, @Valid @RequestBody SendMessageRequest request) { SendMessageCommand command = new SendMessageCommand(id, request.content()); sendMessagePort.sendMessage(command); return ResponseEntity.ok().build(); }}
Controllers handle HTTP concerns (status codes, serialization) and security (@PreAuthorize), keeping these out of use cases.
@Testvoid test() { var mockRepo = mock(ConversationRepositoryPort.class); var useCase = new ProcessTelegramUpdateUseCase(mockRepo, ...); useCase.processUpdate(command); verify(mockRepo).save(any());}
Flexibility
Swap implementations without changing core
PostgreSQL → MongoDB
OpenRouter → OpenAI
REST → GraphQL
Clear Contracts
Interfaces document expectations
public interface AiGeneratorPort { // What does this do? Generate AI response // What do I need? User input string // What do I get? Response string String generateResponse(String userInput);}
Ports Are Contracts: Interfaces define communication protocols
Input Ports = Use Cases: What the application can do
Output Ports = Dependencies: What the application needs
Adapters Are Implementations: Technology-specific code
Core Stays Pure: No framework dependencies in ports
Easy to Test: Mock ports, test logic
Easy to Replace: Swap adapter implementations
When adding new functionality, start by defining the port in the core layer. Then implement the use case, and finally create the adapters. This ensures the core drives the design, not the infrastructure.