simotion's avatar

Product pricing history vs performance — denormalization or single source of truth?

I originally had three price columns on my products table: price_purchase, price_sell and price_advice.

To support pricing history, I introduced a ProductPrice model. The product_prices table contains: • id • product_id • price_type (purchase / sell / advice) • price_value • publisher_id • publisher_name (cached for when a user gets deleted) • timestamps

Whenever a product is created or updated, I check whether a price changed and, if so, I insert a new ProductPrice record. This part works well and gives me full pricing history per product.

The dilemma I’m facing now is where the single source of truth should live.

If I fully rely on the ProductPrice model for “current” prices, I need to load relational data for every product. Eager loading all prices caused a significant memory increase on product index and inventory management pages. Even when limiting this to only the most recent price per type (using scopes or subqueries), it quickly adds up to three extra lookups per product.

With a growing user base (±50 concurrent users) and fairly heavy product listings and inventory views, I’m worried about the long-term performance impact of always deriving the current price from the new table.

The alternative is to keep the original price columns on the products table and treat them as denormalized “current price” fields, while using product_prices purely for historical insight (charts, audits, etc.). This would avoid joins and additional queries entirely for most product-related logic.

However, this feels conceptually “wrong” to me, since the ProductPrice model then isn’t the single source of truth anymore.

I’m curious how others would approach this: • Would you denormalize and accept the duplication for performance? • Use subqueries or views to project current prices? • Or structure this differently altogether?

How would you optimize this without sacrificing performance?

0 likes
4 replies
LaryAI's avatar
Level 58

Great and thoughtful question! This is a common scenario in applications tracking changing state over time, and you’ve already outlined the main trade-offs between normalization (single source of truth) and denormalization (performance).

Here’s how I would approach it:


1. Denormalize Current Prices on the Products Table

Yes, keep the price_purchase, price_sell, and price_advice columns on your products table.

This denormalized approach has these advantages:

  • Fast Reads: Most of your application probably needs the current price, not the entire history. Accessing these fields directly is extremely fast—no additional queries, joins, or subqueries.
  • Simplicity: Your UI code and logic for index/listings remain simple and high-performing.

2. Maintain Historical Records for Auditing/Analytics

Your product_prices table continues to store the full price history. This is the right place for analytics, audit logs, price trends, reporting, etc.


3. Keep Them in Sync with Database Transactions or Events

Whenever you change a price, do these two things:

  • Write a new row in product_prices
  • Update the product’s "current price" column

If you’re using Eloquent, you could do this in your service/model logic or using model events:

public function updateProductPrice(Product $product, $type, $value, $publisher)
{
    // 1. Log price change
    ProductPrice::create([
        'product_id'     => $product->id,
        'price_type'     => $type,
        'price_value'    => $value,
        'publisher_id'   => $publisher->id,
        'publisher_name' => $publisher->name,
    ]);

    // 2. Update denormalized column on products
    $column = "price_{$type}";
    $product->$column = $value;
    $product->save();
}

And to ensure atomicity, wrap both in a transaction:

DB::transaction(function() use ($product, $type, $value, $publisher) {
    // ...both steps as above...
});

4. Document Your Design Decision

It’s perfectly acceptable to denormalize in this scenario—many successful applications make this choice (even at massive scale, e.g. Shopify, Amazon, etc.). Just be clear in documentation and code comments so future maintainers understand the synchronization constraints.


5. Alternatives Evaluated

  • Subqueries/Views: Can be elegant, but for large data sets they become a bottleneck and complicate Eloquent relationships and eager loading.
  • Database Triggers: Possible, but can be harder to maintain/debug and are less portable.
  • Caching: Could offload some reads, but you’d still need to update cache upon each write, and full denormalization is simpler for this case.

Conclusion

Denormalize. Your products table fields are the source of truth for “current” business logic, while product_prices provides full history. The key is careful synchronization, but Laravel (and good database practices) make this straightforward. This approach maximizes performance and clarity for the needs you’ve described.


Summary:

  • Keep current prices denormalized on products
  • Record price changes in product_prices
  • Synchronize both in a transaction
  • Reads are fast, history is preserved, models stay simple

Let me know if you want specific Eloquent/scoped query examples for fetching price history!

simotion's avatar

To add some more context, this is how I currently persist pricing history from the Product model.

On creation I store the initial prices:

protected static function booted()
{
    static::created(function (Product $product) {
        ProductPrijs::add($product, PrijsType::INKOOP, $product->prijs_inkoop);
        ProductPrijs::add($product, PrijsType::VERKOOP, $product->prijs_verkoop);
        ProductPrijs::add($product, PrijsType::ADVIES, $product->prijs_advies);
    });
}

And when a price is updated, I append a new ProductPrijs record only if the value actually changed:

public function setPrijsVerkoopAttribute($value)
{
    $oldPrice = $this->getOriginal('prijs_verkoop');
    $newPrice = Price::fromFloat($value);

    $this->attributes['prijs_verkoop'] = $newPrice->getValue();

    if ($this->exists && $oldPrice && ! $oldPrice->equals($newPrice)) {
        ProductPrijs::add($this, PrijsType::VERKOOP, $newPrice);
    }
}

…and similar mutators for the other two price types.

This keeps the history append-only and avoids unnecessary writes.

I’m aware this introduces side effects (using a mutator), but this was a conscious trade-off for now. The main thing I’m trying to balance is keeping product reads fast (no joins or global scopes on heavy product listings) while still having a complete and reliable pricing history.

Curious how others would structure this — especially around where this responsibility should live (mutator vs observer vs service), and whether you’d keep the denormalized price columns in this scenario.

Glukinho's avatar

A source of truth for a model is what is in model's table. And a history is a history - it is just collected in a background and you can refer to it if you need historical data.

Think about log files: they also have some data inside, but you don't call them a source of truth, do you?

simotion's avatar

I think I may have given the wrong impression by referring to the new model as “history” in my earlier posts.

It’s called ProductPrice rather than PricingLog or PricingHistory for a reason. It isn’t merely a log; it’s domain data. Instead of updating a single row in place, price changes are stored as new entries. This keeps historic prices in the database primarily for auditing purposes, but decoupling prices from the products table also enables future use cases the original architecture didn’t support.

For example, the model could support temporary prices (e.g., Black Friday deals) via a nullable valid_until column.

The part I’m struggling with is whether the current price should be derived from this model (making it the conceptual source of truth), or whether it’s acceptable to keep a denormalized current value on the product purely for performance reasons.

Keeping denormalized columns stores current prices in two places — on Product and on ProductPrice. It adds complexity to the codebase and isn’t as explicit as keeping prices solely in the prices table, in exchange for faster reads in product-heavy parts of the application.

At what point would you say the added complexity isn’t worth the performance gain? For instance, with temporary prices, a denormalized approach would require a scheduled task to detect expired prices and update the product table back to the last valid price, whereas deriving the current price from the ProductPrice model wouldn’t. I’ve also considered caching, but that feels like pushing domain logic into cache management.

Curious how others would draw that line.

Please or to participate in this conversation.