All writing

WRITING FIX & gRPC

From FIX to gRPC: Building a Trading Order Gateway with Spring Boot and Protocol Buffers

Building a FIX-to-gRPC order gateway with Codex, Spring Boot, and Protocol Buffers—with real Java code, diagrams, and honest answers to timeouts.

On the Pont de l’Europe by Gustave Caillebotte: three men beside the blue-gray iron girders of a bridge overlooking railway tracks.
Gustave Caillebotte On the Pont de l’Europe, 1876–77Kimbell Art Museum Public domain

1. Introduction: Why FIX Meets gRPC

“Receive an order, call a service, send the answer back.”

Sounds like a quiet afternoon. Then the network joins the meeting.

What happens if the service accepts the order, but its reply arrives too late? Do we reject it? Send it again? Make another coffee?

I built this application with OpenAI Codex to explore those questions through a working Java project. This article follows the code: a FIX client, a Spring Boot gateway, and a gRPC order management service.

We'll move one order through the system, then look at what happens when things go wrong. Expect diagrams, real code, and a few carefully placed “we don't know yet” answers.

This is a learning project. It accepts or rejects demo orders; it doesn't execute trades.

2. Understanding FIX and gRPC

First, three names we'll use throughout:

NameIts job hereThink of it as…
FIXCarries financial messages between the client and gatewayThe trading conversation
Protocol BuffersDefines the request and response fields and their binary encodingThe shared vocabulary
gRPCCalls the OMS using that contractThe service-to-service conversation

FIX and gRPC serve different boundaries. We keep FIX for the trading client and use gRPC behind the gateway.

In FIX 4.4, NewOrderSingle means “here's an order.” ExecutionReport communicates an order event, including acceptance or rejection. QuickFIX/J handles Logon, heartbeats, sequence numbers, and protocol resends.

An accepted order is not a completed trade. Our NEW report means the demo OMS accepted it. No shares change hands here.

The internal call uses HTTP/2 to carry Protobuf messages. HTTP/2 supports multiple streams over a connection; Protobuf defines how our message values become bytes.

The generators turn a .proto contract into Java messages, client stubs, and a server base class. Our method is unary: one request, one response. No streaming API to learn today.

For the underlying concepts, see gRPC's introduction and the Protobuf overview.

3. Our Trading Gateway Architecture

Here's the whole trip:

Architecture: FIX simulator sends orders to the gateway; a bounded worker calls OMS over gRPC; decisions return as FIX ExecutionReports.

The contract module supplies generated Java types to the gateway and OMS. Editable Mermaid source.

ModuleMain responsibility
fix-client-simulatorConsole commands and a QuickFIX/J initiator
fix-gatewayFIX acceptor, mapping, worker queue, gRPC client, reports
order-management-serviceValidation, demo decisions, in-memory order records
proto-contractsShared .proto contract and generated Java code

The build uses Java 17, Spring Boot 4.1.1, QuickFIX/J 3.0.2, Protobuf 4.35.1, and gRPC Java 1.83.1. Gradle 8.14.3 builds the modules; the Protobuf plugin is 0.9.6. These are the repository's versions, not a shopping list of latest releases.

Let's follow a BUY order for 100 units of THYAO at a limit price of 250.50.

4. Designing the Protobuf Contract

Before connecting services, we need an agreement about the messages. Here's the real contract:

Source: order.proto, lines 1–42 (verbatim excerpt).

Code
syntax = "proto3";
 
package trading.v1;
 
option java_package = "com.example.trading.contracts.v1";
option java_multiple_files = true;
option java_outer_classname = "OrderContract";
 
service OrderService {
  rpc SubmitOrder(SubmitOrderRequest) returns (SubmitOrderResponse);
}
 
message SubmitOrderRequest {
  string client_order_id = 1;
  string symbol = 2;
  Side side = 3;
  // Decimal text avoids binary floating-point rounding for trading values.
  string quantity = 4;
  string limit_price = 5;
  string sender_comp_id = 6;
  string target_comp_id = 7;
  // ISO-8601 local date (yyyy-MM-dd), supplied explicitly by the caller.
  string trading_date = 8;
}
 
enum Side {
  SIDE_UNSPECIFIED = 0;
  SIDE_BUY = 1;
  SIDE_SELL = 2;
}
 
message SubmitOrderResponse {
  string order_id = 1;
  OrderDecision decision = 2;
  string reject_reason = 3;
}
 
enum OrderDecision {
  ORDER_DECISION_UNSPECIFIED = 0;
  ACCEPTED = 1;
  REJECTED = 2;
}

The service line is the API: send a SubmitOrderRequest, receive a SubmitOrderResponse.

Side describes BUY or SELL. OrderDecision describes ACCEPTED or REJECTED. Zero means unspecified, so a missing decision never quietly becomes “yes.”

