Construction Technology · Commerce Infrastructure Case study
Compare Builder Merchants
A high-volume commerce and price-comparison platform for building materials, designed around canonical product data, multi-merchant offers, asynchronous catalogue ingestion and fast customer-facing search.
- Role
- Senior Full-Stack Software Engineer · Architecture & Delivery
- Disclosure
- Client/project identity anonymized · Proprietary implementation omitted
Verified proof
100K+ production requestsMulti-merchant pricing infrastructure
The problem
What the system had to solve.
Comparing building-material prices across merchants looks simple at the interface level, but the underlying system has a very different shape. Each merchant owns a separate catalogue, product identifiers, pricing model, availability representation and update cycle. Customer traffic has to stay fast while large merchant feeds are refreshed behind it.
The platform needed to unify those independent sources into a searchable product catalogue, associate merchant listings with the correct products, keep prices and stock sufficiently fresh, preserve historical changes, and serve the result under production traffic exceeding 100,000 requests.
- Multiple independent merchant catalogues
- Large product datasets
- Frequently changing prices
- Different stock and availability formats
- Product matching across merchants
- Background catalogue synchronization
- High-read customer traffic
- Price history and alerts
- Search and filter performance
- Failure isolation between merchants
What I owned
The responsibility behind the work.
Worked across the platform from the customer-facing application through the backend APIs, catalogue modelling, asynchronous merchant ingestion, price synchronization and production reliability.
Designed and implemented the infrastructure that continuously ingests, normalizes, compares and serves product and pricing information from independent merchants, while keeping customer traffic fast and isolated from upstream merchant systems.
Engineering approach
How it was built.
Built around a strict separation between write-heavy merchant synchronization and read-heavy customer traffic. Merchant data enters through scheduled Python ingestion, is bulk-staged and diffed against existing state, and only meaningful changes continue through RabbitMQ. PostgreSQL remains the source of truth while Redis and OpenSearch provide the derived read paths customer requests are served from.
Data path
- Acquire
- Normalize
- Diff
- Persist
- Publish
- Index / cache
- Serve
Separate product identity from merchant offers
One product, many merchant offers.
A product does not have one price. It has a canonical identity and a set of merchant-specific listings, each carrying its own commercial offer. Treating every merchant listing as its own product produces a catalogue full of near-duplicates, and comparison stops being possible before any interface work begins.
Once identity is separated from the offer, comparison, sorting, best-price calculation, stock comparison, price history and alerts all fall out of the same model rather than being bolted onto it.
Product and offer relationship
- Canonical product Identity, attributes and identifiers
- Merchant listing A
- Current offer
- Merchant listing B
- Current offer
- Merchant listing C
- Current offer
What the customer compares
DeWalt DCD796N-XJ
| Merchant | Price | Availability |
|---|---|---|
| Merchant A | £94.98 | In stock |
| Merchant B | £99.99 | In stock |
| Merchant C | £102.50 | In stock |
What the separation enables
- Real price comparison
- Merchant sorting
- Best-price calculation
- Stock comparison
- Price history
- Price alerts
- Product-level search
System architecture
How the pieces sit together.
The platform has two independent paths over an owned data platform. Customer requests travel from the edge through the application and API layers. Merchant data travels its own path on its own schedule: sources, scheduled ingestion, Python workers, staging and diff. PostgreSQL is the transactional source of truth; Redis and OpenSearch are derived read infrastructure. Neither path runs through the other.
That separation is the property the rest of the architecture is arranged to protect. Everything below is a consequence of it.
Independent request and ingestion paths
Read path · customer traffic
- Customers Production traffic exceeding 100,000 requests
- CDN / edge Static and cacheable responses
- Next.js Product discovery and comparison
- NestJS Application and API layer
- Redis / OpenSearch / PostgreSQL Cache / search model / source of truth
Write path · merchant synchronization
- Merchant sources APIs, data feeds, partner feeds, permitted scrapers
- Scheduled ingestion Runs on its own cadence
- Python workers Validate, normalize, extract identifiers
- Bulk staging and diff Classify unchanged, changed, new and missing listings
- PostgreSQL Transactional source of truth
- RabbitMQ Downstream change delivery
Downstream processing
- RabbitMQ Asynchronous messaging
- Offer updates
- Price history
- Search indexing
- OpenSearch
- Cache invalidation
- Redis
- Alert evaluation
Reading the paths separately: nothing on the customer path touches a merchant, and nothing on the merchant path waits for a customer request. Merchant changes reach PostgreSQL before RabbitMQ delivers isolated downstream work, including the updates that keep Redis and OpenSearch current.
Merchant ingestion, kept off the request path
Prices are fetched before anyone asks for them.
Merchant acquisition runs on its own schedule, independently of customer reads. Prices are fetched ahead of time, normalized and stored locally. When a customer opens a product page, the request is served from our own application, cache, search index and database.
This is the decision the platform is built on. Aggregating live at request time makes every page as slow as the slowest merchant and as available as the least available one.
Two ways to answer the same question
Deliberately avoided
Live aggregation at request time.
- Customer opens a product
- Call merchant A
- Call merchant B
- Call merchant C
- Wait for the slowest one
Production architecture
Synchronized ahead of the request.
- Merchants
- Background ingestion
- Our data platform
- Redis / OpenSearch / PostgreSQL
- API
- Customers
Ingestion pipeline
- Merchant sources One adapter per integration type
- Scheduled ingestion
- Python workers
- Staging and validation
- Change detection
- PostgreSQL
Source preference
- Merchant API
- Structured merchant / feed integration
- Affiliate / partner feed
- Merchant-specific scraper where required and permitted
A customer request never triggers merchant crawling. It is answered from synchronized state we already hold.
Updating large catalogues without rewriting everything
Only what actually changed moves downstream.
A large incoming merchant snapshot should not force every listing through downstream processing when only a subset changed. The ingestion model bulk-stages and diffs the snapshot first, so unchanged records stop at the comparison boundary.
Incoming records are bulk-loaded into a staging area rather than issuing a database round trip per product, and the comparison runs set-wise against current merchant listings. The merchant plus its own SKU is the practical stable identity within a feed when the integration contract preserves that key; sources that do not preserve it need an explicit adapter-level identity rule.
Dataset diff
- Incoming merchant feed Bulk-staged, not one request per record
- Compare against current merchant listings
- Unchanged Ignored
- Changed Update
- Offer or catalogue update
- New Insert and classify
- Matching and mapping
- Missing Validate before deactivation
Two fingerprints per listing
Catalogue fingerprint
Derived from the fields that describe the product itself.
- Title
- Brand
- MPN
- GTIN / EAN
- Attributes
- Dimensions
- Pack quantity
- Supporting product metadata
Offer fingerprint
Derived from the fields that describe the commercial terms.
- Price
- Tax representation
- Stock
- Delivery
- Promotion
- Availability
What each combination costs
- Catalogue fingerprint same
- Offer fingerprint same
No work.
- Catalogue fingerprint same
- Offer fingerprint changed
Update the offer only: price, tax, stock, delivery, availability.
- Catalogue fingerprint changed
Reprocess the product metadata, and re-check the mapping if identifying data has moved.
A price change does not cause the whole product to be re-normalized or re-matched.
Once a merchant SKU is confidently mapped to a canonical product, that relationship stays persisted. It is only revisited when the identifying product data materially changes, which is the expensive path and the rare one.
End-to-end synchronization
- Merchant source
- Scheduled fetch
- Python ingestion
- Normalize
- Bulk staging
- Dataset diff
- PostgreSQL
- RabbitMQ
- Cache invalidation
- Search indexing
- Alert evaluation
The canonical product and merchant-offer model
What the data model has to hold.
The model expresses four separate things: what a product is, how a given merchant represents it, what that merchant is currently offering, and how that offer has moved over time. Collapsing any two of them costs either comparison or history.
Relationship chain
- Category
- Product Canonical identity
- Product variant
- Merchant listing A merchant’s representation of it
- Offer Current commercial terms
- Price history How the offer moved
Simplified public-safe domain model
Product identity
- brands
- categories
- products
- product_variants
- product_identifiers
- product_attributes
Merchant and commercial state
- merchants
- merchant_listings
- offers
- offer_price_history
- inventory_state
Conceptual entities, written to show the shape of the model. These are not production table definitions.
Python processing workers
Where a merchant record becomes an offer.
The application and API layer is TypeScript. The data-processing workloads are Python and run as their own workers, so a large catalogue import never competes with the request path for the same process, and the two can be scaled against different signals.
Worker pipeline
- Raw merchant record
- Validation
- Normalization
- Identifier extraction
- Existing listing lookup
- Change classification
- Product mapping / matching Only when identity data requires it
- Offer update
- Price history Only for a meaningful price transition
- Downstream events
Processing surface
- Python
- Typed validation
- Structured parsing
- Batch processing
- HTTP integrations
- Normalization utilities
- Fuzzy matching where applicable
RabbitMQ as the asynchronous backbone
Why the stages talk through a broker.
Merchant imports are large, price updates should not block ingestion, search indexing does not need to be synchronous, and alerts should not be able to slow either. RabbitMQ sits between those workloads so each one can fail, retry and scale on its own terms.
What the broker buys
- Large imports run without blocking
- Price updates do not block ingestion
- Search indexing happens asynchronously
- Alert processing stays isolated
- Failed work is retryable
- Workloads scale independently
- One merchant failure stays local
Consumer topology
- RabbitMQ
- Offer updates
- Price history
- Search updates
- OpenSearch
- Alert processing
- Notifications
Published change events
- Offer updated
- Price changed
- Stock changed
- Product changed
- Search reindex
- Cache invalidation
Event kinds, described conceptually. Production routing names, queue names and bindings are not published.
Delivery guarantees
Acknowledgement
Work is acknowledged only after it has been processed and its state committed. A worker that dies first leaves the work recoverable.
- Receive message
- Process
- Commit state
- Acknowledge
Retry and dead-letter
A message that keeps failing is isolated rather than left to block healthy work behind it.
- Failure
- Retry
- Backoff
- Retry
- Dead-letter queue
Duplicate-safe consumption is the required property: a repeated price-change event must not write a second history transition, send a duplicate notification or repeat downstream state changes. The exact production deduplication mechanism is intentionally not claimed here.
Reliability boundary
A reference pattern for keeping writes and events consistent.
Committing a database change and publishing a message are two operations, and the gap between them is a distributed-systems correctness boundary. If the write succeeds and the process dies before publishing, the price has changed but the cache, the search index and the alert evaluator may never hear about it.
The gap and the pattern that closes it
The failure mode
- Database update succeeds
- Process fails before publishing
- Cache, search and alerts never hear about it
Reference pattern · transactional outbox
- One database transaction The offer update and the outgoing event commit together
- Publisher / dispatcher
- RabbitMQ
With this pattern the event is persisted alongside the state change, so publication can be retried without losing the business event, and idempotent consumers make redelivery safe. It is set out here as the reliability boundary this class of system has to answer for, not as a published account of the production internals.
Search as a dedicated read model
Discovery does not run on the transactional tables.
PostgreSQL stays the transactional source of truth. Product discovery runs against a read-optimized search index that is kept current asynchronously, so a browsing customer never contends with a catalogue import for the same rows.
Index path
- PostgreSQL
- Product or offer change
- RabbitMQ
- Index worker
- OpenSearch
What the index serves
- Text search
- Category navigation
- Brand filters
- Merchant filters
- Product attributes
- Availability
- Price filtering
- Sorting
- Autocomplete
Precomputed read-side fields
- Best price
- Available offer count
- Merchant count
- Availability summary
A category page reads these directly instead of aggregating across products, listings and offers for every product on every request.
Scaling reads independently of ingestion
Customer traffic and merchant traffic are different systems.
Production traffic exceeded 100,000 requests, so customer reads were deliberately kept independent of merchant synchronization workloads. The read path and the write path scale separately and communicate through owned, durable platform state rather than live merchant calls.
Two paths, shared owned state
Read path
- Customers Production traffic exceeding 100,000 requests
- CDN / edge
- Next.js
- NestJS
- Redis · OpenSearch · PostgreSQL
Write path
- Merchant sources
- Scheduled ingestion
- Python workers
- Bulk staging and diff
- PostgreSQL Transactional source of truth
- RabbitMQ
- Downstream processors
What keeps reads bounded
- No merchant page is fetched during a customer request
- Frequently read data is served from cache
- Search queries go to the search layer
- Database access is indexed and bounded
- Expensive catalogue processing is asynchronous
- Category and product responses use precomputed read models
- Static and cacheable content is delivered at the edge
- Worker capacity scales separately from web and API capacity
Held in Redis
- Current product offers
- Best-price summaries
- Frequently requested product data
- Popular category metadata
- Autocomplete support
One synchronization, many reads
- One merchant synchronization
- Stored, normalized offer state
- Customer read
- Customer read
- Customer read
- Further reads Until the next synchronization
Cache invalidation
- Offer changes
- Database commit
- RabbitMQ event
- Cache invalidation
- Next request repopulates
Cached and indexed data is derived and disposable. If Redis is lost, PostgreSQL is still authoritative and the cache refills; OpenSearch can be reconstructed from the same source of truth.
Price history and alerts
Current price and price history are different questions.
The current offer answers what a customer pays now. The history answers how it got there. Keeping them separate means the read path stays a single-row lookup while the historical record grows behind it.
Recorded price changes
DeWalt DCD796N-XJ · current offer £87.99
| Date | Price |
|---|---|
| Aug 01 | £109.00 |
| Aug 12 | £104.00 |
| Aug 20 | £99.00 |
| Sep 04 | £87.99 |
What the history enables
- Price-change visibility
- Alerts
- Trend analysis
- Historical-low logic
- Auditing unexpected price changes
Alert evaluation
- Price update
- RabbitMQ
- Alert evaluation
- User threshold or rule
- Notification
Alert conditions
- Price changed
- Price dropped below a threshold
- Back in stock
Protecting production from bad merchant data
A merchant feed can fail in ways that look like data.
An external feed does not usually fail by refusing to arrive. It arrives truncated, half-parsed, or carrying a pricing column that has quietly changed meaning. A suspicious import is not allowed to overwrite production state on the assumption that it is correct.
What a bad run looks like
Anomalous change rate
- Previous run: a limited share of prices changed
- New run: an unexpectedly large majority changed
Anomalous catalogue size
- Previous feed: a large, healthy catalogue
- New feed: a large share of products missing
What a run is checked against
- Unexpected record-count changes
- Unusual share of price changes
- Impossible or zero prices
- Extreme price jumps
- Missing-product spikes
- Parsing failures
- Incomplete pagination or downloads
- Malformed identifiers
Import gate
- Incoming feed
- Validation
- Sanity checks
- Within expectations
- Publish downstream
- Outside expectations
- Quarantine and investigate
Absence is treated with the same caution. A product missing from one feed run is not evidence that the product is gone, so records move through cautious state transitions rather than being deleted on a single observation.
Why a product can vanish from one run
- Genuinely discontinued
- Removed by the merchant
- Temporary merchant failure
- Parser or adapter failure
- Incomplete feed
- Pagination problem
Only one of those causes means the product is actually gone. Deletion is the one action that cannot be walked back on the next healthy run.
Reliability principles
The properties a production implementation must hold.
Idempotent processing
Repeated messages do not create repeated business effects.
Backpressure
RabbitMQ decouples the feed-processing rate from downstream capacity.
Dead-letter handling
Poison messages are isolated rather than blocking healthy work.
Bulk diffing
Unchanged merchant listings generate no unnecessary downstream processing.
Derived read models
Redis and OpenSearch accelerate reads without replacing PostgreSQL as the source of truth.
Failure isolation
A failing merchant integration does not take down product browsing.
Stale-data awareness
Merchant freshness is tracked independently of customer traffic.
Recoverability
Asynchronous processing can be replayed and derived stores rebuilt from durable state.
Platform surface
What the architecture serves.
The customer-facing surface is deliberately ordinary. Everything above is what makes it possible to answer these questions quickly, from our own data, while merchant catalogues keep moving underneath.
Product discovery
- Hierarchical categories
- Product search
- Filters
- Sorting
- Brand and product-code discovery
Merchant comparison
- Multiple retailer offers
- Current pricing
- Stock status
- Merchant filtering
Customer workflows
- Price alerts
Construction ecosystem
- Building products and materials
- Merchant discovery
Engineering scope
What I worked on.
- Canonical product and merchant-offer domain modelling
- Next.js customer-facing commerce experience
- NestJS application and API architecture
- Python merchant-ingestion and processing workers
- RabbitMQ asynchronous and event-driven workflows
- Bulk feed staging and change detection
- Price and availability synchronization
- Price-history processing
- Redis caching strategy
- OpenSearch-backed product discovery
- Failure isolation and asynchronous reliability boundaries
- Production traffic exceeding 100,000 requests
Why these decisions mattered
The trade-offs behind the architecture.
Background ingestion, not live aggregation
Canonical product plus merchant listing
Bulk diff before asynchronous processing
RabbitMQ between workloads
Python for the data workers
PostgreSQL as the source of truth
Impact
The verified result.
Delivered a comparison architecture capable of continuously synchronizing independently changing merchant catalogues while serving production traffic exceeding 100,000 requests, without coupling customer reads to merchant availability.
- 100K+ Production requests handled
- Independent Customer reads from merchant synchronization
- Incremental Only changed and new merchant records reach expensive downstream processing
- Resilient Asynchronous processing isolates ingestion, indexing, pricing and user-facing work
Technology
The technical surface.
Frontend
- Next.js
- React
- TypeScript
Backend
- NestJS
- TypeScript
- Application / API layer
Processing
- Python
- Background workers
- Batch processing
Messaging
- RabbitMQ
- Event-driven workflows
Data
- PostgreSQL
- Redis
- OpenSearch
Infrastructure
- AWS
- CDN / caching