Enterprise Trading Platform · architecture diagrams

Kafka event backbone

Apache Kafka, 9092 from the host and 29092 inside the compose network. Three topics, three dead-letter topics, and one message envelope that every service understands.

Compose starts the broker process and stops there. Everything above that line is the team's work: creating the topics with the contracted names, partition counts, replication factor and retention, writing down why those numbers are right, and configuring every producer and consumer. Auto-creation is switched off, so a team that has not created its topics gets an error rather than a one-partition topic with default retention that silently behaves almost correctly.

The catalogue in docs/contracts/kafka-topics.md is binding. Renaming a topic or changing a field breaks every consumer in the platform, including ones another team wrote.

The three topics, their producers and their consumers Three topic lanes. The orders topic is produced by the Trade REST API and consumed only by the Trade Executor. The trade-events topic is produced by the Trade Executor and consumed by four extension modules and the analytics service. The market-data topic is produced by the scheduled poller in the Trade Executor and consumed by four extension modules. Partition counts are drawn to scale, and the shared message envelope is shown below. PRODUCERS TOPIC CONSUMERS orders a work queue key accountId as a string · 3 partitions · replication 1 locally, 3 in a real cluster retention 7 days · cleanup delete · eventType ORDER_PLACED · source trade-api p0 p1 p2 One consumer group only. A second group means the same order filled twice. Trade REST API messaging.KafkaEventPublisher publishes after the database commit Trade Executor group trade-executor three partitions, so at most three useful instances trade-events the platform's event log key accountId as a string · 3 partitions · replication 1 locally, 3 in a real cluster retention 30 days · cleanup delete · ORDER_FILLED, ORDER_REJECTED, ORDER_CANCELLED p0 p1 p2 Carries cashDelta and positionQuantityAfter, so a consumer can hold its own view. Trade Executor messaging.TradeEventPublisher one message per outcome, fills and rejects Trade REST API, extension modules portfolio-service notification-service advice-service strategy-service Analytics service analytics-loader, optional market-data high volume, low value per message key symbol · 6 partitions · replication 1 locally, 3 in a real cluster retention 1 day · cleanup delete · eventType QUOTE · source market-poller p0 p1 p2 p3 p4 p5 One message per symbol, never one per batch. Batching is a quota decision only. Trade Executor marketdata.MarketDataPoller, scheduled one message per symbol, keyed by symbol Trade REST API, extension modules portfolio-service watchlist-service advice-service strategy-service The frontend never consumes a topic. DEAD LETTER orders.DLT · trade-events.DLT · market-data.DLT The original message as the value, the failure reason in a header. A malformed message goes here on the first attempt, because it will never succeed. A transient failure, for example a 503 from Fauxnance, is retried with backoff and dead-lettered only when the retry budget is spent. MESSAGE ENVELOPE, IDENTICAL ON ALL THREE TOPICS eventId · UUID eventType · enum eventTime · RFC 3339 source · string schemaVersion · int payload · object eventId is unique per message and is the idempotency key for consumers. eventTime is when the producer created the event, not when it was consumed. source names the producing component rather than the container, so quotes carry market-poller even though the poller now runs inside the Trade Executor.
Partition counts are drawn to scale. Serialisation is JSON with UTF-8 encoding, and consumers ignore fields they do not recognise.

Why these keys and these partition counts

The key decides the partition, and the partition decides the ordering guarantee. Kafka orders messages within a partition and gives no ordering across partitions.

orders and trade-events are keyed by accountId because the ordering that matters is per account. Two orders on the same account must execute in the order they were accepted, or a sell can be processed before the buy that made it possible. Two orders on different accounts have no relationship and can run in parallel.

market-data is keyed by symbol because the ordering that matters is per instrument. A consumer must never see an older quote for AAPL after a newer one. Never key by order identifier: every message lands on its own partition and per-account ordering is gone.

Three partitions lets three executor instances run in one group, which is enough to demonstrate a rebalance and enough to show that a group cannot usefully exceed the partition count. Six on market-data reflects its higher message rate. Partitions can be increased later but never decreased, and increasing them rehashes keys, so an account's history splits across partitions from that point. Pick the number in Sprint 7 and record why.

Producer and consumer matrix

Componentorderstrade-eventsmarket-data
Trade REST APIproduceconsume, to update read modelsnot used at the core
Trade Executorconsume, group trade-executorproduceproduce, from the scheduled poller inside it
Analytics servicenot usedconsume, optional, group analytics-loadernot used
Portfolio and P&L modulenot usedconsume, group portfolio-serviceconsume, group portfolio-service
Watchlists and price alerts modulenot usednot usedconsume, group watchlist-service
Customer notifications modulenot usedconsume, group notification-servicenot used
Customer preferences modulenot usednot usednot used
Trade advice and signals modulenot usedconsume, group advice-serviceconsume, group advice-service
Automated strategy execution modulenot usedconsume, group strategy-serviceconsume, group strategy-service
Frontendnevernevernever

Group ids stay distinct per extension module even though the modules run in one process. A group id names a logical consumer, and separate ids keep each module's offsets independent: a redeployed notifications consumer must not move the watchlist consumer's position in the stream. Two consumers sharing a group id split the partitions between them, and each sees only part of the stream, which presents as messages going missing at random.

Delivery semantics

The platform runs at-least-once. Producers retry, consumers commit offsets after processing, and duplicates therefore happen. Plan for them; do not try to eliminate them.

SettingValueWhy
acksallWait for the in-sync replicas. A local single-broker cluster makes this cheap and a real cluster makes it necessary.
enable.idempotencetrueRemoves duplicates caused by a producer retry. It does not remove duplicates caused by an application retrying after a crash.
max.in.flight.requests.per.connection5 or fewerKeeps ordering inside a partition while a retry is outstanding.
enable.auto.commitfalseCommit after processing. Committing first loses the message on a crash; committing after reprocesses it, and reprocessing is survivable when the handler is idempotent.
group.idexplicit, per logical consumerA default or a shared group id is the most common cause of "messages disappearing".
auto.offset.resetearliestA new consumer group reads the retained history rather than only what arrives after it starts.

Every consumer with a side effect must survive seeing the same eventId twice. Two mechanisms are acceptable: a processed-events table keyed on eventId and written inside the same transaction as the side effect, or a guarded state transition. The Trade Executor uses the second, and demonstrating it is the Sprint 7 acceptance check.

Kafka transactions can give exactly-once between topics. This platform does not use them, because the side effects here are database writes rather than topic writes, and the guarded transition gives the same outcome with less machinery. Be able to explain that choice.

What the team owns on the broker

Local development runs plaintext with no authentication, because TLS, SASL and ACLs on a single-node broker teach configuration rather than architecture. Document what you would configure in production: TLS between clients and brokers, SASL for client authentication, and per-topic ACLs so that only the Trade REST API can write to orders and only the Trade Executor can read it. Document it as a plan, and do not claim it is implemented.

Never put a credential, a full name, an email address or an API key in a message payload. Topics are retained for days and read by services that have no need for that data.