Pular para conteúdo

Exemplo Java Spring Boot: Auth API Hexagonal

Este documento mostra, passo a passo, como implementar uma API de autenticação usando Java, Spring Boot e Arquitetura Hexagonal.

A ideia é explicar de forma prática onde cada classe nasce, por que ela existe e quando usar cada conceito: model, command, result, port, adapter, mapper, controller, entity, repository e configuração de beans.

Antes de começar

Leia primeiro Arquitetura Hexagonal. Este documento é o exemplo prático com Spring Boot. Spring, JPA, Redis e HTTP aparecem aqui como adapters, não como centro da arquitetura.

O caso de uso

Vamos implementar o login de um usuário.

Fluxo esperado:

1. Cliente envia email e senha para POST /api/v1/auth/login.
2. Controller recebe o request.
3. Mapper converte LoginRequestDTO em LoginCommand.
4. LoginUseCase executa a regra de login.
5. Use case busca usuário por email usando FindUserByEmailPort.
6. Persistence adapter implementa essa porta usando JPA.
7. Use case valida senha usando PasswordVerifierPort.
8. Security adapter implementa essa porta usando PasswordEncoder.
9. Use case gera tokens usando TokenIssuerPort.
10. Use case salva refresh token usando SaveRefreshTokenPort.
11. Redis adapter implementa essa porta usando Redis.
12. Use case retorna AuthTokenResult.
13. Mapper converte AuthTokenResult em AuthTokenResponseDTO.
14. Controller retorna HTTP 200.

Estrutura final

com.arkorizon.service.auth
|-- AuthApplication.java
|
|-- config
}
|   |-- SecurityConfig.java
|
|-- domain
|   |-- model
|   |   |-- User.java
|   |-- command
|   |   |-- LoginCommand.java
|   |-- result
|   |   |-- AuthTokenResult.java
|   |-- exception
|   |   |-- InvalidCredentialsException.java
|   |   |-- UserInactiveException.java
|   |-- port
|   |   |-- in
|   |   |   |-- LoginUseCase.java
|   |   |-- out
|   |   |   |-- FindUserByEmailPort.java
|   |   |   |-- PasswordVerifierPort.java
|   |   |   |-- TokenIssuerPort.java
|   |   |   |-- SaveRefreshTokenPort.java
|   |-- service
|   |   |-- LoginUseCaseImpl.java
|
|-- adapter
|   |-- in
|   |   |-- web
|   |   |   |-- controller
|   |   |   |   |-- AuthController.java
|   |   |   |-- dto
|   |   |   |   |-- LoginRequestDTO.java
|   |   |   |   |-- AuthTokenResponseDTO.java
|   |   |   |-- mapper
|   |   |   |   |-- AuthWebMapper.java
|   |   |   |-- exception
|   |   |   |   |-- GlobalExceptionHandler.java
|   |-- out
|   |   |-- persistence
|   |   |   |-- adapter
|   |   |   |   |-- UserPersistenceAdapter.java
|   |   |   |-- entity
|   |   |   |   |-- UserEntity.java
|   |   |   |-- mapper
|   |   |   |   |-- UserPersistenceMapper.java
|   |   |   |-- repository
|   |   |   |   |-- UserJpaRepository.java
|   |   |-- redis
|   |   |   |-- adapter
|   |   |   |   |-- RefreshTokenRedisAdapter.java
|   |   |   |-- model
|   |   |   |   |-- RefreshTokenCacheValue.java
|   |   |-- security
|   |   |   |-- BCryptPasswordVerifierAdapter.java
|   |   |   |-- JwtTokenIssuerAdapter.java

Como decidir cada classe

