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.
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.setFieldo 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 umBigDecimalparadouble, 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! 🚀