DEV Community

Roberto de Vargas Neto
Roberto de Vargas Neto

Posted on

O Coração da B3: Construindo o Matching Engine com RabbitMQ, Redis e Spring Boot

Olá, pessoal!

Dando continuidade à série My Broker B3, chegamos em um dos componentes mais aguardados do ecossistema: o B3 Matching Engine API.

No post anterior, construímos o b3-market-sync-api que sincroniza preços reais da brapi.dev e os armazena no Redis. Agora é hora de usar esses dados — este é o serviço que simula a própria Bolsa de Valores, decidindo se uma ordem de compra ou venda será executada ou rejeitada com base nos preços que acabamos de colocar no cache.


🏗️ O que é o Matching Engine?

No mundo real, quando você envia uma ordem de compra pela sua corretora, ela vai para a B3, que verifica se existe uma contraparte disposta a negociar naquele preço. Esse processo é chamado de matching.

No nosso simulador, o b3-matching-engine-api reproduz essa lógica de forma simplificada:

  1. Consumo: Recebe a ordem da corretora via RabbitMQ (fila mq-broker-to-b3)
  2. Preço: Busca o preço atual do ativo no Redis (market:price:{TICKER}) — dados injetados pelo Market Sync que construímos antes
  3. Decisão: Aplica a regra de matching
  4. Persistência: Grava o resultado no PostgreSQL
  5. Notificação: Devolve o resultado para a corretora via RabbitMQ (fila mq-b3-to-broker)
[Broker] ──RabbitMQ──▶ [mq-broker-to-b3] ──▶ [Matching Engine]
                                                      │
                                             Redis: market:price:{TICKER}
                                             (alimentado pelo Market Sync ⬆️)
                                                      │
                                         ┌────────────┴────────────┐
                                      FILLED                   REJECTED
                                         │                         │
                                   PostgreSQL                 PostgreSQL
                                         │                         │
                               [mq-b3-to-broker] ◀────────────────┘
                                         │
                                      [Broker]
Enter fullscreen mode Exit fullscreen mode

🎯 Foco no MVP

Antes de mergulhar no código, vale o mesmo disclaimer dos posts anteriores: estamos construindo a base. O objetivo é ter o fluxo ponta a ponta funcionando com robustez suficiente para validar a POC.

Nesta fase, priorizei:

  • Fluxo principal funcionando de ponta a ponta
  • Tratamento correto de falhas (sem ordens "sumindo" silenciosamente)
  • Dead Letter Queue para mensagens que falham no processamento
  • API REST para consulta do histórico de execuções
  • Documentação via Swagger

🛠️ Stack Tecnológica

Tecnologia Uso
Java 21 + Spring Boot 3.5 Core do serviço
Spring RabbitMQ Consumo de ordens e envio de resultados
Spring Data Redis Consulta de preços em tempo real
Spring Data JPA + PostgreSQL Persistência das execuções
Flyway Versionamento do schema do banco
SpringDoc OpenAPI Documentação via Swagger UI

🏗️ Os Pilares da Implementação

1. Configuração do RabbitMQ

Um ponto crítico que aprendi neste serviço: nunca dependa que as filas já existam no broker. Se a aplicação subir antes do RabbitMQ ter as filas criadas, o consumer falha na inicialização.

A solução é declarar todos os beans de infraestrutura diretamente no Spring — ele garante que filas, exchanges e bindings existam antes de qualquer mensagem trafegar:

@Bean
public DirectExchange exchange() {
    return new DirectExchange(exchangeName);
}

@Bean
public Queue queueIn() {
    return QueueBuilder.durable(queueIn)
            .withArgument("x-dead-letter-exchange", dlxName)
            .withArgument("x-dead-letter-routing-key", dlqName)
            .build();
}

@Bean
public Binding bindingQueueIn(Queue queueIn, DirectExchange exchange) {
    return BindingBuilder.bind(queueIn).to(exchange).with(routingKey);
}
Enter fullscreen mode Exit fullscreen mode

Note o withArgument — ele já configura a Dead Letter Queue diretamente na declaração da fila principal. Qualquer mensagem que falhe no processamento é automaticamente redirecionada.

2. A Lógica de Matching

O coração do serviço. A regra é simples e direta:

  • COMPRA (BUY): Se o preço que o usuário aceita pagar >= preço de mercado → FILLED
  • VENDA (SELL): Se o preço que o usuário quer receber <= preço de mercado → FILLED
  • Caso contrário → REJECTED
private static boolean isCanExecute(OrderEventDTO order, BigDecimal marketPrice) {
    if (SideStatus.BUY.name().equalsIgnoreCase(order.getSide())) {
        return order.getPrice().compareTo(marketPrice) >= 0;
    } else if (SideStatus.SELL.name().equalsIgnoreCase(order.getSide())) {
        return order.getPrice().compareTo(marketPrice) <= 0;
    }
    return false;
}
Enter fullscreen mode Exit fullscreen mode

3. Tratamento de Falhas — Sem Ordens Sumindo

Um bug silencioso que identifiquei: quando o ticker não estava no Redis, o método simplesmente retornava sem fazer nada. A corretora ficava esperando uma resposta que nunca chegava.

A correção foi simples mas fundamental — qualquer falha deve notificar o broker:

