System Design Intermediate B1

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

#ddd #hexagonal #design