← início

Desafio Java Júnior Banco Itaú 2024

Desafio Itaú Backend 2024

Fala galera, beleza? Vi que minhas postagens estavam indo para um caminho do tipo “aprenda a programar em Java” e nunca foi esse o intuito dos meus registros. Mas enfim, essa semana me deparei com um desafio que achei muito interessante — um desafio Júnior da instituição bancária Itaú de 2024 — e resolvi tentar.

Repositório do Desafio

Depois de ler toda a documentação, achei uma grande oportunidade para me aventurar em TDD (Test Driven Development), pois testes de unidade seriam um diferencial neste desafio. Então comecei por eles:

@Test
void deveRejeitarTransacaoComValorNulo() {
    Transacao transacao = new Transacao(
            null,
            OffsetDateTime.now()
    );
    assertThrows(TransacaoInvalidaException.class, () -> service.processarTransacao(transacao));
}

@Test
void deveRejeitarTransacaoComValorNegativo() {
    Transacao transacao = new Transacao(
            BigDecimal.valueOf(-100.00),
            OffsetDateTime.now()
    );
    assertThrows(TransacaoInvalidaException.class, () -> service.processarTransacao(transacao));
}

@Test
void deveRejeitarTransacaoComDataNula() {
    Transacao transacao = new Transacao(
            BigDecimal.valueOf(100.00),
            null
    );
    assertThrows(TransacaoInvalidaException.class, () -> service.processarTransacao(transacao));
}

@Test
void deveRejeitarTransacaoComDataFutura() {
    Transacao transacao = new Transacao(
            BigDecimal.valueOf(100.00),
            OffsetDateTime.now().plusDays(1)
    );
    assertThrows(TransacaoInvalidaException.class, () -> service.processarTransacao(transacao));
}

@Test
void deveLimparTodasAsTransacoes() {
    service.limparTransacoes();
    verify(repository).limpar();
}

@Test
void deveSalvarTransacaoValida() {
    Transacao transacao = new Transacao(
            BigDecimal.valueOf(100.00),
            OffsetDateTime.now()
    );
    assertDoesNotThrow(() -> service.processarTransacao(transacao));
    verify(repository).salvar(transacao);
}

No meu código já está sendo empregada a TransacaoInvalidaException.class, que foi criada depois — no momento da construção dos testes foi utilizado o IllegalArgumentException.class. Partindo do princípio de que todos os testes não passaram, vamos à construção da nossa classe Transacao, e já aproveito para criar o DTO do tipo Record, pois a partir do Java 16+ ele já vem com Construtor, Getter, Equals e HashCode implementados por padrão. Poderia ter criado a própria Transacao como um record? Poderia — se eu não fosse fazer o encapsulamento dos atributos:

@AllArgsConstructor
@Getter
public class Transacao {
    private final BigDecimal valor;
    private final OffsetDateTime dataHora;
}
public record TransacaoDTO(BigDecimal valor, OffsetDateTime dataHora) {}

Anotações do Lombok apenas para ter o construtor com todos os argumentos e o getter — sem setter e com atributos private final, pensando sempre em imutabilidade. Na escolha dos tipos: BigDecimal pela sua precisão, já que estamos falando de valores monetários, e OffsetDateTime por levar em conta o fuso horário.

O Repository para persistir as transações em memória com os métodos necessários para que tudo na aplicação funcione, incluindo o listar — que, por mais que não tenhamos um endpoint para listar transações, eu preciso dele para o retorno das estatísticas:

@Repository
public class TransacaoRepository {
    private final ConcurrentLinkedDeque<Transacao> transacoes = new ConcurrentLinkedDeque<>();

    public void salvar(Transacao transacao) {
        transacoes.add(transacao);
    }

    public void limpar() {
        transacoes.clear();
    }

    public List<Transacao> listar() {
        return List.copyOf(transacoes);
    }
}

Aqui foi onde a coisa pegou um pouco… pensar em qual estrutura de dados usar. A parte de validações foi tranquila, mas tive que pesquisar sobre qual estrutura seria ideal para este contexto. Eu precisava de uma lista thread-safe para evitar corrupção de dados e que funcionasse como uma fila com as entradas mais antigas sendo lidas primeiro — porque em determinado momento da aplicação eu vou precisar das transações dos últimos 60 segundos. Isso reduziu minha pesquisa a duas opções: ConcurrentLinkedDeque e CopyOnWriteArrayList.

