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:
-
Intake: Receives the order from the broker via RabbitMQ (queue
mq-broker-to-b3) -
Price: Fetches the current asset price from Redis (
market:price:{TICKER}) β data injected by the Market Sync we built previously - Decision: Applies the matching rule
- Persistence: Records the result in PostgreSQL
-
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]
π― 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);
}
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;
}
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;
}
π‘ This is exactly why we built
b3-market-sync-apifirst. 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);
}
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);
}
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
}
}
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..."));
}
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_executionstable 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:
- Receive the buy/sell intent from the user
- Validate the ticker via REST against
broker-asset-api - Save the order as
PENDING - Publish the event to Kafka
- Send the order to the Matching Engine via RabbitMQ
- 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)