Those = 1, = 2 assignments are wire field numbers. Keep existing numbers and compatible types stable. If you remove a field, reserve its number and name instead of recycling them. The proto3 guide explains the rules.

One small trap: a message can remain readable while becoming invalid for the application. Older requests without the added session/date fields still decode, but today's validator rejects their missing values. Deserialization success isn't a complete migration plan.

Why is the price a string?

Because we'd like 250.50 to arrive without a floating-point souvenir.

The gateway reads price as BigDecimal, sends plain decimal text, and OMS parses it as BigDecimal. Quantity is also text: OMS requires positive integer digits and parses them as BigInteger.

The FIX path additionally requires quantity to fit an exact positive Java long. Currency and tick-size rules aren't implemented.

What Java do we get?

SubmitOrderRequest and SubmitOrderResponse provide builders and accessors. Side and OrderDecision become enums. OrderContract holds descriptors; OrderServiceGrpc provides the client stubs and server base class.

The build generates them under proto-contracts/build/generated/sources/proto/main/. We edit the contract, not those generated files.

5. Implementing the gRPC Server

The OMS adapter extends OrderServiceGrpc.OrderServiceImplBase and uses @GrpcService. Its job is short: delegate, translate errors, return a response.

Source: OrderGrpcService.java, lines 25–47 (verbatim excerpt).

Code
@Override
public void submitOrder(SubmitOrderRequest request, StreamObserver<SubmitOrderResponse> observer) {
    SubmitOrderResponse response;
    try {
        response = orders.submitOrder(request);
    }
    catch (InvalidOrderException ex) {
        observer.onError(Status.INVALID_ARGUMENT.withDescription(ex.getMessage()).asRuntimeException());
        return;
    }
    catch (Exception ex) {
        log.error("Unexpected SubmitOrder failure", ex);
        observer.onError(Status.INTERNAL.withDescription("Order processing failed").asRuntimeException());
        return;
    }
    // Keep observer callbacks outside the business exception handler so a transport
    // failure cannot cause a second terminal callback after successful completion.
    log.info("OMS decision: senderCompId={} ClOrdID={} tradingDate={} orderId={} Decision={} rejectReason={}",
            request.getSenderCompId(), request.getClientOrderId(), request.getTradingDate(),
            response.getOrderId(), response.getDecision(), response.getRejectReason());
    observer.onNext(response);
    observer.onCompleted();
}

Three paths matter:

SituationRPC result
Valid order, acceptedSuccessful response with ACCEPTED
Valid request, rejected by a business ruleSuccessful response with REJECTED
Invalid input or unexpected server failureINVALID_ARGUMENT or sanitized INTERNAL error

Yes, a rejection can be a successful RPC. The service successfully answered the question. The answer was “no.”

OrderApplicationService validates the request, then asks OrderRepository.processOnce to store one decision per order identity. A new stored decision gets a UUID, including a business rejection.

The demo allows THYAO, ASELS, and TUPRS, with a maximum quantity of 10,000. These are configurable demo rules, not exchange requirements.

onNext delivers the unary response; onCompleted finishes it. onError terminates a failed RPC. Response callbacks stay outside the business exception handler to avoid attempting a second terminal callback if transport delivery fails.

6. Connecting the FIX Gateway

Now the order reaches QuickFIX/J's MessageCracker. It routes NewOrderSingle into this handler:

Source: GatewayOrderMessageHandler.java, lines 27–42 (verbatim excerpt).

Code
public void onMessage(NewOrderSingle message, SessionID sessionId) throws FieldNotFound {
    IncomingOrder order;
    try {
        order = mapper.map(message);
    } catch (OrderValidationException invalidOrder) {
        log.warn("FIX order validation failed: SessionID={} ClOrdID={} reason={}", sessionId,
                message.getOptionalString(ClOrdID.FIELD).orElse("<missing>"), invalidOrder.getMessage());
        // Invalid requests remain explicit errors, not fabricated OMS business decisions.
        return;
    }
    onOrder(order, sessionId);
}
 
protected void onOrder(IncomingOrder order, SessionID sessionId) {
    orders.submit(order, sessionId); // Processor records queue rejection explicitly.
}

GatewayOrderMessageHandler extends MessageCracker; GatewayFixApplication.fromApp invokes its crack method.

The handler maps locally and attempts queue admission. It doesn't wait for OMS. A worker handles the blocking call, keeping that wait off the FIX callback thread.

First: FIX fields become an order

Source: FixOrderMapper.java, lines 22–29 (verbatim excerpt).

Code
String clientOrderId = message.getString(ClOrdID.FIELD);
String senderCompId = message.getHeader().getString(SenderCompID.FIELD);
String targetCompId = message.getHeader().getString(TargetCompID.FIELD);
String symbol = message.getString(Symbol.FIELD);
char fixSide = message.getChar(Side.FIELD);
char orderType = message.getChar(OrdType.FIELD);
BigDecimal quantity = message.getDecimal(OrderQty.FIELD);
LocalDateTime transactTime = message.getUtcTimeStamp(TransactTime.FIELD);

