The Price Catalog Problem
The problem
Business context: You are the technical lead of a B2B industrial materials distribution platform. The catalog contains ~50,000 products. Each product has a base price, but the final price depends on:
The customer (there are ~2,000 customers, each of whom may have negotiated discounts by category or for specific products). The order volume (tiers: 1–10 units, 11–50, 51–200, 200+). Temporary promotions that marketing launches without prior notice (2–3 per week; they may affect entire categories or individual products). A daily exchange rate (some suppliers invoice in USD/EUR and the price is recalculated).
Current situation (the mess): The legacy monolith in PHP 7.4 calculates the price in a 1,800-line service with 47 nested if/else statements. Nobody dares touch it. When marketing introduces a new promotion, a developer takes 2 days to implement it and pray that it doesn't break another rule. The calculation takes ~200ms per product, and with an order containing 80 lines, the checkout page takes 16 seconds.
Requirements:
Functional: The price must be calculated correctly by applying all rules in the appropriate priority order. Marketing wants to be able to create promotions without developer intervention. Non-functional: Checkout for an order with 100 lines must respond in < 2 seconds. The system must be auditable (we need to know why a customer paid X for a product on a given date). Constraints: Team of 3 backend developers (PHP/Symfony). There is no budget to rewrite everything at once. The monolith is still alive and other modules depend on it.
Expected deliverables:
Domain model for the pricing engine (entities, value objects, rules). Migration strategy from the monolith (how do you coexist with the legacy code while migrating?). Performance decision: How do you reduce the response time from 16s to <2s?
Don't give me a generic answer. I want concrete decisions with justification of the trade-offs. If you draw a diagram, even better. If you write code for the model, even better.
Domain Model
Three key concepts:
PriceContext
→ Value Object containing all the information required for the calculation:
customer product category quantity date currency PricingRule
→ Interface that every rule must implement:
appliesTo(context): bool
apply(price, context): Money
priority(): int PriceBreakdown
→ Result Value Object containing:
base price final price list of applied rules
This IS your audit trail.
Two types of rules Structural (code) Configurable (database) VolumeDiscountRule Hydrated from the pricing_rules table ExchangeRateRule Marketing creates them from the back office Fields: type, value, criteria, start/end date, priority
The PricingEngine loads all active rules, sorts them by priority, evaluates which ones apply to the context, and executes them sequentially.
It returns a PriceBreakdown that tells you exactly which rules were applied and in what order.
This is then persisted to the audit table via an asynchronous event.
Performance
Checkout request (100 products):
Request de checkout (100 productos)
│ ├─ 1 query → load customer discounts
├─ 1 query → load active promotions (event-based TTL cache)
├─ 1 query → get today's exchange rate (1-hour TTL cache)
│ └─ 100 calculations in pure memory (no I/O)
→ ~1-5ms per product = 100-500ms total
From 16 seconds to less than 1 second.
The key is that the calculation itself is CPU-bound, operating on data that is already in memory.
The original bottleneck was the N×3 queries, not the logic itself.
Migration with Parallel Run Phase 1 — Weeks 1-3
Extract interface → implement new engine → write tests
Phase 2 — Weeks 4-6
Every request executes BOTH engines:
Legacy → responds to the user (source of truth)
New → calculates in shadow mode → compares → logs discrepancies Phase 3 — Week 7
0% discrepancies → switch over:
New → responds to the user
Legacy → runs in shadow mode Phase 4 — Week 8
Remove legacy