Pergunta Classe criada Camada
Qual ação o usuário quer executar? LoginUseCase domain.port.in
Quais dados entram no caso de uso? LoginCommand domain.command
Quais dados saem do caso de uso? AuthTokenResult domain.result
Qual conceito de negócio existe? User domain.model
O caso de uso precisa buscar usuário fora do núcleo? FindUserByEmailPort domain.port.out
O caso de uso precisa validar senha fora do núcleo? PasswordVerifierPort domain.port.out
O caso de uso precisa gerar token fora do núcleo? TokenIssuerPort domain.port.out
O caso de uso precisa salvar refresh token fora do núcleo? SaveRefreshTokenPort domain.port.out
Quem recebe HTTP? AuthController adapter.in.web
Qual contrato HTTP chega? LoginRequestDTO adapter.in.web.dto
Qual contrato HTTP sai? AuthTokenResponseDTO adapter.in.web.dto
Quem traduz DTO para command? AuthWebMapper adapter.in.web.mapper
Quem usa JPA? UserPersistenceAdapter adapter.out.persistence
Quem representa tabela? UserEntity adapter.out.persistence.entity
Quem usa Redis? RefreshTokenRedisAdapter adapter.out.redis
Quem conecta tudo? UseCaseConfig config

Passo 1: criar o domain model

Comece pelo conceito de negócio. Para login, precisamos de um usuário.

package com.arkorizon.service.auth.domain.model;

import com.arkorizon.service.auth.domain.exception.UserInactiveException;
import java.util.UUID;

public record User(
        UUID id,
        String email,
        String passwordHash,
        boolean active
) {

    public void ensureCanLogin() {
        if (!active) {
            throw new UserInactiveException();
        }
    }
}

Use model quando estiver representando algo do negócio. Neste exemplo, User é um conceito real do domínio de autenticação.

Passo 2: criar command e result

O use case precisa receber dados de entrada. Esses dados entram como command.

package com.arkorizon.service.auth.domain.command;

public record LoginCommand(
        String email,
        String password
) {
}

Use command quando quiser representar uma intenção enviada ao use case.

O use case também precisa devolver uma saída.

package com.arkorizon.service.auth.domain.result;

public record AuthTokenResult(
        String accessToken,
        String refreshToken,
        long expiresInSeconds
) {
}

Use result quando quiser representar a resposta do núcleo para um adapter.

Passo 3: criar o input port

Input port é a porta de entrada do núcleo.

package com.arkorizon.service.auth.domain.port.in;

import com.arkorizon.service.auth.domain.command.LoginCommand;
import com.arkorizon.service.auth.domain.result.AuthTokenResult;

public interface LoginUseCase {

    AuthTokenResult execute(LoginCommand command);
}

O controller vai depender de LoginUseCase, não da implementação concreta.

Passo 4: descobrir os output ports

Agora pergunte: o login precisa de quais coisas externas?

Ele precisa:

  • buscar usuário no banco;
  • validar senha com algoritmo de hash;
  • gerar tokens;
  • salvar refresh token no Redis.

Cada necessidade externa vira uma porta de saída.

package com.arkorizon.service.auth.domain.port.out;

import com.arkorizon.service.auth.domain.model.User;
import java.util.Optional;

public interface FindUserByEmailPort {

    Optional<User> findByEmail(String email);
}
package com.arkorizon.service.auth.domain.port.out;

public interface PasswordVerifierPort {

    boolean matches(String rawPassword, String passwordHash);
}
package com.arkorizon.service.auth.domain.port.out;

import com.arkorizon.service.auth.domain.model.User;
import com.arkorizon.service.auth.domain.result.AuthTokenResult;

public interface TokenIssuerPort {

    AuthTokenResult issueFor(User user);
}
package com.arkorizon.service.auth.domain.port.out;

import java.time.Duration;
import java.util.UUID;

public interface SaveRefreshTokenPort {

    void save(UUID userId, String refreshToken, Duration ttl);
}

Output port não diz como será feito. Ele só diz o que o núcleo precisa.

Passo 5: implementar o use case

Agora o use case pode ser implementado sem saber nada de HTTP, JPA ou Redis.

package com.arkorizon.service.auth.domain.service;

import com.arkorizon.service.auth.domain.command.LoginCommand;
import com.arkorizon.service.auth.domain.exception.InvalidCredentialsException;
import com.arkorizon.service.auth.domain.model.User;
import com.arkorizon.service.auth.domain.port.in.LoginUseCase;
import com.arkorizon.service.auth.domain.port.out.FindUserByEmailPort;
import com.arkorizon.service.auth.domain.port.out.PasswordVerifierPort;
import com.arkorizon.service.auth.domain.port.out.SaveRefreshTokenPort;
import com.arkorizon.service.auth.domain.port.out.TokenIssuerPort;
import com.arkorizon.service.auth.domain.result.AuthTokenResult;
import java.time.Duration;

