Building a Flexible Jewellery Filtering System at Scale
How we designed a normalized, admin-configurable filtering architecture for jewellery products without constant schema rewrites.
Overview
While working on jewellery platform architecture at Nivoda, one of the interesting backend challenges was designing a scalable and flexible filtering system for jewellery products like rings, necklaces, earrings, and bracelets.
The core challenge was:
- Jewellery attributes evolve constantly.
- Filters differ across categories.
- Search performance must remain fast.
- Products can have multiple styles and properties.
Instead of hardcoding fields everywhere, we designed a normalized and extensible filtering architecture.
Problem Statement
A jewellery product can contain attributes such as:
- Metal Quality. Gold, Platinum
- Metal Carat. 18K, 22K
- Primary Style. Halo
- Secondary Style. Solitaire
- Stone Shape. Oval, Round
- Occasion. Engagement, Wedding
The challenge:
- New filters are introduced frequently.
- Multiple jewellery categories share filters.
- Some filters are category-specific.
- Products can contain multiple styles.
We needed: dynamic filtering, admin-driven configuration, scalable search, and efficient querying.
High-Level Architecture
The system was split by category:
jewellery
|
├── jewellery_ring
├── jewellery_necklace
├── jewellery_earring
└── jewellery_braceletThe jewellery table stored common product information. Subtype tables stored category-specific attributes.
Core Database Design
1. Jewellery Table
Stores shared product information across all categories.
CREATE TABLE jewellery (
id BIGINT PRIMARY KEY,
sku VARCHAR(100),
category VARCHAR(50),
price DECIMAL(10,2),
primary_style VARCHAR(100),
secondary_style VARCHAR(100),
created_at TIMESTAMP
);2. Ring-Specific Table
CREATE TABLE jewellery_ring (
id BIGINT PRIMARY KEY,
jewellery_id BIGINT,
ring_size INT,
band_width DECIMAL(5,2)
);Why subtype tables?
Splitting by category prevents a giant table full of nullable columns. Each category only carries the fields it actually needs.
Dynamic Filter System
Instead of adding every filter as a new database column, we introduced a generic filtering engine.
3. Filter Names
CREATE TABLE jewellery_filter_name (
id BIGINT PRIMARY KEY,
name VARCHAR(100)
);Example rows:
| id | name |
|---|---|
| 1 | Metal Quality |
| 2 | Metal Carat |
| 3 | Primary Style |
| 4 | Secondary Style |
4. Filter Values
CREATE TABLE jewellery_filter_value (
id BIGINT PRIMARY KEY,
filter_name_id BIGINT,
value VARCHAR(100)
);Each value row links back to its parent filter name via filter_name_id:
| id | filter_name_id | value |
|---|---|---|
| 1 | 1 | Gold |
| 2 | 2 | 18K |
| 3 | 3 | Halo |
| 4 | 4 | Solitaire |
So Gold belongs to Metal Quality (id 1), and 18K belongs to Metal Carat (id 2).
Product-to-Filter Mapping
5. Ring Filter Values
CREATE TABLE jewellery_ring_filter_value (
ring_id BIGINT,
filter_value_id BIGINT
);Ring 101 is linked to four filter values — including Metal Quality (Gold) and Metal Carat (18K):
| ring_id | filter_value_id | resolves to |
|---|---|---|
| 101 | 1 | Metal Quality → Gold |
| 101 | 2 | Metal Carat → 18K |
| 101 | 3 | Primary Style → Halo |
| 101 | 4 | Secondary Style → Solitaire |
A single ring (id: 101) can now dynamically carry:
| Filter | Value |
|---|---|
| Metal Quality | Gold |
| Metal Carat | 18K |
| Primary Style | Halo |
| Secondary Style | Solitaire |
Why This Design Worked Well
No Schema Migration for New Filters
Adding a new filter requires no backend deployment and no schema rewrite. Filters like Vintage Collection, Side Stone Type, or Setting Height are just new rows in jewellery_filter_name — and their values are rows in jewellery_filter_value. The system extends naturally as the business evolves.
Zero-migration filter additions
New filter names and values are just rows in a table. The entire backend and frontend adapt automatically — no code change needed.
Flexible Multi-Style Support
Jewellery can carry multiple style dimensions simultaneously:
Primary Style → Halo
Secondary Style → Solitaire
A single style field would have forced us into comma-separated hacks or arbitrary limits. The many-to-many mapping handles it cleanly.
Better Search Experience
Users could filter using combinations like:
Metal Carat = 18K AND Primary Style = Halo
This powered faceted search across large catalogue datasets — the kind of filtering shoppers expect on modern ecommerce platforms.
Example Query
SELECT jr.*
FROM jewellery_ring jr
JOIN jewellery_ring_filter_value rfv
ON jr.id = rfv.ring_id
JOIN jewellery_filter_value fv
ON rfv.filter_value_id = fv.id
WHERE fv.value IN ('18K', 'Halo');Scalability Considerations
As catalogue size grows, joins become expensive. The first line of defence is indexing.
CREATE INDEX idx_filter_value
ON jewellery_ring_filter_value(filter_value_id);
CREATE INDEX idx_ring
ON jewellery_ring_filter_value(ring_id);Search Engine Integration
For large-scale filtering, the normalized schema was synced into search engines like Elasticsearch and OpenSearch.
{
"id": 101,
"category": "Ring",
"primary_style": "Halo",
"secondary_style": "Solitaire",
"metal_carat": "18K",
"metal_quality": "Gold"
}This enabled millisecond filtering, aggregation counts, faceted navigation, and autocomplete — capabilities that a relational JOIN chain simply cannot match at scale.
API Example
GET /rings?primary_style=Halo&metal_carat=18K[
{
"id": 101,
"name": "Diamond Engagement Ring",
"primary_style": "Halo",
"secondary_style": "Solitaire"
}
]Key Learnings
Building ecommerce systems is rarely about just storing products. The real challenge is flexibility, evolving business requirements, search scalability, admin configurability, and clean domain modelling.
What a normalized filtering architecture gave us:
- Extensibility. New filters never require a schema change or deployment.
- Scalability. Sync to Elasticsearch kept search fast as catalogue grew.
- Reusable filters. The same filter (e.g. Metal Carat) works across rings, necklaces, and earrings.
- Category flexibility. Each category can have its own attributes without polluting the shared table.
- Better search performance. Denormalized search documents eliminated join overhead at query time.
The most important takeaway
The system could evolve without constant schema rewrites. That single property — the ability to change without fear — is what made it worth building.