Construí o projeto inteiro com CopyOnWriteArrayList, mas levando em consideração que a cada escrita ele cria uma cópia do array inteiro, decidi migrar para ConcurrentLinkedDeque — pensando em performance com milhares de requisições por minuto vs. uma leitura de estatística por minuto.

O service de Transacao foi bem tranquilo, necessitando apenas das 4 validações. Eu poderia ter substituído pelo Bean Validation, mas com a exceção automática que ele lançaria em um @NotNull, por exemplo, o Spring retornaria por padrão o status code 400 — e o desafio pedia 422. Então decidi manter as validações feitas na mão mesmo:

@Service
@RequiredArgsConstructor
@Slf4j
public class TransacaoService {

    private final TransacaoRepository repository;

    public void processarTransacao(Transacao transacao) {

        log.info("Processando transação: valor: {}, dataHora: {}", transacao.getValor(), transacao.getDataHora());

        if (transacao.getValor() == null) {
            log.warn("Transação inválida: valor nulo");
            throw new TransacaoInvalidaException("Valor da transação não pode ser nulo.");
        }

        if (transacao.getValor().compareTo(BigDecimal.ZERO) < 0) {
            log.warn("Transação inválida: valor negativo");
            throw new TransacaoInvalidaException("Valor da transação deve ser positivo.");
        }

        if (transacao.getDataHora() == null) {
            log.warn("Transação inválida: data e hora nulas");
            throw new TransacaoInvalidaException("Data e hora da transação não podem ser nulas.");
        }

        if (transacao.getDataHora().isAfter(OffsetDateTime.now())) {
            log.warn("Transação inválida: data e hora futuras");
            throw new TransacaoInvalidaException("Data e hora da transação não podem ser futuras.");
        }
        repository.salvar(transacao);
    }

    public void limparTransacoes() {
        repository.limpar();
    }
}

Logs e observabilidade também são um diferencial deste desafio — com a anotação @Slf4j consigo controlar esses logs durante a execução do meu service.

E como havia dito no começo, durante a construção do service criei a exceção personalizada TransacaoInvalidaException para não usar a padrão do Java, que seria o IllegalArgumentException. No GlobalExceptionHandler controlo o comportamento tanto do HttpMessageNotReadableException quanto da TransacaoInvalidaException, garantindo que ambas retornem sem corpo algum — apenas o status code correto:

public class TransacaoInvalidaException extends RuntimeException {
    public TransacaoInvalidaException(String message) {
        super(message);
    }
}
@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(TransacaoInvalidaException.class)
    public ResponseEntity<Void> handleTransacaoInvalida() {
        return ResponseEntity.unprocessableContent().build();
    }

    @ExceptionHandler(HttpMessageNotReadableException.class)
    public ResponseEntity<Void> handleJsonInvalido() {
        return ResponseEntity.badRequest().build();
    }
}

O Controller também foi bem tranquilo — somente dois endpoints na rota /transacao, já com o mapper de Transacao implementado para fazer a transferência de dados sem expor a classe do meu domínio. Pra isso, o uso do DTO:

@RestController
@RequestMapping("/transacao")
@RequiredArgsConstructor
public class TransacaoController {

    private final TransacaoService service;
    private final TransacaoMapper mapper;

    @PostMapping
    public ResponseEntity<Void> processarTransacao(@RequestBody TransacaoDTO request) {
        var transacao = mapper.toTransacao(request);
        service.processarTransacao(transacao);
        return ResponseEntity.status(201).build();
    }

    @DeleteMapping
    public ResponseEntity<Void> limparTransacoes() {
        service.limparTransacoes();
        return ResponseEntity.ok().build();
    }
}
@Component
public class TransacaoMapper {
    public Transacao toTransacao(TransacaoDTO dto) {
        return new Transacao(dto.valor(), dto.dataHora());
    }
}

