Olá, pessoal!
Chegamos em um dos posts mais importantes da série My Broker B3. Até aqui construímos cada peça do ecossistema separadamente — o sincronizador de preços, o matching engine, a custódia financeira, o catálogo de ativos. Agora é hora de construir o serviço que conecta tudo isso: o Broker Order API.
Este é o maestro do ecossistema. Quando um usuário quer comprar ou vender uma ação, é aqui que tudo começa — e é aqui que os resultados chegam de volta.
🏦 O que é a Order API?
A trading-broker-order é o ponto de entrada para o fluxo transacional da corretora. Ela tem uma responsabilidade clara: orquestrar o ciclo de vida completo de uma ordem, desde a intenção do usuário até a confirmação final da execução.
Para isso, ela se comunica com quatro outros serviços do ecossistema:
- Asset API (REST/Feign) — valida se o ticker existe e está ativo
- Wallet API (REST/Feign) — valida se o usuário tem saldo suficiente
- B3 Matching Engine (RabbitMQ) — envia a ordem para execução
- Broker Wallet (Kafka) — notifica o ecossistema sobre cada mudança de status
🗺️ O Fluxo Completo de uma Ordem
Antes de mergulhar no código, veja o fluxo ponta a ponta:
[Usuário]
│
│ POST /api/v1/orders
│ { userId, ticker, quantity, price, side: "BUY" }
▼
[trading-broker-order]
│
├─ 1. Valida ticker → GET trading-asset/assets/{ticker} (Feign/REST)
│
├─ 2. Valida saldo → GET trading-wallet/summary (Feign/REST)
│
├─ 3. Persiste ordem → MySQL (status: PENDING)
│
├─ 4. Envia para B3 → RabbitMQ (mq-broker-to-b3)
│
├─ 5. Notifica ecossistema → Kafka (order-events-v1, status: PENDING)
│
└─ Retorna ordem criada → HTTP 201
─ ─ ─ (B3 processa a ordem) ─ ─ ─
[b3-matching-engine]
│
│ RabbitMQ (mq-b3-to-broker)
│ { orderId, status: FILLED/REJECTED, executedPrice }
▼
[trading-broker-order]
│
├─ 6. Atualiza status → MySQL (FILLED ou REJECTED)
│
└─ 7. Notifica ecossistema → Kafka (order-events-v1, status final)
│
├─ trading-broker-wallet consome → bloqueia/liquida/estorna saldo
└─ outros consumers futuros
Dois canais de comunicação distintos, dois momentos de publicação Kafka, e a wallet reagindo a cada mudança de estado. Vamos ver como cada parte foi implementada.
🛠️ Stack Tecnológica
| Tecnologia | Uso |
|---|---|
| Java 21 + Spring Boot 3.3.5 | Core do serviço |
| MySQL + Flyway | Persistência de ordens |
| Spring Kafka | Publicação de eventos de ciclo de vida |
| Spring AMQP | Integração com o Matching Engine via RabbitMQ |
| Spring Cloud OpenFeign | Chamadas REST à Asset API e Wallet API |
| SpringDoc OpenAPI | Documentação via Swagger UI |
🏗️ Os Pilares da Implementação
1. O Modelo de Domínio — Order
A entidade Order representa o estado de uma ordem em qualquer ponto do ciclo de vida:
@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 enquanto PENDING — preenchido apenas após feedback da B3
@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;
}
Um detalhe de design importante: executedPrice é nullable intencionalmente. Quando a ordem é criada, ainda não sabemos o preço de execução — isso só vem como resposta do Matching Engine. Forçar nullable = false aqui seria um erro de modelo de domínio.
A entidade também carrega o método mapToEvent() que serializa o estado atual para o evento Kafka:
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. Validação de Ticker — Asset Feign Client
Antes de validar saldo ou persistir qualquer coisa, precisamos garantir que o ticker solicitado existe e está disponível para negociação. Um usuário submetendo uma ordem para XPTO999 deve receber um erro claro imediatamente — não uma rejeição silenciosa da B3 duas chamadas depois.
@FeignClient(name = "trading-broker-asset", url = "${app.services.asset-url}")
public interface AssetClient {
@GetMapping("/api/v1/assets/{ticker}")
AssetDTO getByTicker(@PathVariable("ticker") String ticker);
}
A validação no 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.");
}
}
Por que capturar FeignException.NotFound separadamente? Um 404 da asset service significa que o ticker não existe — erro de negócio que deve falhar rápido. Qualquer outro FeignException (conexão recusada, timeout, 5xx) significa que o serviço está indisponível — um tipo diferente de falha que merece uma mensagem diferente para o usuário.
3. Validação de Saldo — Wallet Feign Client
Para ordens de compra (BUY), precisamos garantir que o usuário tem saldo disponível. Usamos o Spring Cloud OpenFeign para consultar a Wallet API de forma declarativa:
@FeignClient(name = "trading-broker-wallet", url = "${app.services.wallet-url}")
public interface WalletClient {
@GetMapping("/api/v1/wallet/{userId}/summary")
WalletSummaryDTO getWalletSummary(@PathVariable String userId);
}
A validação acontece após a validação do ticker:
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);
}
}
Por que Feign e não RestTemplate? O OpenFeign elimina o boilerplate de HTTP — a interface é suficiente. A URL é injetada via application.yaml, facilitando a mudança de ambiente sem recompilar.
4. O Fluxo de Criação — OrderService
O placeOrder() é o coração do serviço. Ele orquestra cinco etapas em sequência dentro de uma transação:
@Transactional
public Order placeOrder(OrderRequestDTO request) {
log.info("Processing new {} order for user: {}", request.getSide(), request.getUserId());
// 1. Valida ticker existe e está ACTIVE no catálogo de ativos
validateTicker(request.getTicker());
// 2. Valida saldo para ordens BUY via Wallet API
if (request.getSide() == OrderSide.BUY) {
validateBalance(request);
}
// 3. Persiste ordem com status PENDING
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. Envia para a B3 via RabbitMQ
b3MessageProducer.sendToB3(mapToB3Message(order));
// 5. Publica evento PENDING no Kafka para a wallet e outros consumers
orderEventProducer.sendOrderEvent(order.mapToEvent());
return order;
}
Por que validar o ticker antes do saldo? É mais barato falhar rápido num ticker inexistente (uma chamada de rede) do que validar saldo primeiro e só então descobrir que o ticker é inválido. A ordem das validações importa para a UX e para minimizar carga desnecessária nos serviços downstream.
Decisão de design: O evento Kafka é publicado com status PENDING logo após a criação. Isso permite que a trading-broker-wallet já bloqueie o saldo imediatamente, sem esperar o resultado da B3. Essa é a essência da consistência eventual — cada serviço reage ao evento no seu próprio tempo.
5. A Integração com a B3 — RabbitMQ
A comunicação com o Matching Engine usa RabbitMQ por uma razão específica: garantia de entrega. Mesmo que o Matching Engine esteja temporariamente indisponível, a mensagem fica na fila e é processada quando ele voltar.
Configuração de infraestrutura
Toda a infraestrutura RabbitMQ é declarada como beans Spring — garantindo que filas, exchanges e bindings existam antes de qualquer mensagem trafegar:
@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);
}
O withArgument de dead letter garante que mensagens que falham são redirecionadas para a DLQ em vez de simplesmente descartadas.
Producer — enviando para a 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);
}
Decisão importante: a mensagem é enviada para o exchange com a routing key, não diretamente para a fila. Isso respeita o modelo de roteamento do RabbitMQ e permite adicionar novos consumers sem alterar o producer.
Consumer — recebendo o feedback da B3
@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
}
}
O throw e é intencional: relançar a exceção permite que o RabbitMQ aplique sua política de retry em vez de descartar a mensagem silenciosamente.
6. Atualização de Status — OrderStatusService
Quando o feedback da B3 chega, o OrderStatusService atualiza o estado da ordem e publica o segundo evento Kafka:
@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);
// Notifica ecossistema com status final — wallet liquida ou estorna
orderEventProducer.sendOrderEvent(order.mapToEvent());
}
Este segundo evento Kafka fecha o loop: a trading-broker-wallet o consome e executa a liquidação (FILLED) ou estorno (REJECTED) do saldo bloqueado na etapa anterior.
🌐 API REST
| Método | Endpoint | Descrição |
|---|---|---|
| POST | /api/v1/orders |
Submeter nova ordem de compra ou venda |
| GET | /api/v1/orders/{id} |
Consultar detalhes de uma ordem por ID |
| GET | /api/v1/orders/user/{userId} |
Listar todas as ordens de um usuário |
📄 Swagger UI: http://localhost:8088/swagger-ui.html
✅ Validando a Execução
Com a aplicação rodando localmente:
- ✅ Flyway validou o schema da tabela
orders - ✅ Tomcat subiu na porta
8088 - ✅ RabbitMQ conectado com exchange, filas e DLQ declarados
- ✅ Swagger UI com 3 endpoints documentados
- ✅ AssetClient validando ticker antes de qualquer persistência
- ✅ WalletClient validando saldo para ordens BUY
🔗 O Ecossistema Completo
Com a trading-broker-order pronta, temos o primeiro fluxo ponta a ponta funcionando:
[Usuário] → [Order API] → [Asset API] (validação de ticker)
→ [Wallet API] (validação de saldo)
→ [B3 Matching Engine] (execução)
→ [Kafka] → [Wallet API] (custódia)
Do clique do usuário até o saldo atualizado na carteira, passando por validação de ticker e saldo, roteamento RabbitMQ, matching de preço no Redis e consistência eventual via Kafka.
Ficou com alguma dúvida sobre as decisões de design ou sobre a orquestração entre os serviços? Deixe nos comentários!
🔎 Sobre a série
⬅️ Post Anterior: Custódia Financeira: Gerenciando Saldo e Carteira com Consistência Eventual
📘 Índice da Série: Guia da Série
Links:
Top comments (0)