public final class LoginUseCaseImpl implements LoginUseCase {

    private static final Duration REFRESH_TOKEN_TTL = Duration.ofDays(30);

    private final FindUserByEmailPort findUserByEmailPort;
    private final PasswordVerifierPort passwordVerifierPort;
    private final TokenIssuerPort tokenIssuerPort;
    private final SaveRefreshTokenPort saveRefreshTokenPort;

    public LoginUseCaseImpl(
            final FindUserByEmailPort findUserByEmailPort,
            final PasswordVerifierPort passwordVerifierPort,
            final TokenIssuerPort tokenIssuerPort,
            final SaveRefreshTokenPort saveRefreshTokenPort
    ) {
        this.findUserByEmailPort = findUserByEmailPort;
        this.passwordVerifierPort = passwordVerifierPort;
        this.tokenIssuerPort = tokenIssuerPort;
        this.saveRefreshTokenPort = saveRefreshTokenPort;
    }

    @Override
    public AuthTokenResult execute(final LoginCommand command) {
        final User user = findUserByEmailPort.findByEmail(command.email())
                .orElseThrow(InvalidCredentialsException::new);

        user.ensureCanLogin();

        final boolean validPassword = passwordVerifierPort.matches(
                command.password(),
                user.passwordHash()
        );

        if (!validPassword) {
            throw new InvalidCredentialsException();
        }

        final AuthTokenResult token = tokenIssuerPort.issueFor(user);
        saveRefreshTokenPort.save(user.id(), token.refreshToken(), REFRESH_TOKEN_TTL);

        return token;
    }
}

Perceba: o use case não sabe se o usuário vem de PostgreSQL, se o token é JWT ou se o refresh token vai para Redis. Ele só conhece contratos.

Passo 6: criar DTOs HTTP

DTO é contrato externo. Ele pertence ao adapter web.

package com.arkorizon.service.auth.adapter.in.web.dto;

import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;

public record LoginRequestDTO(
        @NotBlank @Email String email,
        @NotBlank String password
) {
}
package com.arkorizon.service.auth.adapter.in.web.dto;

public record AuthTokenResponseDTO(
        String accessToken,
        String refreshToken,
        long expiresInSeconds
) {
}

Use DTO quando existe contrato de entrada ou saída com o mundo externo.

Passo 7: criar mapper web

Mapper separa contrato externo do núcleo.

package com.arkorizon.service.auth.adapter.in.web.mapper;

import com.arkorizon.service.auth.adapter.in.web.dto.AuthTokenResponseDTO;
import com.arkorizon.service.auth.adapter.in.web.dto.LoginRequestDTO;
import com.arkorizon.service.auth.domain.command.LoginCommand;
import com.arkorizon.service.auth.domain.result.AuthTokenResult;
import org.mapstruct.Mapper;

@Mapper(componentModel = "spring")
public interface AuthWebMapper {

    LoginCommand toCommand(LoginRequestDTO request);

    AuthTokenResponseDTO toResponse(AuthTokenResult result);
}

Crie mapper quando existe tradução entre bordas. Não faça essa tradução manualmente dentro do controller se ela começar a crescer.

Passo 8: criar controller

Controller é input adapter. Ele recebe HTTP e chama o input port.

package com.arkorizon.service.auth.adapter.in.web.controller;

import com.arkorizon.service.auth.adapter.in.web.dto.AuthTokenResponseDTO;
import com.arkorizon.service.auth.adapter.in.web.dto.LoginRequestDTO;
import com.arkorizon.service.auth.adapter.in.web.mapper.AuthWebMapper;
import com.arkorizon.service.auth.domain.command.LoginCommand;
import com.arkorizon.service.auth.domain.port.in.LoginUseCase;
import com.arkorizon.service.auth.domain.result.AuthTokenResult;
import jakarta.validation.Valid;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api/v1/auth")
public class AuthController {

    private final LoginUseCase loginUseCase;
    private final AuthWebMapper authWebMapper;

    public AuthController(
            final LoginUseCase loginUseCase,
            final AuthWebMapper authWebMapper
    ) {
        this.loginUseCase = loginUseCase;
        this.authWebMapper = authWebMapper;
    }

