Clean&Reactoring Entry Created: 04 Feb 2026 Updated: 04 Feb 2026

The Danger of Non-Local Invariants : A Case Study

In software architecture, an invariant is a condition that must always be true during the execution of a program. A common source of fragile code is the "Non-Local Invariant"—a rule that is implicitly relied upon in one part of the system but enforced in a completely different, unconnected part.

Below, we examine a scenario involving product inventory to demonstrate how moving from an Anemic Domain Model to a Rich Domain Model improves system stability.

1. The Fragile Approach: Distributed Logic

In the first implementation, the Product class is a simple data container (DTO) with no internal logic. The critical rule—"A product cannot have 0 days until expiry"—is enforced by the InventoryProcessor.

However, the AnalyticsService performs a division operation assuming this rule is always true. This creates a dangerous coupling: if the InventoryProcessor fails to run, or if a developer manually creates a Product with 0 days, the AnalyticsService will crash with a DivideByZeroException.

// File: Product.cs
public class Product
{
// Anemic model: Just data, no protection.
public string Name { get; set; }
public decimal Value { get; set; }
public int DaysUntilExpiry { get; set; } // The hidden invariant: Must be > 0
}

// File: AnalyticsService.cs
public class AnalyticsService
{
public decimal CalculateUrgency(Product product)
{
// RISKY: This relies on a NONLOCAL INVARIANT.
// We "assume" DaysUntilExpiry is never 0 because of how the
// InventoryProcessor was written years ago.
// If that external logic changes, this line crashes.
return product.Value / product.DaysUntilExpiry;
}
}

// File: InventoryProcessor.cs
public class InventoryProcessor
{
public void ProcessDay(List<Product> products)
{
foreach (var p in products.ToList())
{
p.DaysUntilExpiry--;
// The "Safety Net" is located far away from the calculation
if (p.DaysUntilExpiry == 0) RemoveFromSystem(p);
}
}
}

2. The Robust Approach: Localized Invariants

In the refactored version, we apply Encapsulation. We treat the Product not just as a bag of data, but as an entity responsible for its own state.

By moving the logic into the class:

  1. Guard Clauses: The constructor prevents an invalid object from ever existing.
  2. Safe Calculations: The logic for Urgency is internal to the class, allowing it to handle edge cases (like division by zero) gracefully without crashing the system.

This ensures that the invariant is Localized. You do not need to read the InventoryProcessor code to understand how Product behaves.

// File: Product.cs
public class Product
{
public string Name { get; private set; }
public decimal Value { get; private set; }
// Backing field to ensure control over the data
private int _daysUntilExpiry;

public Product(string name, decimal value, int daysUntilExpiry)
{
Name = name;
Value = value;
// LOCALIZING THE INVARIANT: The rule is checked at the source.
// The object refuses to be created in an invalid state.
if (daysUntilExpiry <= 0)
throw new ArgumentException("Expiry must be positive.");
_daysUntilExpiry = daysUntilExpiry;
}

// The calculation logic is now owned by the object containing the data.
// It safely handles the logic, preventing external consumers from crashing.
public decimal Urgency => Value / (_daysUntilExpiry == 0 ? 0.1m : _daysUntilExpiry);
}

Conclusion

The transition from the first example to the second highlights a core principle of Object-Oriented Design: Tell, Don't Ask. Instead of asking for data and hoping it conforms to hidden rules, we tell the object to perform the calculation itself. This localizes complexity and makes the system significantly easier to maintain and debug.

Share this lesson: