Loading learning content...
It's 2:47 AM. Your pager goes off. Production is down.
After hours of debugging, you trace the issue to a NullPointerException deep in the payment processing code. But the null value shouldn't exist—it comes from a getCustomer() method on an Order object, and every Order is supposed to have a customer. That's the whole point of the Order class.
You dig deeper. The Order came from a RushOrder subclass. Someone optimized rush order creation for speed and skipped customer validation. The subclass broke the invariant "every order has a customer," and now that broken promise propagated through the entire order processing pipeline.
The object lied about what it was. It said it was an Order, and orders have customers. But it didn't have a customer. The lie caused every downstream system that trusted the Order contract to fail.
This page examines invariant violations: how they happen, why they're so destructive, how to detect them, and what they look like when they cascade through real systems.
By the end of this page, you will understand the different types of invariant violations, see how violations manifest as production bugs, learn techniques for detecting violations early, and comprehend why invariant violations through inheritance are particularly dangerous.
Invariant violations aren't all created equal. Understanding the different types helps you recognize and prevent them.
1. Construction Violations
The object is created in an invalid state from the very beginning.
1234567891011121314151617181920
class EmailAddress { private address: string; // INVARIANT: address matches email format constructor(address: string) { // VIOLATION: No validation! this.address = address; // Could be "not-an-email" }} // Object created in invalid stateconst invalid = new EmailAddress("this is not an email");// invalid exists, but violates its own invariant // Later, code trusts the invariantfunction sendEmail(to: EmailAddress): void { // Assumes address is valid for email protocol // Fails mysteriously due to invalid format}2. Mutation Violations
The object starts valid but transitions to an invalid state through mutation.
12345678910111213141516171819202122232425262728293031323334353637
class CircularBuffer<T> { private items: (T | undefined)[]; private head: number = 0; private tail: number = 0; private count: number = 0; // INVARIANT: 0 <= count <= items.length // INVARIANT: head and tail are valid indices // INVARIANT: count accurately reflects number of items constructor(capacity: number) { this.items = new Array(capacity); } push(item: T): void { // VIOLATION: No bounds check for count this.items[this.tail] = item; this.tail = (this.tail + 1) % this.items.length; this.count++; // Can exceed capacity if push called too many times! } pop(): T | undefined { if (this.count === 0) return undefined; const item = this.items[this.head]; this.head = (this.head + 1) % this.items.length; this.count--; return item; }} const buffer = new CircularBuffer<number>(3);buffer.push(1); buffer.push(2); buffer.push(3); // At capacitybuffer.push(4); // VIOLATION: count is now 4, but capacity is 3 // Invariant broken: count (4) > items.length (3)// All subsequent operations produce wrong results3. Inheritance Violations (LSP Violations)
A subclass fails to maintain invariants that the parent class guarantees.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253
abstract class Animal { protected name: string; protected age: number; // INVARIANT: name is non-empty // INVARIANT: age >= 0 constructor(name: string, age: number) { if (!name || name.trim().length === 0) { throw new Error("Name cannot be empty"); } if (age < 0) { throw new Error("Age cannot be negative"); } this.name = name; this.age = age; } abstract makeSound(): string;} class Dog extends Animal { private breed: string; constructor(name: string, age: number, breed: string) { super(name, age); this.breed = breed; } makeSound(): string { return "Woof!"; }} // VIOLATION: Subclass with weaker constructionclass StrayDog extends Animal { constructor() { // VIOLATION: Bypasses parent validation super("temp", 0); // Satisfy parent this.name = ""; // Then break invariant this.age = -1; // And this one too } makeSound(): string { return "..."; }} // Client code trusts Animal invariantsfunction createPetProfile(pet: Animal): string { // Trusts that name is non-empty and age is non-negative return `${pet.name}, ${pet.age} years old`; // Returns ", -1 years old"}4. External Mutation Violations
External code modifies an object's internal state, breaking invariants.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051
class SortedArray { private items: number[]; // INVARIANT: items is sorted in ascending order constructor(initial: number[] = []) { this.items = [...initial].sort((a, b) => a - b); } add(item: number): void { // Binary search insert to maintain sort const index = this.findInsertionPoint(item); this.items.splice(index, 0, item); } // DANGER: Returns reference to internal array! getItems(): number[] { return this.items; // Should return copy! } private findInsertionPoint(item: number): number { let left = 0, right = this.items.length; while (left < right) { const mid = Math.floor((left + right) / 2); if (this.items[mid] < item) left = mid + 1; else right = mid; } return left; } binarySearch(item: number): number { // Relies on sort invariant for correctness! let left = 0, right = this.items.length - 1; while (left <= right) { const mid = Math.floor((left + right) / 2); if (this.items[mid] === item) return mid; if (this.items[mid] < item) left = mid + 1; else right = mid - 1; } return -1; }} const sorted = new SortedArray([1, 3, 5, 7, 9]); // External code breaks invariant through leaked referenceconst internal = sorted.getItems();internal[2] = 100; // Now array is [1, 3, 100, 7, 9] - NOT SORTED! // Binary search now returns wrong resultssorted.binarySearch(7); // Returns -1 (not found) even though 7 is present!Notice how none of these violations caused immediate crashes. The objects continue to exist and respond to method calls. They produce wrong results, make wrong decisions, and corrupt data—but they don't obviously fail. This silence is what makes invariant violations so dangerous.
When an invariant is violated, the damage doesn't stop at the broken object. It cascades through every piece of code that trusts that invariant.
The Trust Chain Collapse:
Consider a typical layered system:
Repository returns User objects with invariant: email is validUserService uses User objects, trusting email is validNotificationService sends emails to User objects, trusting email is validReportGenerator counts users by email domain, trusting email is validAnalytics processes reports, trusting data is validIf the Repository creates a User with an invalid (or null) email, every layer fails:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172
// LAYER 1: Repository with invariant-violating subclassclass UserRepository { async findById(id: string): Promise<User> { // Returns User with invariant: email is valid const data = await this.db.query(`SELECT * FROM users WHERE id = ?`, [id]); return new User(data.name, data.email); }} class CachedUserRepository extends UserRepository { private cache: Map<string, User> = new Map(); async findById(id: string): Promise<User> { if (this.cache.has(id)) { return this.cache.get(id)!; } // VIOLATION: Caching partially-constructed user for "performance" const placeholder = new PartialUser(id); // email is null! this.cache.set(id, placeholder); const fullUser = await super.findById(id); this.cache.set(id, fullUser); return fullUser; }} // LAYER 2: Service trusts repository's guaranteed invariantclass UserService { constructor(private repo: UserRepository) {} async getEmailDomain(userId: string): Promise<string> { const user = await this.repo.findById(userId); // Trusts email is valid - crashes on null email return user.email.split('@')[1]; // TypeError: Cannot read 'split' of null }} // LAYER 3: Notification trusts user has valid emailclass NotificationService { async sendWelcome(user: User): Promise<void> { // Trusts user.email is a valid address await this.mailer.send(user.email, "Welcome!"); // Fails: invalid email format or null }} // LAYER 4: Reporting trusts email invariant for groupingclass ReportGenerator { generateDomainReport(users: User[]): Map<string, number> { const domains = new Map<string, number>(); for (const user of users) { // Trusts all emails are valid with @ sign const domain = user.email.split('@')[1]; // Crashes or produces undefined domains.set(domain, (domains.get(domain) ?? 0) + 1); } return domains; // Contains 'undefined' as a key! }} // LAYER 5: Analytics sees corrupted dataclass Analytics { processReport(report: Map<string, number>): void { // Iterates over domains, assumes all are valid strings for (const [domain, count] of report) { this.track(domain, count); // Tracks "undefined" as a domain } // Data is now permanently corrupted in analytics system }}The Failure Modes:
Each layer fails differently based on how it uses the violated invariant:
| Layer | Assumption | Failure Mode |
|---|---|---|
| UserService | email is non-null string | TypeError crash |
| NotificationService | email is valid format | Silent delivery failure, bounced emails |
| ReportGenerator | email contains @ symbol | undefined domain key, corrupted report |
| Analytics | domain is meaningful string | Corrupted analytics data, wrong business decisions |
| Downstream Systems | All data is valid | Unknown failures propagated indefinitely |
When the cascade hits, you debug backwards. You find the analytics data is wrong. You trace to the report. You find an 'undefined' domain. You trace to the email extraction. You find a null email. You trace to the repository... and finally discover a subclass caching optimization broke the invariant. The root cause is buried layers away from the symptom.
How do you recognize when an invariant violation is the root cause of a bug? Watch for these symptoms:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758
// SYMPTOM 1: "Impossible" state in logs// Log entry: "Processing order with total: -$523.47"// Invariant violated: Order.total >= 0 // SYMPTOM 2: Null errors on guaranteed fieldsfunction processPayment(order: Order): void { // Order.customer should NEVER be null (invariant) const email = order.customer.email; // TypeError: Cannot read 'email' of null} // SYMPTOM 3: Type confusionclass Amount { // INVARIANT: value is a number constructor(public value: number) {}} // Somewhere, invariant was violatedconst corrupted = new Amount("100" as any); // Actually a string! function sum(a: Amount, b: Amount): Amount { return new Amount(a.value + b.value); // "100" + 50 = "10050" (string concat!)} // SYMPTOM 4: Intermittent failuresclass Config { // INVARIANT: all keys have non-empty values get(key: string): string { return this.values.get(key)!; // Works 99% of time // Fails when FastConfig subclass allows empty values }} // SYMPTOM 5: Subtype-specific failuresfunction render(shapes: Shape[]): void { for (const shape of shapes) { shape.draw(); // Works for Circle, Rectangle // Crashes for BrokenShape subclass that has null renderer }} // SYMPTOM 6: Degradation over timeclass Counter { private count: number = 0; // INVARIANT: count accurately reflects calls increment(): void { this.count++; } // Bug: forgetful decrement decrement(): void { // Bug: missing check for count > 0 this.count--; // Can go negative, violating invariant } // After many operations: count is -47, makes no sense}Diagnostic Questions:
When you suspect an invariant violation, ask:
When debugging, temporarily add assertions that check invariants at method entry and exit. If an invariant is already violated when a method is called, you know the violation happened earlier. If it's valid on entry but violated on exit, the current method is the culprit.
Let's examine invariant violations that occur in real production systems:
Pattern 1: The Optimization Bypass
A developer optimizes for performance and bypasses validation:
1234567891011121314151617181920212223242526272829303132333435363738394041
class Product { private price: number; private quantity: number; // INVARIANT: price >= 0, quantity >= 0 constructor(price: number, quantity: number) { this.validateAndSet(price, quantity); } private validateAndSet(price: number, quantity: number): void { if (price < 0) throw new Error("Price cannot be negative"); if (quantity < 0) throw new Error("Quantity cannot be negative"); this.price = price; this.quantity = quantity; } update(price: number, quantity: number): void { this.validateAndSet(price, quantity); }} // "Optimized" bulk loader that skips validationclass ProductBulkLoader { // Direct database load for "performance" loadFromDatabase(rows: any[]): Product[] { return rows.map(row => { // VIOLATION: Bypasses validation for speed const product = Object.create(Product.prototype); product.price = row.price; // Could be -99.99! product.quantity = row.qty; // Could be -50! return product; }); }} // Data corruption propagates through inventory systemconst products = loader.loadFromDatabase(rawData);// Some products have negative prices/quantities// Inventory calculations go haywire// Orders get processed with wrong totalsPattern 2: The Deserialization Trap
Data loaded from storage, network, or files bypasses constructors:
1234567891011121314151617181920212223242526272829303132333435363738394041424344
class UserSession { private userId: string; private expiresAt: Date; private isRevoked: boolean; // INVARIANT: if isRevoked === true, session is invalid // INVARIANT: expiresAt is in the future when session is valid // INVARIANT: userId is never empty constructor(userId: string, durationMinutes: number) { if (!userId) throw new Error("User ID required"); if (durationMinutes <= 0) throw new Error("Duration must be positive"); this.userId = userId; this.expiresAt = new Date(Date.now() + durationMinutes * 60 * 1000); this.isRevoked = false; } isValid(): boolean { return !this.isRevoked && this.expiresAt > new Date(); }} // Deserialization from Redis cachefunction loadSessionFromCache(json: string): UserSession { // VIOLATION: Parse JSON directly, bypass constructor const data = JSON.parse(json); // Object.assign creates object with wrong prototype or state const session = Object.assign(new UserSession("temp", 1), data); // Problem 1: expiresAt is now a string, not a Date! // Problem 2: userId could be empty if data is corrupted // Problem 3: Invariants never checked return session;} // Later...const session = loadSessionFromCache(cached);if (session.isValid()) { // expiresAt is a string, comparison "2024-01-01" > Date fails weirdly grantAccess(session.userId);}Pattern 3: The Framework Override
A framework calls methods in unexpected order, breaking invariants:
12345678910111213141516171819202122232425262728293031323334353637
// UI Component with invariantclass PaymentForm { private amount: number = 0; private currency: string = "USD"; private isInitialized: boolean = false; // INVARIANT: If isInitialized, then amount and currency are set initialize(amount: number, currency: string): void { this.amount = amount; this.currency = currency; this.isInitialized = true; } submit(): PaymentRequest { if (!this.isInitialized) { throw new Error("Form not initialized"); } return { amount: this.amount, currency: this.currency }; }} // Framework subclass that breaks lifecycleclass ReactivePaymentForm extends PaymentForm { // Framework calls this BEFORE initialize() render(): string { // VIOLATION: Access state before invariant is established return `Pay ${this.amount} ${this.currency}`; // Shows "Pay 0 USD" } // Framework calls this on any state change onStateChange(): void { // May be called between partial state updates // Invariant temporarily violated during updates this.autoSubmit(); // Submits with wrong data! }}Pattern 4: The Concurrent Mutation
Multiple threads or async operations interleave, breaking invariants:
1234567891011121314151617181920212223242526272829303132333435363738394041
class BankAccount { private balance: number; // INVARIANT: balance >= 0 constructor(initial: number) { if (initial < 0) throw new Error("Negative balance"); this.balance = initial; } async withdraw(amount: number): Promise<boolean> { // Check invariant if (this.balance >= amount) { // TIME GAP: Another async call could run here! await this.log(`Withdrawing ${amount}`); // By now, another withdrawal might have occurred this.balance -= amount; // VIOLATION: balance could now be negative! return true; } return false; } private async log(message: string): Promise<void> { await fetch('/audit', { body: message, method: 'POST' }); }} // Two concurrent withdrawalsconst account = new BankAccount(100); // Both check passes at balance=100, both proceedPromise.all([ account.withdraw(80), // Checks: 100 >= 80 ✓, then pauses at await account.withdraw(60), // Checks: 100 >= 60 ✓, then pauses at await]); // After both complete: balance = 100 - 80 - 60 = -40// Invariant violated!The TOCTOU (Time Of Check To Time Of Use) bug is a classic invariant violation. The invariant is verified, then time passes, then the operation occurs. In that gap, another operation can make the invariant false. This is why concurrent code requires locks, atomic operations, or immutable data structures.
The earlier you detect an invariant violation, the easier it is to fix. Here are strategies for early detection:
1. Invariant Assertions
Add assertions that explicitly check invariants:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354
class TransactionLog { private entries: LogEntry[] = []; private totalCredits: number = 0; private totalDebits: number = 0; // INVARIANT: sum(credits) - sum(debits) === balance // INVARIANT: entries are chronologically ordered private checkInvariant(): void { // Sum all entries let computedCredits = 0; let computedDebits = 0; for (let i = 0; i < this.entries.length; i++) { const entry = this.entries[i]; // Check chronological order if (i > 0 && entry.timestamp < this.entries[i-1].timestamp) { throw new Error(`Invariant violated: entries out of order at index ${i}`); } if (entry.type === 'credit') computedCredits += entry.amount; else computedDebits += entry.amount; } // Check totals match if (computedCredits !== this.totalCredits) { throw new Error(`Invariant violated: totalCredits mismatch`); } if (computedDebits !== this.totalDebits) { throw new Error(`Invariant violated: totalDebits mismatch`); } } addEntry(entry: LogEntry): void { // Check invariant BEFORE operation (precondition) if (process.env.NODE_ENV === 'development') { this.checkInvariant(); } // Perform operation this.entries.push(entry); if (entry.type === 'credit') { this.totalCredits += entry.amount; } else { this.totalDebits += entry.amount; } // Check invariant AFTER operation (postcondition) if (process.env.NODE_ENV === 'development') { this.checkInvariant(); } }}2. Type System Enforcement
Use the type system to make violations impossible:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849
// Instead of a number that "should" be positive,// use a branded type that MUST be positive type PositiveNumber = number & { __brand: 'positive' }; function toPositive(n: number): PositiveNumber { if (n <= 0) { throw new Error("Number must be positive"); } return n as PositiveNumber;} class SafeAccount { private balance: PositiveNumber; constructor(initial: PositiveNumber) { this.balance = initial; // Type system ensures it's positive } // Can only deposit positive amounts deposit(amount: PositiveNumber): void { this.balance = toPositive(this.balance + amount); }} // Usage - type system catches violationsconst account = new SafeAccount(toPositive(100));// account.deposit(-50); // Compile error: number is not PositiveNumberaccount.deposit(toPositive(50)); // OK // Pattern: NonEmptyStringtype NonEmptyString = string & { __brand: 'nonempty' }; function toNonEmpty(s: string): NonEmptyString { if (!s || s.trim().length === 0) { throw new Error("String must be non-empty"); } return s as NonEmptyString;} class User { constructor( public readonly name: NonEmptyString, public readonly email: NonEmptyString ) {}} // new User("", "a@b.com"); // Compile error// new User(toNonEmpty(""), "a@b.com"); // Runtime error at toNonEmpty3. Property-Based Testing
Generate random test cases that verify invariants hold:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960
import * as fc from 'fast-check'; // Property: After any sequence of operations, invariants holddescribe('BinarySearchTree invariants', () => { it('maintains BST property after any inserts', () => { fc.assert( fc.property( fc.array(fc.integer()), (numbers) => { const tree = new BinarySearchTree<number>((a, b) => a - b); for (const n of numbers) { tree.insert(n); // After EVERY insert, verify BST property if (!isBSTPropertySatisfied(tree.root)) { return false; // Invariant violated! } } return true; } ) ); }); it('maintains size invariant', () => { fc.assert( fc.property( fc.array(fc.integer()), (numbers) => { const tree = new BinarySearchTree<number>((a, b) => a - b); const uniqueNumbers = new Set(numbers); for (const n of numbers) { tree.insert(n); } // INVARIANT: size equals number of unique inserted values return tree.size() === uniqueNumbers.size; } ) ); });}); function isBSTPropertySatisfied<T>( node: TreeNode<T> | null, comparator: (a: T, b: T) => number, min?: T, max?: T): boolean { if (!node) return true; if (min !== undefined && comparator(node.value, min) <= 0) return false; if (max !== undefined && comparator(node.value, max) >= 0) return false; return isBSTPropertySatisfied(node.left, comparator, min, node.value) && isBSTPropertySatisfied(node.right, comparator, node.value, max);}| Strategy | When It Detects | Pros | Cons |
|---|---|---|---|
| Constructor validation | At object creation | Prevents invalid objects from existing | Only catches construction-time violations |
| Method assertions | At method entry/exit | Catches violations as they happen | Performance overhead in production |
| Type system | At compile time | Zero runtime cost; impossible to bypass | Limited expressiveness in most languages |
| Property-based testing | In test suite | Finds edge cases humans miss | Only as good as your property definitions |
| Runtime monitoring | In production | Catches real-world violations | Reactive, not preventive; needs infrastructure |
Let's consolidate what we've learned about invariant violations:
What's Next:
Now that you understand how invariants get violated and the damage they cause, the final page covers Designing for Invariant Safety. You'll learn proactive design strategies that make invariant violations difficult or impossible, including immutability, encapsulation, factory patterns, and defensive programming techniques.
Prevention is always better than detection. The goal is to design classes where invariants can't be broken, not just classes where invariant breaks are noticed.
You now understand the anatomy of invariant violations—how they happen, how they propagate, and how to detect them. Invariant violations are among the most insidious bugs in software systems because they create objects that lie about what they are. Armed with this knowledge, you can diagnose these issues faster and design systems that resist them.