if (marketDataOpt.isEmpty()) {
    log.warn("Price for ticker {} not found in Redis. Rejecting order {}",
             order.getTicker(), order.getOrderId());
    OrderResponseEvent response = new OrderResponseEvent(
        order.getOrderId(), ExecutionStatus.REJECTED.name(), BigDecimal.ZERO);
    orderProducer.sendToBroker(response);
    return;
}
Enter fullscreen mode Exit fullscreen mode

💡 Este é exatamente o motivo pelo qual construímos o b3-market-sync-api primeiro. Se o Redis não tiver o preço, a ordem é imediatamente rejeitada — comportamento correto e previsível.

4. Garantia Transacional

Outro ponto crítico: persistir no PostgreSQL e publicar no RabbitMQ são duas operações distintas. Se o banco salvar mas o publish falhar, a execução fica gravada mas o broker nunca é notificado — inconsistência silenciosa.

A solução foi adicionar @Transactional no método que orquestra as duas operações:

@Transactional
private void saveAndNotify(OrderEventDTO order, BigDecimal price, ExecutionStatus status) {
    // 1. Persists in PostgreSQL
    OrderExecution execution = OrderExecution.builder()
            .orderId(order.getOrderId())
            .ticker(order.getTicker())
            .side(SideStatus.valueOf(order.getSide()))
            .quantity(order.getQuantity())
            .executedPrice(price)
            .status(status)
            .build();
    repository.save(execution);

    // 2. Notifies the Broker
    OrderResponseEvent response = new OrderResponseEvent(
        order.getOrderId(), status.name(), price);
    orderProducer.sendToBroker(response);
}
Enter fullscreen mode Exit fullscreen mode

5. Dead Letter Queue

Para mensagens que falham no consumer (erro inesperado no processamento), configuramos uma DLQ completa:

@Bean
public DirectExchange deadLetterExchange() {
    return new DirectExchange(dlxName);
}

@Bean
public Queue deadLetterQueue() {
    return QueueBuilder.durable(dlqName).build();
}

@Bean
public Binding bindingDlq(Queue deadLetterQueue, DirectExchange deadLetterExchange) {
    return BindingBuilder.bind(deadLetterQueue)
            .to(deadLetterExchange)
            .with(dlqName);
}
Enter fullscreen mode Exit fullscreen mode

No consumer, relançamos a exceção para que o RabbitMQ saiba que a mensagem falhou e a redirecione:

@RabbitListener(queues = "${app.rabbitmq.queue-in}")
public void receiveOrder(OrderEventDTO event) {
    log.info("New order received: ID {} | Ticker {} | Side {}",
            event.getOrderId(), event.getTicker(), event.getSide());
    try {
        matchingService.process(event);
    } catch (Exception e) {
        log.error("Failed to process order {}: {}", event.getOrderId(), e.getMessage(), e);
        throw e; // RabbitMQ routes to DLQ
    }
}
Enter fullscreen mode Exit fullscreen mode

6. API REST + Swagger

Como o serviço expõe endpoints de consulta, aproveitei para configurar o Swagger UI desde o início:

@Bean
public OpenAPI customOpenAPI() {
    return new OpenAPI()
            .info(new Info()
                    .title("B3 Matching Engine API")
                    .version("1.0.0")
                    .description("Simulates the B3 stock exchange matching engine..."));
}
Enter fullscreen mode Exit fullscreen mode

Os endpoints disponíveis:

Método Endpoint Descrição
GET /api/v1/executions Lista todas as execuções
GET /api/v1/executions/order/{orderId} Busca por ID de ordem
GET /api/v1/executions/ticker/{ticker} Busca por ticker
GET /api/v1/executions/status/{status} Busca por status

✅ Validando a Execução

Com a aplicação rodando localmente, todos os componentes subiram corretamente:

  • ✅ Flyway aplicou a migration da tabela order_executions
  • ✅ Hibernate validou o schema
  • ✅ RabbitMQ conectado com filas e DLQ declaradas
  • ✅ Tomcat rodando na porta 8091
  • ✅ Swagger UI acessível em http://localhost:8091/swagger-ui.html

🚀 O que vem a seguir?

Com o b3-market-sync-api alimentando o Redis e o b3-matching-engine-api pronto para processar ordens, toda a parte da B3 está operacional.

O próximo passo é construir o broker-order-api — o maestro que vai orquestrar todo o ciclo de vida de uma ordem do lado da corretora:

  1. Receber a intenção de compra/venda do usuário
  2. Validar o ticker via REST na broker-asset-api
  3. Salvar a ordem como PENDING
  4. Publicar o evento no Kafka
  5. Enviar a ordem para o Matching Engine via RabbitMQ
  6. Consumir o feedback e atualizar o status final

Quando esse serviço estiver pronto, teremos o primeiro fluxo completo ponta a ponta rodando no ecossistema.

Ficou com alguma dúvida sobre a lógica de matching ou sobre a configuração do RabbitMQ com DLQ? Deixe nos comentários!


🔎 Sobre a série

⬅️ Post Anterior: Sincronizando o Mercado Real: Consumindo a Brapi e Alimentando o Redis com Spring Boot

📘 Índice da Série: Guia da Série


Links:

Top comments (0)