These lines come from FixOrderMapper.map. FIX's numeric tags have named constants, so we don't have to memorize the entire protocol before lunch.

The rest of the method validates BUY/SELL, limit orders, nonblank identifiers, positive price, and an exact positive integer quantity. It returns an immutable IncomingOrder.

Missing fields propagate as FieldNotFound for engine protocol handling. Locally unsupported business inputs are logged and stopped; they don't produce a made-up OMS rejection.

Next: the order becomes Protobuf

Source: FixToProtobufMapper.java, lines 9–26 (verbatim excerpt).

Code
public SubmitOrderRequest map(IncomingOrder order) {
    return SubmitOrderRequest.newBuilder()
            .setClientOrderId(order.clientOrderId())
            .setSenderCompId(order.senderCompId())
            .setTargetCompId(order.targetCompId())
            .setSymbol(order.symbol())
            .setSide(switch (order.side()) {
                case BUY -> Side.SIDE_BUY;
                case SELL -> Side.SIDE_SELL;
            })
            .setQuantity(Long.toString(order.quantity()))
            .setLimitPrice(order.price().toPlainString())
            // QuickFIX/J represents FIX UTC timestamps as LocalDateTime.
            // Use the original timestamp, never the clock or the JVM's default zone.
            // Production exchange calendars may use a different trading date.
            .setTradingDate(order.transactTime().toLocalDate().toString())
            .build();
}

Notice toPlainString() for price and the date taken from the original FIX UTC timestamp.

Using today's date could change a repeated order's identity after midnight. Midnight causes enough bugs without an enthusiastic mapper helping out. A real exchange calendar would require additional rules.

Finally: call OMS—with a deadline

OrderClientConfiguration imports the generated blocking stub through @ImportGrpcClients, targeting the oms channel. Boot manages that channel.

Source: GrpcOrderClient.java, lines 29–55 (verbatim excerpt).

Code
public OrderSubmissionResult submit(IncomingOrder order) {
    try {
        // Derived immutable stubs share the Spring-managed channel. No application retries.
        SubmitOrderResponse response = stub.withDeadlineAfter(deadlineNanos, TimeUnit.NANOSECONDS)
                .submitOrder(mapper.map(order));
        // ACCEPTED without an OMS identity is not a valid business decision.
        // Treat the malformed response as uncertain rather than failing later
        // during report creation or inventing an OMS identifier.
        if (response.getDecision() == com.example.trading.contracts.v1.OrderDecision.ACCEPTED
                && response.getOrderId().isBlank()) {
            return new OrderSubmissionResult(Outcome.UNKNOWN_OUTCOME, "", "", Status.Code.DATA_LOSS);
        }
        return switch (response.getDecision()) {
            case ACCEPTED -> new OrderSubmissionResult(Outcome.ACCEPTED,
                    response.getOrderId(), "", Status.Code.OK);
            case REJECTED -> new OrderSubmissionResult(Outcome.REJECTED,
                    response.getOrderId(), response.getRejectReason(), Status.Code.OK);
            // An absent/future decision is not evidence of rejection.
            default -> new OrderSubmissionResult(Outcome.UNKNOWN_OUTCOME,
                    response.getOrderId(), "", Status.Code.UNKNOWN);
        };
    } catch (StatusRuntimeException failure) {
        Status.Code status = failure.getStatus().getCode();
        return new OrderSubmissionResult(status == Status.Code.INVALID_ARGUMENT
                ? Outcome.INVALID_REQUEST : Outcome.UNKNOWN_OUTCOME, "", "", status);
    }
}

A worker calls this method. Defaults are two workers, 100 queued tasks, and a two-second RPC deadline.

The deadline starts with the RPC; queue waiting is additional. If the queue fills, admission logs NOT_SUBMITTED. AbortPolicy prevents the callback thread from taking over the blocking work.

The code keeps business rejection separate from uncertain results. No application retry is added.

7. Returning FIX Execution Reports

Let's put the round trip together:

Sequence: NewOrderSingle reaches the gateway, becomes a Protobuf request, calls OMS with SubmitOrder, and returns a definitive decision as ExecutionReport.

This is the successful decision path. Transport uncertainty produces no business report. Mermaid source with failure branches.

The report factory creates the FIX fields:

Source: ExecutionReportFactory.java, lines 37–46 (verbatim excerpt).