    @PostMapping("/login")
    public ResponseEntity<AuthTokenResponseDTO> login(
            @Valid @RequestBody final LoginRequestDTO request
    ) {
        final LoginCommand command = authWebMapper.toCommand(request);
        final AuthTokenResult result = loginUseCase.execute(command);
        final AuthTokenResponseDTO response = authWebMapper.toResponse(result);

        return ResponseEntity.ok(response);
    }
}

Controller não contém regra de negócio. Ele só traduz HTTP para caso de uso.

Passo 9: criar persistence entity

Entity representa tabela. Ela pertence ao adapter de persistência.

package com.arkorizon.service.auth.adapter.out.persistence.entity;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.util.UUID;

@Entity
@Table(name = "tb_user")
public class UserEntity {

    @Id
    @Column(name = "id", nullable = false)
    private UUID id;

    @Column(name = "email", nullable = false, unique = true)
    private String email;

    @Column(name = "password_hash", nullable = false)
    private String passwordHash;

    @Column(name = "active", nullable = false)
    private boolean active;

    protected UserEntity() {
    }

    public UUID getId() {
        return id;
    }

    public String getEmail() {
        return email;
    }

    public String getPasswordHash() {
        return passwordHash;
    }

    public boolean isActive() {
        return active;
    }
}

UserEntity não é User. Um é persistência; o outro é domínio.

Passo 10: criar repository JPA

Repository é detalhe de infraestrutura.

package com.arkorizon.service.auth.adapter.out.persistence.repository;

import com.arkorizon.service.auth.adapter.out.persistence.entity.UserEntity;
import java.util.Optional;
import java.util.UUID;
import org.springframework.data.jpa.repository.JpaRepository;

public interface UserJpaRepository extends JpaRepository<UserEntity, UUID> {

    Optional<UserEntity> findByEmail(String email);
}

O use case nunca deve receber UserJpaRepository.

Passo 11: criar persistence mapper

package com.arkorizon.service.auth.adapter.out.persistence.mapper;

import com.arkorizon.service.auth.adapter.out.persistence.entity.UserEntity;
import com.arkorizon.service.auth.domain.model.User;
import org.mapstruct.Mapper;

@Mapper(componentModel = "spring")
public interface UserPersistenceMapper {

    User toDomain(UserEntity entity);
}

Esse mapper evita que UserEntity vaze para o núcleo.

Passo 12: criar persistence adapter

package com.arkorizon.service.auth.adapter.out.persistence.adapter;

import com.arkorizon.service.auth.adapter.out.persistence.mapper.UserPersistenceMapper;
import com.arkorizon.service.auth.adapter.out.persistence.repository.UserJpaRepository;
import com.arkorizon.service.auth.domain.model.User;
import com.arkorizon.service.auth.domain.port.out.FindUserByEmailPort;
import java.util.Optional;
import org.springframework.stereotype.Component;

@Component
public class UserPersistenceAdapter implements FindUserByEmailPort {

    private final UserJpaRepository userJpaRepository;
    private final UserPersistenceMapper userPersistenceMapper;

    public UserPersistenceAdapter(
            final UserJpaRepository userJpaRepository,
            final UserPersistenceMapper userPersistenceMapper
    ) {
        this.userJpaRepository = userJpaRepository;
        this.userPersistenceMapper = userPersistenceMapper;
    }

    @Override
    public Optional<User> findByEmail(final String email) {
        return userJpaRepository.findByEmail(email)
                .map(userPersistenceMapper::toDomain);
    }
}

Aqui é onde JPA encontra Hexagonal: o adapter implementa a porta do núcleo.

Passo 13: criar Redis adapter

O Redis também é detalhe externo. O núcleo só conhece SaveRefreshTokenPort.

package com.arkorizon.service.auth.adapter.out.redis.model;

import java.util.UUID;

public record RefreshTokenCacheValue(
        UUID userId,
        String refreshToken
) {
}
package com.arkorizon.service.auth.adapter.out.redis.adapter;

import com.arkorizon.service.auth.adapter.out.redis.model.RefreshTokenCacheValue;
import com.arkorizon.service.auth.domain.port.out.SaveRefreshTokenPort;
import java.time.Duration;
import java.util.UUID;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;

@Component
public class RefreshTokenRedisAdapter implements SaveRefreshTokenPort {

