Hey folks!
We've reached one of the most important posts in the My Broker B3 series. Up until now we built each piece of the ecosystem separately β the price synchronizer, the matching engine, the financial custody, the asset catalog. Now it's time to build the service that connects all of it: the Broker Order API.
This is the conductor of the ecosystem. When a user wants to buy or sell a stock, this is where everything begins β and where the results come back to.
π¦ What is the Order API?
The trading-broker-order is the entry point for the broker's transactional flow. It has a clear responsibility: orchestrate the complete lifecycle of an order, from the user's intent to the final execution confirmation.
To do this, it communicates with four other ecosystem services:
- Asset API (REST/Feign) β validates whether the ticker exists and is active
- Wallet API (REST/Feign) β validates whether the user has sufficient balance
- B3 Matching Engine (RabbitMQ) β sends the order for execution
- Broker Wallet (Kafka) β notifies the ecosystem about each status change
πΊοΈ The Complete Order Flow
Before diving into the code, here's the end-to-end flow:
[User]
β
β POST /api/v1/orders
β { userId, ticker, quantity, price, side: "BUY" }
βΌ
[trading-broker-order]
β
ββ 1. Validate ticker β GET trading-asset/assets/{ticker} (Feign/REST)
β
ββ 2. Validate balance β GET trading-wallet/summary (Feign/REST)
β
ββ 3. Persist order β MySQL (status: PENDING)
β
ββ 4. Send to B3 β RabbitMQ (mq-broker-to-b3)
β
ββ 5. Notify ecosystem β Kafka (order-events-v1, status: PENDING)
β
ββ Returns created order β HTTP 201
β β β (B3 processes the order) β β β
[b3-matching-engine]
β
β RabbitMQ (mq-b3-to-broker)
β { orderId, status: FILLED/REJECTED, executedPrice }
βΌ
[trading-broker-order]
β
ββ 6. Update status β MySQL (FILLED or REJECTED)
β
ββ 7. Notify ecosystem β Kafka (order-events-v1, final status)
β
ββ trading-broker-wallet consumes β blocks/settles/refunds balance
ββ other future consumers
Two distinct communication channels, two Kafka publish moments, and the wallet reacting to each state change. Let's see how each part was implemented.
π οΈ Tech Stack
| Technology | Usage |
|---|---|
| Java 21 + Spring Boot 3.3.5 | Service core |
| MySQL + Flyway | Order persistence |
| Spring Kafka | Lifecycle event publishing |
| Spring AMQP | Integration with Matching Engine via RabbitMQ |
| Spring Cloud OpenFeign | REST calls to Asset API and Wallet API |
| SpringDoc OpenAPI | Swagger UI documentation |
ποΈ Implementation Pillars
1. The Domain Model β Order
The Order entity represents the state of an order at any point in its lifecycle:
@Entity
@Table(name = "orders")
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String userId;
private String ticker;
@Column(nullable = false, precision = 19, scale = 4)
private BigDecimal quantity;
@Column(nullable = false, precision = 19, scale = 4)
private BigDecimal price;
// Null while PENDING β only set after B3 feedback
@Column(precision = 19, scale = 4)
private BigDecimal executedPrice;
@Enumerated(EnumType.STRING)
private OrderSide side; // BUY | SELL
@Enumerated(EnumType.STRING)
private OrderStatus status; // PENDING | FILLED | REJECTED | CANCELLED
@CreationTimestamp
private LocalDateTime createdAt;
@UpdateTimestamp
private LocalDateTime updatedAt;
}
An important design detail: executedPrice is intentionally nullable. When the order is created, we don't yet know the execution price β that only comes as a response from the Matching Engine. Forcing nullable = false here would be a domain modeling error.
The entity also carries the mapToEvent() method that serializes the current state into a Kafka event:
public OrderEventDTO mapToEvent() {
return OrderEventDTO.builder()
.orderId(this.getId())
.userId(this.getUserId())
.ticker(this.getTicker())
.quantity(this.getQuantity())
.price(this.getPrice())
.executedPrice(this.getExecutedPrice())
.side(this.getSide().name())
.status(this.getStatus().name())
.eventTimestamp(LocalDateTime.now())
.build();
}
2. Ticker Validation β Asset Feign Client
Before validating balance or persisting anything, we need to ensure the requested ticker actually exists and is available for trading. A user submitting an order for XPTO999 should get a clear error immediately β not a silent rejection from B3 two hops later.
@FeignClient(name = "trading-broker-asset", url = "${app.services.asset-url}")
public interface AssetClient {
@GetMapping("/api/v1/assets/{ticker}")
AssetDTO getByTicker(@PathVariable("ticker") String ticker);
}
The validation in OrderService:
private void validateTicker(String ticker) {
try {
var asset = assetClient.getByTicker(ticker.toUpperCase());
if (!"ACTIVE".equalsIgnoreCase(asset.status())) {
throw new RuntimeException("Asset is not available for trading: " + ticker);
}
log.info("Ticker {} validated β status: {}", ticker, asset.status());
} catch (FeignException.NotFound e) {
throw new RuntimeException("Asset not found or inactive: " + ticker);
} catch (FeignException e) {
log.error("Asset service unavailable during ticker validation: {}", e.getMessage());
throw new RuntimeException("Asset service unavailable. Please try again later.");
}
}
Why catch FeignException separately from FeignException.NotFound? A 404 from the asset service means the ticker doesn't exist β a business error that should fail fast. Any other FeignException (connection refused, timeout, 5xx) means the asset service is unavailable β a different kind of failure that warrants a different message to the user.
3. Balance Validation β Wallet Feign Client
For buy orders (BUY), we need to ensure the user has available balance. We use Spring Cloud OpenFeign to query the Wallet API declaratively:
@FeignClient(name = "trading-broker-wallet", url = "${app.services.wallet-url}")
public interface WalletClient {
@GetMapping("/api/v1/wallet/{userId}/summary")
WalletSummaryDTO getWalletSummary(@PathVariable String userId);
}
The validation happens after ticker validation:
private void validateBalance(OrderRequestDTO request) {
BigDecimal totalOrderValue = request.getPrice().multiply(request.getQuantity());
var walletSummary = walletClient.getWalletSummary(request.getUserId());
if (walletSummary.getAvailableBalance().compareTo(totalOrderValue) < 0) {
throw new RuntimeException("Insufficient balance. Available: "
+ walletSummary.getAvailableBalance()
+ " | Required: " + totalOrderValue);
}
}
Why Feign and not RestTemplate? OpenFeign eliminates HTTP boilerplate β the interface is sufficient. The URL is injected via application.yaml, making environment changes straightforward without recompiling.
4. The Creation Flow β OrderService
The placeOrder() is the heart of the service. It orchestrates five steps in sequence within a transaction:
@Transactional
public Order placeOrder(OrderRequestDTO request) {
log.info("Processing new {} order for user: {}", request.getSide(), request.getUserId());
// 1. Validate ticker exists and is ACTIVE in asset catalog
validateTicker(request.getTicker());
// 2. Validate balance for BUY orders via Wallet API
if (request.getSide() == OrderSide.BUY) {
validateBalance(request);
}
// 3. Persist order with PENDING status
Order order = Order.builder()
.userId(request.getUserId())
.ticker(request.getTicker().toUpperCase())
.quantity(request.getQuantity())
.price(request.getPrice())
.side(request.getSide())
.status(OrderStatus.PENDING)
.build();
order = orderRepository.save(order);
// 4. Send to B3 via RabbitMQ
b3MessageProducer.sendToB3(mapToB3Message(order));
// 5. Publish PENDING event to Kafka for wallet and other consumers
orderEventProducer.sendOrderEvent(order.mapToEvent());
return order;
}
Why validate ticker before balance? It's cheaper to fail fast on a non-existent ticker (one network call) than to validate balance first and then discover the ticker is invalid. Order of validations matters for UX and for minimizing unnecessary load on downstream services.
Design decision: The Kafka event is published with PENDING status right after creation. This allows trading-broker-wallet to immediately block the balance, without waiting for B3's result. This is the essence of eventual consistency β each service reacts to the event in its own time.
5. B3 Integration β RabbitMQ
Communication with the Matching Engine uses RabbitMQ for a specific reason: delivery guarantee. Even if the Matching Engine is temporarily unavailable, the message stays in the queue and is processed when it comes back.
Infrastructure configuration
All RabbitMQ infrastructure is declared as Spring beans β ensuring queues, exchanges and bindings exist before any message flows:
@Bean
public DirectExchange brokerExchange() {
return new DirectExchange(exchangeName);
}
@Bean
public Queue orderOutQueue() {
return QueueBuilder.durable(queueIn)
.withArgument("x-dead-letter-exchange", "dlx.broker.orders")
.withArgument("x-dead-letter-routing-key", "dlq-broker-to-b3")
.build();
}
@Bean
public Binding orderOutBinding() {
return BindingBuilder.bind(orderOutQueue())
.to(brokerExchange())
.with(routingKey);
}
The dead letter withArgument ensures messages that fail are redirected to the DLQ instead of simply being discarded.
Producer β sending to B3
public void sendToB3(B3OrderRequestDTO message) {
log.info("Sending order {} to B3 via exchange: {} | routing-key: {}",
message.getOrderId(), exchange, routingKey);
rabbitTemplate.convertAndSend(exchange, routingKey, message);
}
Important decision: the message is sent to the exchange with the routing key, not directly to the queue. This respects the RabbitMQ routing model and allows adding new consumers without changing the producer.
Consumer β receiving B3 feedback
@RabbitListener(queues = "${app.rabbitmq.queue-out}")
public void receiveFeedback(B3OrderResponseDTO response) {
log.info("Feedback received from B3 for order: {} | Status: {}",
response.getOrderId(), response.getStatus());
try {
orderStatusService.updateOrderStatus(response);
} catch (Exception e) {
log.error("Failed to update status for order {}: {}",
response.getOrderId(), e.getMessage(), e);
throw e; // RabbitMQ applies retry/DLQ policy
}
}
The throw e is intentional: rethrowing the exception allows RabbitMQ to apply its retry policy instead of silently discarding the message.
6. Status Update β OrderStatusService
When B3 feedback arrives, OrderStatusService updates the order state and publishes the second Kafka event:
@Transactional
public void updateOrderStatus(B3OrderResponseDTO response) {
Order order = orderRepository.findById(Long.valueOf(response.getOrderId()))
.orElseThrow(() -> new RuntimeException("Order not found: " + response.getOrderId()));
order.setStatus(OrderStatus.valueOf(response.getStatus()));
order.setExecutedPrice(response.getExecutedPrice());
orderRepository.save(order);
// Notify ecosystem with final status β wallet settles or refunds
orderEventProducer.sendOrderEvent(order.mapToEvent());
}
This second Kafka event closes the loop: trading-broker-wallet consumes it and executes the settlement (FILLED) or refund (REJECTED) of the balance blocked in the previous step.
π REST API
| Method | Endpoint | Description |
|---|---|---|
| POST | /api/v1/orders |
Place a new buy or sell order |
| GET | /api/v1/orders/{id} |
Get order details by ID |
| GET | /api/v1/orders/user/{userId} |
List all orders for a user |
π Swagger UI: http://localhost:8088/swagger-ui.html
β Validating the Execution
With the application running locally:
- β
Flyway validated the
orderstable schema - β
Tomcat started on port
8088 - β RabbitMQ connected with exchange, queues and DLQ declared
- β Swagger UI with 3 documented endpoints
- β AssetClient validating ticker before any persistence
- β WalletClient validating balance for BUY orders
π The Complete Ecosystem
With trading-broker-order ready, we have the first end-to-end flow working:
[User] β [Order API] β [Asset API] (ticker validation)
β [Wallet API] (balance validation)
β [B3 Matching Engine] (execution)
β [Kafka] β [Wallet API] (custody)
From the user's click to the updated wallet balance, through ticker and balance validation, RabbitMQ routing, price matching against Redis, and eventual consistency via Kafka.
Got any questions about the design decisions or the inter-service orchestration? Drop them in the comments!
π About the Series
β¬ οΈ Previous Post: Financial Custody: Managing Balance and Portfolio with Eventual Consistency
π Series Index: Series Roadmap
Links:
Top comments (0)