Com tudo certo em Transacao, parto para a segunda parte e começo a construção de todo o ecossistema de Estatistica — também partindo pelos testes:

@ExtendWith(MockitoExtension.class)
public class EstatisticaServiceTest {

    @InjectMocks
    private EstatisticaService service;

    @Mock
    private TransacaoRepository repository;

    @BeforeEach
    void setUp() {
        ReflectionTestUtils.setField(service, "intervaloSegundos", 60);
    }

    @Test
    void deveRetornarTodosOsValoresZeradosQuandoNaoExistiremTransacoes() {
        when(repository.listar()).thenReturn(Collections.emptyList());

        Estatistica estatistica = service.calcularEstatisticas();

        assertEquals(0L, estatistica.getCount());
        assertEquals(0.0, estatistica.getSum());
        assertEquals(0.0, estatistica.getAvg());
        assertEquals(0.0, estatistica.getMin());
        assertEquals(0.0, estatistica.getMax());
    }

    @Test
    void deveRetornarZeradoQuandoTransacoesForemAntigas() {
        List<Transacao> transacoes = List.of(
                new Transacao(BigDecimal.valueOf(100.00), OffsetDateTime.now().minusSeconds(70)),
                new Transacao(BigDecimal.valueOf(200.00), OffsetDateTime.now().minusSeconds(160))
        );
        when(repository.listar()).thenReturn(transacoes);

        Estatistica estatistica = service.calcularEstatisticas();

        assertEquals(0L, estatistica.getCount());
    }

    @Test
    void deveCalcularEstatisticasDasTransacoesNoIntervaloDE60Segundos() {
        List<Transacao> transacoes = List.of(
                new Transacao(BigDecimal.valueOf(100.00), OffsetDateTime.now().minusSeconds(20)),
                new Transacao(BigDecimal.valueOf(200.00), OffsetDateTime.now().minusSeconds(30)),
                new Transacao(BigDecimal.valueOf(300.00), OffsetDateTime.now().minusSeconds(40))
        );
        when(repository.listar()).thenReturn(transacoes);

        Estatistica estatistica = service.calcularEstatisticas();

        assertEquals(3L, estatistica.getCount());
        assertEquals(600.00, estatistica.getSum());
        assertEquals(200.00, estatistica.getAvg());
        assertEquals(100.00, estatistica.getMin());
        assertEquals(300.00, estatistica.getMax());
    }

    @Test
    void deveCalcularEstatisticasSomenteDasTransacoesValidas() {
        List<Transacao> transacoes = List.of(
                new Transacao(BigDecimal.valueOf(100.00), OffsetDateTime.now().minusSeconds(20)),
                new Transacao(BigDecimal.valueOf(200.00), OffsetDateTime.now().minusSeconds(30)),
                new Transacao(BigDecimal.valueOf(999.00), OffsetDateTime.now().minusSeconds(120))
        );
        when(repository.listar()).thenReturn(transacoes);

        Estatistica estatistica = service.calcularEstatisticas();

        assertEquals(2L, estatistica.getCount());
        assertEquals(300.00, estatistica.getSum());
        assertEquals(150.00, estatistica.getAvg());
        assertEquals(100.00, estatistica.getMin());
        assertEquals(200.00, estatistica.getMax());
    }
}

⚠️ Atenção: No ReflectionTestUtils.setField o nome do campo precisa bater exatamente com o nome do atributo na classe — no meu caso "intervaloSegundos" e não "INTERVALO_SEGUNDOS". Parece detalhe bobo, mas esse tipo de erro faz o teste passar sem de fato injetar o valor correto.

Tudo falhando como esperado — construo a classe e seu service. Não há necessidade de repository nem de DTO, pois a própria classe já é um retorno de cálculos dos dados armazenados em Transacao:

@AllArgsConstructor
@Getter
@JsonPropertyOrder({"count", "sum", "avg", "min", "max"})
public class Estatistica {
    private final long count;
    private final double sum;
    private final double avg;
    private final double min;
    private final double max;
}
@Service
@RequiredArgsConstructor
@Slf4j
public class EstatisticaService {

    private final TransacaoRepository repository;