    private static final String KEY_PREFIX = "auth:refresh-token:";

    private final RedisTemplate<String, RefreshTokenCacheValue> redisTemplate;

    public RefreshTokenRedisAdapter(
            final RedisTemplate<String, RefreshTokenCacheValue> redisTemplate
    ) {
        this.redisTemplate = redisTemplate;
    }

    @Override
    public void save(final UUID userId, final String refreshToken, final Duration ttl) {
        final String key = KEY_PREFIX + userId;
        final RefreshTokenCacheValue value = new RefreshTokenCacheValue(userId, refreshToken);

        redisTemplate.opsForValue().set(key, value, ttl);
    }
}

O use case não sabe que existe Redis. Ele só chama SaveRefreshTokenPort.

Passo 14: criar adapters de segurança

Validação de senha é infraestrutura.

package com.arkorizon.service.auth.adapter.out.security;

import com.arkorizon.service.auth.domain.port.out.PasswordVerifierPort;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Component;

@Component
public class BCryptPasswordVerifierAdapter implements PasswordVerifierPort {

    private final PasswordEncoder passwordEncoder;

    public BCryptPasswordVerifierAdapter(final PasswordEncoder passwordEncoder) {
        this.passwordEncoder = passwordEncoder;
    }

    @Override
    public boolean matches(final String rawPassword, final String passwordHash) {
        return passwordEncoder.matches(rawPassword, passwordHash);
    }
}

Geração de token também é infraestrutura.

package com.arkorizon.service.auth.adapter.out.security;

import com.arkorizon.service.auth.domain.model.User;
import com.arkorizon.service.auth.domain.port.out.TokenIssuerPort;
import com.arkorizon.service.auth.domain.result.AuthTokenResult;
import org.springframework.stereotype.Component;

@Component
public class JwtTokenIssuerAdapter implements TokenIssuerPort {

    @Override
    public AuthTokenResult issueFor(final User user) {
        final String accessToken = "generated-access-token";
        final String refreshToken = "generated-refresh-token";
        return new AuthTokenResult(accessToken, refreshToken, 3600);
    }
}

Em uma implementação real, JwtTokenIssuerAdapter usa JwtEncoder, chave privada, claims, expiração e configurações seguras.

Passo 15: configurar beans

O use case não tem @Service. Ele vira bean por configuração.

package com.arkorizon.service.auth.config;

import com.arkorizon.service.auth.domain.port.in.LoginUseCase;
import com.arkorizon.service.auth.domain.port.out.FindUserByEmailPort;
import com.arkorizon.service.auth.domain.port.out.PasswordVerifierPort;
import com.arkorizon.service.auth.domain.port.out.SaveRefreshTokenPort;
import com.arkorizon.service.auth.domain.port.out.TokenIssuerPort;
import com.arkorizon.service.auth.domain.service.LoginUseCaseImpl;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class UseCaseConfig {

    @Bean
    public LoginUseCase loginUseCase(
            final FindUserByEmailPort findUserByEmailPort,
            final PasswordVerifierPort passwordVerifierPort,
            final TokenIssuerPort tokenIssuerPort,
            final SaveRefreshTokenPort saveRefreshTokenPort
    ) {
        return new LoginUseCaseImpl(
                findUserByEmailPort,
                passwordVerifierPort,
                tokenIssuerPort,
                saveRefreshTokenPort
        );
    }
}

Esse é o ponto em que Spring conecta as implementações concretas aos contratos do núcleo.

Passo 16: configurar Spring Security

package com.arkorizon.service.auth.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain securityFilterChain(final HttpSecurity http) throws Exception {
        return http
                .csrf(csrf -> csrf.disable())
                .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                .authorizeHttpRequests(auth -> auth
                        .requestMatchers("/api/v1/auth/login", "/actuator/health").permitAll()
                        .anyRequest().authenticated()
                )
                .build();
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

Security configuration pertence à infraestrutura. Não coloque essa lógica no domínio.

Passo 17: tratar exceções HTTP

package com.arkorizon.service.auth.adapter.in.web.exception;

import com.arkorizon.service.auth.domain.exception.InvalidCredentialsException;
import com.arkorizon.service.auth.domain.exception.UserInactiveException;
import java.time.Instant;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(InvalidCredentialsException.class)
    public ResponseEntity<ApiErrorDTO> handleInvalidCredentials(
            final InvalidCredentialsException exception
    ) {
        return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
                .body(new ApiErrorDTO("INVALID_CREDENTIALS", exception.getMessage(), Instant.now()));
    }

    @ExceptionHandler(UserInactiveException.class)
    public ResponseEntity<ApiErrorDTO> handleUserInactive(
            final UserInactiveException exception
    ) {
        return ResponseEntity.status(HttpStatus.FORBIDDEN)
                .body(new ApiErrorDTO("USER_INACTIVE", exception.getMessage(), Instant.now()));
    }
}
package com.arkorizon.service.auth.adapter.in.web.exception;

