DEV Community

Roberto de Vargas Neto
Roberto de Vargas Neto

Posted on

The Heart of B3: Building the Matching Engine with RabbitMQ, Redis and Spring Boot

Hello everyone!

Continuing the My Broker B3 series, we've reached one of the most anticipated components of the ecosystem: the B3 Matching Engine API.

In the previous post, we built b3-market-sync-api which synchronizes real prices from brapi.dev and stores them in Redis. Now it's time to use that data β€” this is the service that simulates the stock exchange itself, deciding whether a buy or sell order will be executed or rejected based on the prices we just put in cache.


πŸ—οΈ What is the Matching Engine?

In the real world, when you place a buy order through your broker, it gets sent to B3, which checks whether there's a counterpart willing to trade at that price. This process is called matching.

In our simulator, the b3-matching-engine-api reproduces this logic in a simplified way:

  1. Intake: Receives the order from the broker via RabbitMQ (queue mq-broker-to-b3)
  2. Price: Fetches the current asset price from Redis (market:price:{TICKER}) β€” data injected by the Market Sync we built previously
  3. Decision: Applies the matching rule
  4. Persistence: Records the result in PostgreSQL
  5. Notification: Returns the result to the broker via RabbitMQ (queue mq-b3-to-broker)
[Broker] ──RabbitMQ──▢ [mq-broker-to-b3] ──▢ [Matching Engine]
                                                      β”‚
                                             Redis: market:price:{TICKER}
                                             (fed by Market Sync ⬆️)
                                                      β”‚
                                         β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                                      FILLED                   REJECTED
                                         β”‚                         β”‚
                                   PostgreSQL                 PostgreSQL
                                         β”‚                         β”‚
                               [mq-b3-to-broker] β—€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                                         β”‚
                                      [Broker]
Enter fullscreen mode Exit fullscreen mode

🎯 MVP Focus

Before diving into the code, same disclaimer as previous posts: we're building the foundation. The goal is to have an end-to-end flow working with enough robustness to validate the POC.

In this phase, I prioritized:

  • End-to-end flow working correctly
  • Proper failure handling (no orders silently disappearing)
  • Dead Letter Queue for messages that fail during processing
  • REST API for querying execution history
  • Swagger documentation

πŸ› οΈ Tech Stack

Technology Usage
Java 21 + Spring Boot 3.5 Service core
Spring RabbitMQ Order intake and result notification
Spring Data Redis Real-time price lookup
Spring Data JPA + PostgreSQL Execution persistence
Flyway Database schema versioning
SpringDoc OpenAPI Swagger UI documentation

πŸ—οΈ Implementation Pillars

1. RabbitMQ Configuration

A critical lesson learned here: never rely on queues already existing in the broker. If the application starts before RabbitMQ has the queues created, the consumer fails on startup.

The solution is to declare all infrastructure beans directly in Spring β€” it ensures queues, exchanges and bindings exist before any message is processed:

@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 the withArgument calls β€” they configure the Dead Letter Queue directly in the main queue declaration. Any message that fails processing is automatically redirected.

2. The Matching Logic

The heart of the service. The rule is straightforward:

  • BUY: If the price the user accepts to pay >= market price β†’ FILLED
  • SELL: If the price the user wants to receive <= market price β†’ FILLED
  • Otherwise β†’ 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. Failure Handling β€” No Silent Order Loss

A silent bug I identified: when the ticker wasn't in Redis, the method simply returned without doing anything. The broker would wait for a response that never came.

The fix was simple but critical β€” any failure must notify the 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

πŸ’‘ This is exactly why we built b3-market-sync-api first. If Redis doesn't have the price, the order is immediately rejected β€” correct and predictable behavior.

4. Transactional Guarantee

Another critical point: persisting to PostgreSQL and publishing to RabbitMQ are two distinct operations. If the database saves but the publish fails, the execution is recorded but the broker is never notified β€” silent inconsistency.

The solution was adding @Transactional to the method that orchestrates both operations:

@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

For messages that fail in the consumer (unexpected processing error), we configured a full DLQ setup:

@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

In the consumer, we rethrow the exception so RabbitMQ knows the message failed and routes it to the DLQ:

@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. REST API + Swagger

Since the service exposes query endpoints, I configured Swagger UI from the start:

@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

Available endpoints:

Method Endpoint Description
GET /api/v1/executions List all executions
GET /api/v1/executions/order/{orderId} Find by order ID
GET /api/v1/executions/ticker/{ticker} Find by ticker
GET /api/v1/executions/status/{status} Find by status

βœ… Validating the Execution

With the application running locally, all components started correctly:

  • βœ… Flyway applied the order_executions table migration
  • βœ… Hibernate validated the schema
  • βœ… RabbitMQ connected with queues and DLQ declared
  • βœ… Tomcat running on port 8091
  • βœ… Swagger UI accessible at http://localhost:8091/swagger-ui.html

πŸš€ What's Next?

With b3-market-sync-api feeding Redis and b3-matching-engine-api ready to process orders, the entire B3 side is operational.

The next step is building the broker-order-api β€” the orchestrator on the broker side that will manage the complete order lifecycle:

  1. Receive the buy/sell intent from the user
  2. Validate the ticker via REST against broker-asset-api
  3. Save the order as PENDING
  4. Publish the event to Kafka
  5. Send the order to the Matching Engine via RabbitMQ
  6. Consume the feedback and update the final status

Once that service is ready, we'll have the first complete end-to-end flow running in the ecosystem.

Got any questions about the matching logic or RabbitMQ DLQ configuration? Drop them in the comments!


πŸ”Ž About the Series

⬅️ Previous Post: Syncing the Real Market: Consuming Brapi and Feeding Redis with Spring Boot

πŸ“˜ Series Index: Series Roadmap


Links:

Top comments (0)