Building an Art Auction System in Rust
1. Introduction
In 2025, after years of building backends in various languages, I made a decision: write my startup's entire auction system in Rust. It wasn't a trend — it was a necessity.
Why Rust for an auction system?
A real-time auction system has very specific requirements:
- Tens of thousands of concurrent WebSocket connections during live auction events.
- Sub-50ms bid latency — nobody wants to lose a piece of art due to network lag.
- Guaranteed correctness in the bidding engine: you cannot outbid yourself, increments must be valid, proxy bidding must be mathematically exact.
- Safe concurrency — no data races, no forgotten locks, no memory leaks.
Rust with Tokio gave us all of that: memory safety without a GC, zero-cost abstractions for modeling complex domains with enums, and an async runtime that scales linearly.
The domain problem
Traditional art auctions are physical events: a room, a gavel, a curator certifying each piece's authenticity. Digitizing this process requires modeling:
- Inventory: artwork with a lifecycle (creation → curatorship → authentication → auction → sale).
- Curatorship: experts who verify authenticity and issue certificates.
- Auctions: events with start/end dates, multiple lots, reserve prices.
- Bids: a real-time engine with increments, proxy bidding, and outbid detection.
- Public catalog: an optimized view for thousands of concurrent users.
- Semantic search: finding artwork by description in multiple languages.
- Live notifications: bids, outbids, auction starts and ends.
Tech stack at a glance
| Category | Technology |
|---|---|
| Language | Rust (edition 2024) |
| Async Runtime | Tokio |
| Sync Communication | gRPC (tonic) + protobuf |
| Async Messaging | Apache Iggy (persistent streaming) |
| Databases | PostgreSQL, ScyllaDB, Dragonfly, Qdrant |
| File Storage | LocalStack S3 |
| Auth | JWT (jsonwebtoken) + Argon2 |
| Real-Time | WebSocket (Axum) + Iggy pub/sub |
| Observability | Datadog |
2. General Architecture
The system follows a microservice architecture with 7 core services plus a WebSocket Gateway, all written in Rust with Tokio. Synchronous communication happens via gRPC through a unified Gateway exposing gRPC-Web to the frontend. Asynchronous communication uses Apache Iggy as a persistent message broker.
Key architectural patterns
1. Hexagonal Architecture — Each microservice follows the ports and adapters pattern. The domain sits at the center, pure Rust with no external dependencies. Interfaces (trait) define the ports, while infrastructure (PostgreSQL, Iggy, gRPC) provides swappable adapters.
2. Domain-Driven Event Design — Domain events are the primary contract between services. inventory-ms, auction-ms, and bid-ms publish events; auction-view-ms and multilingual-embedding-ms consume them to keep their data models synchronized.
3. Transactional Outbox — To guarantee reliable event delivery without two-phase commit, we combine the event write in the same SQL transaction as the business operation.
4. CQRS — auction-view-ms is a pure read model. It accepts no commands, only consumes events and maintains denormalized data in ScyllaDB for lightning-fast queries.
gRPC port map
Each service exposes its own gRPC port. The Gateway acts as a reverse proxy, authenticating every request via JWT and verifying permissions before forwarding to the appropriate service:
// Real gateway code: route → required permission mapping
permission_map.insert(
"/user_ms.UserService/GetUserById",
Some("user")
);
permission_map.insert(
"/auction_ms.AuctionService/CreateAuction",
Some("admin")
);
permission_map.insert(
"/inventory_ms.InventoryService/CreateItem",
Some("curator")
);
| Role | Typical Permissions |
|---|---|
user |
Browse auctions, place bids, own profile |
curator |
Inventory CRUD, curatorship, sources, categories |
admin |
Create/manage auctions, manage users |
3. The Heart of the Domain: The Inventory State Machine
The inventory is the most complex domain in the system. Each artwork goes through a lifecycle that we model as a state machine using a Rust enum.
ItemStatus: 12 states, explicit transitions
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum ItemStatus {
Draft,
UnderReview,
Authenticated,
NotApproved,
ReadyForAuction,
InAuction,
AuctionCancelled,
AuctionEnded,
SoldPendingPayment,
PaymentReceived,
ReturnedToConsignor,
Delivered,
}
What's interesting here is how Rust lets us model valid transitions explicitly:
impl Item {
pub fn transition_status(&mut self, new_status: ItemStatus) -> Result<(), String> {
match (&self.status, &new_status) {
| (ItemStatus::Draft, ItemStatus::UnderReview) => {}
| (ItemStatus::UnderReview, ItemStatus::Authenticated) => {}
| (ItemStatus::UnderReview, ItemStatus::NotApproved) => {}
| (ItemStatus::Authenticated, ItemStatus::ReadyForAuction) => {}
| (ItemStatus::ReadyForAuction, ItemStatus::InAuction) => {}
| (ItemStatus::InAuction, ItemStatus::AuctionEnded) => {}
| (ItemStatus::InAuction, ItemStatus::AuctionCancelled) => {}
| (ItemStatus::InAuction, ItemStatus::SoldPendingPayment) => {}
| (ItemStatus::SoldPendingPayment, ItemStatus::PaymentReceived) => {}
| (ItemStatus::SoldPendingPayment, ItemStatus::ReturnedToConsignor) => {}
| (ItemStatus::SoldPendingPayment, ItemStatus::ReadyForAuction) => {}
| (ItemStatus::PaymentReceived, ItemStatus::Delivered) => {}
| (from, to) => {
return Err(format!(
"Cannot transition from {:?} to {:?}", from, to
));
}
}
self.status = new_status;
self.updated_at = Utc::now();
Ok(())
}
}
The Rust compiler forces us to cover every combination. If we add a new state, the compiler demands we define its valid transitions. This eliminates an entire category of domain bugs.
The curatorship flow
A curator (user with curator permission) reviews each piece and issues a verdict:
pub enum AuthenticityStatus {
Pending, // Awaiting review
UnderReview, // Curatorship in progress
Authentic, // Authenticated → triggers transition to ReadyForAuction
Inconclusive, // Could not determine authenticity
}
When a curator marks a piece as Authentic, a CuratorshipUpdated event is published. The same inventory-ms consumes that event (via Iggy) and completes the transition: UnderReview → Authenticated → ReadyForAuction.
This self-consumption of events might seem odd at first, but it's by design: it makes the flow reactive and decoupled. In the future, authentication could come from an external AI service or a third party, and inventory-ms would react the same way.
4. The Bidding Engine: The Most Complex Business Logic
The bidding engine lives in bid-ms and is, without doubt, the most delicate component in the system. It implements proxy bidding (similar to eBay), with precise increment rules, reserve price handling, and outbid detection.
BidPlace: the root aggregate
pub struct BidPlace {
previous_bid: Option<Bid>, // Current winning bid
current_bid: Option<Bid>, // New incoming bid
max_bid: Option<Bid>, // Previous winner's max bid
bid_table_registry: BidTableRegistry,
reserve_price: Money, // Lot reserve price
bids_to_store: Vec<Bid>, // Bids generated for persistence
events_to_publish: Vec<BidEvent>, // Events to publish
outbids: HashMap<Uuid, Uuid>, // Outbid users → user_id: bid_id
}
BidPlace is not a persisted entity. It's a temporary aggregate built in memory during the bidding process. It runs all validations, generates the necessary proxy bids, and produces the events and records to persist.
BidTable: progressive increments
pub struct BidTable {
pub bid_increment_ranges: Vec<(f64, f64, f64)>,
pub currency: Currency,
}
impl BidTable {
pub fn new(currency: Currency) -> Self {
let bid_increment_ranges = match currency {
| Currency::USD => vec![
(0.0, 500.0, 5.00),
(500.0, 2000.0, 10.00),
(2000.0, 5000.0, 25.00),
(5000.0, 10000.0, 50.00),
(10000.0, f64::INFINITY, 100.00),
],
| Currency::COP => vec![
(0.0, 500000.0, 10000.00),
(500000.0, 10000000.0, 50000.00),
(10000000.0, 50000000.0, 100000.00),
(50000000.0, 100000000.0, 500000.00),
(100000000.0, f64::INFINITY, 1000000.00),
],
};
// ...
}
}
| Currency | Range | Increment |
|---|---|---|
| USD | $0 – $500 | $5 |
| $500 – $2,000 | $10 | |
| $2,000 – $5,000 | $25 | |
| $5,000 – $10,000 | $50 | |
| $10,000+ | $100 |
How a bid works step by step
The engine rules are:
- You cannot outbid yourself: If you're the current winner and want to bid higher, you must place a multiple of the base increment.
- Proxy Bidding: If you bid $5,000 on a lot with a current bid of $100, the system records your maximum as $5,000 but only displays a bid of $105 (the next valid increment). If someone else bids, the system automatically counter-bids on your behalf up to your maximum.
-
Reserve Price: If the reserve price hasn't been met, the system generates a
SystemProxybid at the reserve value. -
Outbid Detection: When someone is outbid, it's recorded in the
outbidstable for push notifications.
Here's the most interesting competitive flow: when there's an active max_bid:
fn handle_bid_below_max(
&mut self, current_bid: Bid, max_bid: &Bid,
) -> Result<(), BidPlaceError> {
// The new bidder (current_bid.user_id) bids less than the
// current winner's max_bid. The system generates a system
// proxy bid at the next valid increment for the max_bid owner.
let next_amount = bid_table.next_valid_increment(
current_bid.money.amount as f64
) as i32;
let proxy_bid = self.create_bid(
max_bid.user_id, // The max_bid owner stays in the lead
next_amount, // At the next increment
¤t_bid,
false, // Not a max bid
BidSource::SystemProxy,
);
self.current_bid = Some(proxy_bid);
// ... store + publish events
}
This design means the frontend only needs to send { amount: 5000, lot_id: X } to place a max bid. The server handles all the proxy bidding math.
5. Event-Driven Architecture with Apache Iggy
Apache Iggy is the message streaming system I chose. It's persistent (messages are stored on disk), Rust-native, and much lighter than Kafka for our use case.
Topics and event flow
| Topic | Producer | Consumers |
|---|---|---|
inventory.events |
inventory-ms |
inventory-ms (self-consume), auction-view-ms, multilingual-embedding-ms
|
auction.events |
auction-ms |
inventory-ms, auction-view-ms, websocket-gateway
|
lot.events |
auction-ms |
inventory-ms, auction-view-ms, multilingual-embedding-ms, websocket-gateway
|
bid.events |
bid-ms |
auction-ms, auction-view-ms, websocket-gateway
|
Transactional Outbox Pattern
The Transactional Outbox pattern is essential for guaranteeing reliable event delivery. The flow is:
- In the same SQL transaction that executes the business operation, we insert a row into the
outbox_eventstable with the event serialized as JSON. - A background process (
OutboxProcessor) runs every 5 seconds, looks for unpublished events, and sends them to Iggy. - If publishing fails, it retries every 30 seconds up to a configurable maximum.
// Fragment from IggyOutboxPublisher
#[async_trait]
impl OutboxPublisherPort for IggyOutboxPublisher {
async fn publish(&self, event: &OutboxEvent) -> ApplicationResult<()> {
let payload = serde_json::to_string(&event.payload)?;
self.event_publisher
.publish_event(
self.tenant_id,
&self.topic,
&event.event_type,
&payload,
)
.await?;
self.repository
.mark_as_published(event.id, self.publisher_id())
.await?;
Ok(())
}
}
Why not publish directly to Iggy inside the transaction? Because Iggy doesn't support distributed transactions. The outbox pattern gives us exactly-once delivery semantics without two-phase commit.
Real example: inventory-ms self-consumption
When a curator updates a curatorship to Authentic:
-
inventory-mspersists the change + outbox_event in the same TX. -
OutboxProcessorpublishesCuratorshipUpdatedtoinventory.events. -
The same
inventory-msconsumes that event and executes the state transition:UnderReview → Authenticated → ReadyForAuction. - It publishes
ItemUpdated, which is consumed byauction-view-msto update the public catalog.
This self-consumption pattern is intentional: it separates command logic (receiving the gRPC request) from reaction logic (completing secondary transitions).
6. CQRS: The Read Model with ScyllaDB
A common mistake in microservices is sharing databases. We take separation to the extreme: each service has its own independent database, and data flows between services exclusively through events.
auction-view-ms is our pure read model. It accepts no commands, exposes no write endpoints. It only:
-
Consumes events from 4 Iggy topics (
auction.events,inventory.events,lot.events,bid.events). - Denormalizes data to optimize public catalog queries.
- Stores in ScyllaDB (wide-column database, optimized for reads) and Dragonfly (in-memory cache).
// Auction event consumer in auction-view-ms
pub async fn start_auction_events_consumer(
&self, tenant_id: u32
) -> ApplicationResult<()> {
self.event_consumer
.consume_events(
tenant_id,
AUCTION_EVENTS_TOPIC,
AUCTION_CONSUMER_NAME,
|payload, _message_id, _partition_id, _offset| {
let service = self.clone();
Box::pin(async move {
let event: AuctionEvent =
serde_json::from_str(&payload)?;
service.auction_management_service
.process_auction_event(&event)
.await?;
Ok(())
})
},
None,
)
.await
}
Why ScyllaDB over PostgreSQL for the read model? Because the public catalog receives queries with multiple filters (category, status, price range, artist) and must respond in <10ms under high concurrency. ScyllaDB, being wide-column, handles this access pattern far better than PostgreSQL.
Additionally, Dragonfly (a Rust-based Redis replacement) serves as cache for:
- Current bids: the active bid for each lot is cached with a TTL.
- Outbids: outbid user IDs are cached for instant push notifications.
7. Semantic Search with Qdrant
One of the most powerful features is multilingual semantic search. Users can search for artwork in their native language and find relevant results even when the description is in another language.
Architecture
multilingual-embedding-ms maintains two collections in Qdrant:
| Collection | Content | Embedding of... |
|---|---|---|
lots |
Each lot's title + description | "Oil on canvas, Italian Renaissance..." |
sources |
Each source's name + description (artist, house) | "Leonardo da Vinci, Renaissance painter..." |
Embedding generation (event-driven)
When a lot or source is created or updated, the service consumes the corresponding event from Iggy and generates the embedding:
pub async fn upsert_embedding(
&self,
id: String,
text: String,
metadata: serde_json::Value,
collection_type: CollectionType,
) -> ApplicationResult<()> {
let embeddings = self.model
.generate_embeddings(vec![text.clone()])
.await?;
let embedding_vector = embeddings.first().ok_or_else(|| {
ApplicationError::Internal("No embedding generated".to_string())
})?;
let embedding = Embedding::new(
id, text, embedding_vector, metadata, collection_type
);
self.repository.ensure_collection(collection_type).await?;
self.repository.upsert_embedding(embedding).await?;
Ok(())
}
Search
When a user searches for "Italian Renaissance art", the service:
- Generates an embedding of the search text using the same model.
- Finds the most similar vectors in Qdrant using cosine similarity.
- Returns results sorted by relevance score.
The embedding model is multilingual, meaning "Italian Renaissance art" in English and "arte renacentista italiano" in Spanish produce vectors that are close together in the embedding space.
8. Real-Time with WebSocket + Iggy
Live auctions require real-time notifications. Our websocket-gateway is an independent service that:
- Accepts WebSocket connections with JWT authentication.
-
Consumes 3 Iggy topics (
auction.events,lot.events,bid.events). - Forwards events to subscribed clients based on their preferences.
Protocol messages
Client → Server:
{ "action": "subscribe", "topics": ["auction.events", "bid.events"] }
{ "action": "unsubscribe", "topics": ["lot.events"] }
{ "action": "pong" }
Server → Client:
{
"topic": "bid.events",
"id": "550e8400-e29b-41d4-a716-446655440000",
"bid": { "user_id": "...", "lot_id": "...", "amount": 500, ... },
"occurred_at": "2025-06-15T12:00:00Z"
}
The WebSocket gateway handles 10k+ concurrent connections with very low memory consumption thanks to Tokio's lightweight async task model.
9. Lessons Learned and Performance
Impact metrics
| Metric | Before | After | Improvement |
|---|---|---|---|
| p99 bid latency | 120ms | 45ms | -62.5% |
| RAM consumption | — | -20% | Memory leak fixed |
| WebSocket connections | — | 10k+ concurrent | Pub/sub via Iggy |
| Message throughput | — | 50k msgs/sec | Event streaming |
Problems solved along the way
1. Memory leak detected with Datadog
One of the biggest challenges was a slow but steady memory leak in bid-ms. Datadog showed linear RAM growth in the Rust heap. The cause: in BidPlace, we were cloning the Vec<Bid> of bids_to_store multiple times without clearing the original vector after validation. The fix was using std::mem::take() to move (not clone) the data:
let bids = std::mem::take(&mut bid_place.bids_to_store);
let events = std::mem::take(&mut bid_place.events_to_publish);
2. PostgreSQL connection pool
Each service opens its own pool to its dedicated database. We learned the hard way that the default max_connections=10 isn't enough for traffic spikes during live auctions. Now we configure larger pools with min_connections for warm connections:
PgPoolOptions::new()
.max_connections(50)
.min_connections(5)
.acquire_timeout(Duration::from_secs(30))
.idle_timeout(Duration::from_secs(300))
.test_before_acquire(true)
3. Strategic caching with Dragonfly
bid-ms now caches each lot's current bid in Dragonfly immediately after processing it. This reduces PostgreSQL reads for the next bid on the same lot (the most common case). The TTL is configured to expire after the auction ends.
What I'd do differently
- gRPC streaming: currently the gateway is request-response. For bids, server-side streaming would eliminate connection overhead.
- Shared event schemas: proto files are duplicated across services. A shared crate with protos would be more maintainable.
- Integration testing: we have solid unit tests in the domain, but integration tests with Iggy are difficult to set up locally.
10. Conclusion and Next Steps
Building this system took me from being a developer who "used Rust" to truly understanding how Rust's type safety and concurrency model change the way you design distributed systems.
What I learned
-
Rust enums are the best tool for modeling complex domains. The
ItemStatusstate machine is nearly impossible to implement incorrectly when the compiler forces you to cover every transition. - The Transactional Outbox pattern is indispensable in event-driven microservices. Without it, events get lost when the message broker goes down.
- ScyllaDB + Qdrant is a powerful combination for systems that need both fast structured queries and semantic search.
- Apache Iggy is a real Kafka alternative for Rust teams. Less operational overhead, same persistence guarantees.
Roadmap
-
Payment system: Stripe/PayPal integration for the
SoldPendingPayment → PaymentReceivedflow. - Push notifications: Firebase Cloud Messaging for outbid alerts when the app is in the background.
- Deploy to Kubernetes: currently on a homelab with Proxmox + Talos Linux (in progress).
- Open source: I plan to release the system's core under an MIT license.
Interested in a specific part? In upcoming articles in this series, I'll dive deeper into the bidding engine, Apache Iggy integration, Talos Linux deployment, and modeling complex domains with Rust.
#rust #microservices #architecture #eventdriven #webdev
Top comments (0)