import java.time.Instant;

public record ApiErrorDTO(
        String code,
        String message,
        Instant timestamp
) {
}

A exceção nasce no domínio. O HTTP status nasce no adapter web.

Como rodar com Spring Boot

Classe principal:

package com.arkorizon.service.auth;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class AuthApplication {

    public static void main(final String[] args) {
        SpringApplication.run(AuthApplication.class, args);
    }
}

Configuração:

server:
  servlet:
    context-path: /ark-auth-service

spring:
  application:
    name: ark-auth-service
  datasource:
    url: jdbc:postgresql://localhost:5432/arkorizon
    username: ark_auth_user
    password: ${ARK_AUTH_DB_PASSWORD}
  data:
    redis:
      host: localhost
      port: 6379
  jpa:
    hibernate:
      ddl-auto: validate
    open-in-view: false

Comando local:

./mvnw spring-boot:run

Chamada HTTP:

curl -X POST http://localhost:8080/ark-auth-service/api/v1/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email":"user@arkorizon.com","password":"secret"}'

Ordem recomendada para implementar

Para quem nunca implementou Hexagonal, siga esta ordem:

  1. Escreva o nome do caso de uso.
  2. Crie o Command.
  3. Crie o Result.
  4. Crie o Input Port.
  5. Crie os Output Ports necessários.
  6. Crie os Domain Models.
  7. Implemente o UseCaseImpl em Java puro.
  8. Teste o use case com mocks dos output ports.
  9. Crie DTOs do adapter web.
  10. Crie mapper web.
  11. Crie controller.
  12. Crie entities e repositories JPA.
  13. Crie mapper de persistência.
  14. Crie persistence adapter.
  15. Crie adapters de Redis, segurança, APIs externas ou filas.
  16. Configure beans no Spring.
  17. Rode a aplicação.
  18. Adicione testes de adapter e integração.

Como saber se fez certo

Você fez certo se consegue testar LoginUseCaseImpl sem Spring, sem banco, sem Redis e sem HTTP.

Exemplo:

class LoginUseCaseImplTest {

    private final FindUserByEmailPort findUserByEmailPort = mock(FindUserByEmailPort.class);
    private final PasswordVerifierPort passwordVerifierPort = mock(PasswordVerifierPort.class);
    private final TokenIssuerPort tokenIssuerPort = mock(TokenIssuerPort.class);
    private final SaveRefreshTokenPort saveRefreshTokenPort = mock(SaveRefreshTokenPort.class);

    private final LoginUseCase loginUseCase = new LoginUseCaseImpl(
            findUserByEmailPort,
            passwordVerifierPort,
            tokenIssuerPort,
            saveRefreshTokenPort
    );
}

Se esse teste exige subir contexto Spring, conectar banco ou instanciar controller, o núcleo está acoplado demais.

Resumo prático

Se você precisa... Crie...
Receber HTTP Controller + DTO
Traduzir HTTP para núcleo Web mapper
Representar entrada do use case Command
Representar saída do use case Result
Representar conceito de negócio Domain model
Expor ação da aplicação Input port
Pedir algo externo Output port
Implementar banco Persistence adapter
Mapear tabela Entity
Consultar via Spring Data Repository
Usar Redis Redis adapter
Usar JWT/BCrypt Security adapter
Conectar dependências Configuration

Regra final

Spring Boot, JPA, Redis, HTTP e security são ferramentas. Elas devem servir o núcleo, não definir o núcleo.

O núcleo deve continuar compreensível mesmo se todos os adapters forem removidos.