    @Value("${transacao.intervalo-segundos:60}")
    private int intervaloSegundos;

    public Estatistica calcularEstatisticas() {
        StopWatch stopWatch = new StopWatch();
        stopWatch.start("calcularEstatisticas");

        log.info("Calculando estatísticas para transações dos últimos {} segundos", intervaloSegundos);
        OffsetDateTime intervalo = OffsetDateTime.now().minusSeconds(intervaloSegundos);

        log.info("Recuperando transações do repositório");
        List<Transacao> transacoes = repository.listar();

        log.info("Filtrando transações e calculando estatísticas");
        DoubleSummaryStatistics estatistica = transacoes.stream()
                .filter(t -> t.getDataHora().isAfter(intervalo))
                .mapToDouble(t -> t.getValor().doubleValue())
                .summaryStatistics();

        if (estatistica.getCount() == 0) {
            log.info("Nenhuma transação válida encontrada");
            return new Estatistica(0L, 0.0, 0.0, 0.0, 0.0);
        }

        stopWatch.stop();
        log.info("Estatísticas calculadas em {} ms", stopWatch.getTotalTimeMillis());

        return new Estatistica(
                estatistica.getCount(),
                estatistica.getSum(),
                estatistica.getAverage(),
                estatistica.getMin(),
                estatistica.getMax()
        );
    }
}

Na própria documentação do desafio já é entregue a dica do DoubleSummaryStatistics — uma classe do Java 8+ que coleta estatísticas de um conjunto de valores double: count, sum, min, max e average.

💡 Vale mencionar: ao usar .mapToDouble(t -> t.getValor().doubleValue()) estamos convertendo um BigDecimal para double, o que pode causar pequenas perdas de precisão em valores muito grandes ou com muitas casas decimais. Para este desafio o impacto é mínimo, mas em um sistema financeiro de produção isso precisaria de atenção redobrada.

Monitorar o tempo que a aplicação leva para realizar os cálculos também é um diferencial. Eu poderia ter implementado via Actuator (que já estava no pom.xml para monitorar o health da aplicação) + Micrometer, ou com o próprio Java usando System.nanoTime(), mas decidi utilizar o StopWatch do próprio Spring por ser mais expressivo e direto.

Já a anotação @JsonPropertyOrder eu utilizei porque o meu endpoint estava retornando count e avg na ordem errada no JSON e não estava conseguindo resolver kkkkk — então usei ela para guiar o Jackson na ordem correta de serialização (Java → JSON).

Por fim o endpoint /estatistica, bem tranquilo, somente com um método GET e o retorno desejado:

@RestController
@RequestMapping("/estatistica")
@RequiredArgsConstructor
public class EstatisticaController {

    private final EstatisticaService service;

    @GetMapping
    public ResponseEntity<Estatistica> processarEstatisticas() {
        return ResponseEntity.ok(service.calcularEstatisticas());
    }
}

Um ponto importante a destacar é a configuração do intervalo de tempo, que controlo no application.properties:

transacao.intervalo-segundos=60

Isso permite alterar o intervalo sem precisar recompilar a aplicação — só mudar a propriedade e subir o serviço com o novo valor.

E por último, a Dockerização da aplicação. Aqui usei um multi-stage build: na primeira etapa (build) uso a imagem completa com o JDK para compilar e empacotar a aplicação. Na segunda etapa uso apenas o JRE — muito mais leve — e copio somente o .jar gerado. Além disso, crio um usuário sem permissões administrativas dentro do container, uma boa prática de segurança para não rodar a aplicação como root:

FROM eclipse-temurin:21-jdk-alpine AS build
WORKDIR /app
COPY . .
RUN ./mvnw clean package -DskipTests

FROM eclipse-temurin:21-jre-alpine
RUN addgroup -S spring && \
    adduser -S spring -G spring
WORKDIR /app
COPY --from=build --chown=spring:spring /app/target/*.jar app.jar
USER spring
ENTRYPOINT ["java", "-jar", "app.jar"]

Essa foi minha forma de realizar este desafio. Acredito que existam mil e uma formas diferentes de chegar ao mesmo resultado — e essa é a grande magia da programação. Até a próxima! 🚀