DEV Community

Roberto de Vargas Neto
Roberto de Vargas Neto

Posted on

O Maestro do Ecossistema: Orquestrando Ordens de Mercado com Kafka, RabbitMQ e Spring Boot

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
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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.");
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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());
}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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)