Code
ExecutionReport report = new ExecutionReport();
// NONE means OMS did not assign an identifier. It is never a fabricated OMS ID.
report.set(new OrderID(result.orderId().isBlank() ? "NONE" : result.orderId()));
report.set(new ExecID(UUID.randomUUID().toString()));
report.set(new ExecType(accepted ? ExecType.NEW : ExecType.REJECTED));
report.set(new OrdStatus(accepted ? OrdStatus.NEW : OrdStatus.REJECTED));
report.set(new ClOrdID(order.clientOrderId()));
report.set(new Symbol(order.symbol()));
report.set(new Side(order.side() == OrderSide.BUY ? Side.BUY : Side.SELL));
report.set(new OrdType(OrdType.LIMIT));

Three IDs, three jobs:

FieldMeaning
ClOrdIDThe client's submission identifier
OrderIDThe OMS order identifier
ExecIDThis report event's identifier

ExecType describes the event; OrdStatus describes the order's resulting state. Here both are 0 for NEW or 8 for REJECTED. The FIX order-state reference explains that distinction.

For acceptance, LeavesQty equals the submitted quantity. For rejection, it's zero. CumQty and AvgPx stay zero because we don't produce fills. Decimal fields use QuickFIX/J's setDecimal API.

The actual OMS assigns IDs to stored rejections too. NONE is only a fallback for a rejection without an assigned ID. Acceptance without an OMS ID is treated as uncertain.

The delivery layer retains the original SessionID and submits a clone of the report to that session. A successful local send means the engine accepted it; it doesn't prove remote consumption.

The simulator correlates reports by session and ClOrdID. Repeated ExecIDs are displayed as replays rather than new events.

8. Handling Failures and Duplicate Orders

Here's the case that made our quiet afternoon interesting:

StepWhat happens
1OMS stores an acceptance
2Its response is delayed
3The gateway's deadline expires
4Gateway records UNKNOWN_OUTCOME—the order may already exist

A deadline stops waiting. It doesn't undo the order. The gRPC deadline guide describes the cancellation boundary.

The gateway sends neither NEW nor REJECTED when it lacks a definitive decision. Unavailable transport, cancellation, internal errors, and unusable responses also remain uncertain. INVALID_ARGUMENT becomes INVALID_REQUEST, without an ExecutionReport.

“I'll just send it again” needs a plan

OMS uses (senderCompId, clientOrderId, tradingDate) as its identity. Equal normalized attributes return the stored response. A conflicting reuse returns DUPLICATE_CL_ORD_ID, preserving the original OrderID and decision.

The gateway tracks the whole normalized request. Identical repeats—including after an unknown outcome—start no new RPC and produce no new report. A changed payload can reach OMS and receive a conflict rejection.

This is duplicate suppression, not a “show me the old answer” endpoint.

There are no application retries. gRPC may perform transparent retries before server application processing; that's a separate library behavior.

What survives a restart?

StateSurvives process restart?
QuickFIX/J persisted session messages and sequencesYes, through configured FileStores
OMS decisions and business identity historyNo
Gateway queue, request tracking, and report ledgerNo
Simulator correlation historyNo

Native FIX resend preserves an already stored report's ExecID. Engine-managed sequence numbers describe protocol transmission, not business order identity.

A report never handed to the engine can't be recovered by protocol resend. The gateway retains pending delivery records in memory, but reconnect doesn't automatically drain them.

The tracking maps also have no eviction. Durable storage, retention, an outbound outbox, and reconciliation are future work. No exactly-once delivery guarantee is claimed.

Existing Phase 7 reports record 159 passing tests, including real FIX/gRPC flows, stored acceptance before deadline, duplicates, queue saturation, reconnect, and native resend. We reused that evidence for this article; these aren't new test runs or performance benchmarks. Details are in the evidence document.

9. FIX vs REST vs gRPC

No protocol trophy ceremony needed. Each has a useful place:

TechnologyA useful fit
FIXTrading counterparties and standard financial messages
REST over HTTP, often JSONResource APIs and broadly accessible integrations
gRPC with ProtobufInternal service calls with generated contracts and clients

This project implements FIX and gRPC. It has no REST endpoint or comparative benchmark.

HTTP/2 and binary serialization don't prove gRPC is always faster. Workload, payloads, connection reuse, and infrastructure matter. Either internal API style still needs an answer for duplicates and uncertain outcomes.

10. Conclusion

We followed one order from FIX to a generated Java request, through a gRPC call, and back as an ExecutionReport.

Building it with Codex gave me a concrete way to explore the integration. The interesting parts were the boundaries: decimal precision, worker threads, order identity, and what to say when the response never arrives.

Sometimes the correct answer is UNKNOWN_OUTCOME. Less satisfying than a green checkmark, considerably more honest.

Try the GitHub project. The README has build commands and the startup order: OMS → gateway → simulator.

Send an order, inspect the report, then read the deadline test. That's where a small demo starts asking the big backend questions.

Published on tamerardal.com.tr. Built with Codex; code examples come from the project implementation.