Loading learning content...
Consider two approaches to building a dam:
Approach A: Build the dam, then hire guards to watch for leaks 24/7, ready to patch holes as they appear.
Approach B: Engineer the dam with multiple redundant barriers, so leaks are structurally impossible.
Which approach would you choose for a dam holding back a billion gallons of water?
This is exactly the choice you face with invariants.
You can validate inputs, add assertions, run tests, monitor production—all forms of leak-watching. Or you can design your classes so that invalid states are structurally impossible. The object literally cannot exist in a broken state because the code won't compile, the constructor won't succeed, or the operation can't be expressed.
This page teaches you to engineer invariant safety into your designs. Not just checking for problems, but making problems impossible.
By the end of this page, you will master techniques for making invariants structurally unbreakable: immutability, encapsulation strategies, factory patterns, defensive programming, and design patterns that guarantee invariant safety by construction.
The most powerful technique for protecting invariants is immutability. If an object's state cannot change after construction, invariants established at construction time remain true forever.
Why immutability eliminates invariant violations:
The object is frozen in a valid state for its entire lifetime.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061
// IMMUTABLE: Money class with unbreakable invariantsclass Money { // INVARIANT: amount >= 0 // INVARIANT: currency is a valid 3-letter code private constructor( private readonly amount: number, private readonly currency: string ) {} // Factory method enforces invariants static create(amount: number, currency: string): Money { if (amount < 0) { throw new Error("Amount cannot be negative"); } if (!/^[A-Z]{3}$/.test(currency)) { throw new Error("Currency must be a 3-letter code"); } return new Money(amount, currency); } // All operations return NEW objects - never mutate add(other: Money): Money { if (this.currency !== other.currency) { throw new Error("Cannot add different currencies"); } // New object, validated through factory return Money.create(this.amount + other.amount, this.currency); } subtract(other: Money): Money { if (this.currency !== other.currency) { throw new Error("Cannot subtract different currencies"); } // This will throw if result is negative - invariant protected return Money.create(this.amount - other.amount, this.currency); } multiply(factor: number): Money { if (factor < 0) { throw new Error("Cannot multiply by negative factor"); } return Money.create(this.amount * factor, this.currency); } getAmount(): number { return this.amount; } getCurrency(): string { return this.currency; }} // Usage - invariants are ALWAYS true for any Money instanceconst price = Money.create(100, "USD");const tax = Money.create(8, "USD");const total = price.add(tax); // New Money object, guaranteed valid // Cannot create invalid Money// Money.create(-50, "USD"); // Throws: Amount cannot be negative// Money.create(100, "USDOLLAR"); // Throws: must be 3-letter code // Cannot mutate existing Money// price.amount = -100; // Property 'amount' is private and readonly// (price as any).amount = -100; // Still works but you're explicitly breaking rulesImmutable Collections:
Standard collections are mutable, which creates invariant risks. Use immutable alternatives:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051
// DANGER: Mutable internal stateclass ConfigBad { private settings: Map<string, string>; constructor(initial: Map<string, string>) { this.settings = initial; // Reference to external mutable object! } get(key: string): string | undefined { return this.settings.get(key); }} const map = new Map([["debug", "true"]]);const config = new ConfigBad(map);map.set("debug", "false"); // Mutates config's internal state! // SAFE: Immutable designclass ConfigGood { private readonly settings: ReadonlyMap<string, string>; private constructor(settings: Map<string, string>) { // Defensive copy - sealed away from external mutation this.settings = new Map(settings); } static create(initial: Map<string, string>): ConfigGood { // Additional validation could go here return new ConfigGood(initial); } get(key: string): string | undefined { return this.settings.get(key); } // Returns NEW config, doesn't mutate with(key: string, value: string): ConfigGood { const newSettings = new Map(this.settings); newSettings.set(key, value); return new ConfigGood(newSettings); }} // External map changes don't affect configconst map2 = new Map([["debug", "true"]]);const goodConfig = ConfigGood.create(map2);map2.set("debug", "false"); // No effect on goodConfig // Modifications return new instancesconst prodConfig = goodConfig.with("debug", "false");// goodConfig still has debug=true, prodConfig has debug=falseThe combination of immutable fields, private constructors, and factory methods is the gold standard for invariant protection. Factory methods enforce invariants, private constructors prevent bypass, and immutable fields prevent post-construction violation. This pattern eliminates entire categories of bugs.
When full immutability isn't practical, strong encapsulation provides protection. The key principle: never expose internal state that could be modified to break invariants.
Strategy 1: Private Fields with Controlled Accessors
12345678910111213141516171819202122232425262728293031323334353637383940414243
class Temperature { // INVARIANT: kelvin >= 0 (absolute zero) private kelvin: number; constructor(kelvin: number) { this.setKelvin(kelvin); // Use setter to validate } // Getter - safe to expose getKelvin(): number { return this.kelvin; } getCelsius(): number { return this.kelvin - 273.15; } getFahrenheit(): number { return (this.kelvin - 273.15) * 9/5 + 32; } // Controlled setter - enforces invariant setKelvin(kelvin: number): void { if (kelvin < 0) { throw new Error("Temperature cannot be below absolute zero"); } this.kelvin = kelvin; } setCelsius(celsius: number): void { this.setKelvin(celsius + 273.15); // Invariant enforced through setKelvin } setFahrenheit(fahrenheit: number): void { const celsius = (fahrenheit - 32) * 5/9; this.setCelsius(celsius); // Chain through validated path }} // All modification paths go through validationconst temp = new Temperature(300); // ~27°Ctemp.setCelsius(-280); // Throws: would be below absolute zerotemp.setFahrenheit(-500); // Throws: would be below absolute zeroStrategy 2: Defensive Copies for Collections
Never return references to internal mutable state:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546
class Team { private members: string[]; // INVARIANT: members has no duplicates // INVARIANT: members is sorted alphabetically constructor(initialMembers: string[]) { // Defensive copy on input this.members = [...new Set(initialMembers)].sort(); } // BAD: Returns reference to internal array getMembersBad(): string[] { return this.members; // External code can push(), mutate, break invariant } // GOOD: Returns defensive copy getMembersGood(): string[] { return [...this.members]; // New array, mutations don't affect internal } // BETTER: Returns immutable view getMembersBetter(): readonly string[] { return this.members; // TypeScript won't allow mutations (at compile time) } // BEST: Use immutable data structure getMembersBest(): ReadonlyArray<string> { return Object.freeze([...this.members]); // Runtime immutable } addMember(name: string): void { if (this.members.includes(name)) { return; // Maintain no-duplicates invariant } this.members.push(name); this.members.sort(); // Maintain sorted invariant }} // External code cannot break invariantsconst team = new Team(["Alice", "Bob"]);const members = team.getMembersGood();members.push("ZZZ"); // Only mutates the copymembers.push("Alice"); // Only mutates the copy// team.getMembersGood() still returns ["Alice", "Bob"]Strategy 3: Encapsulating Related Fields
When invariants involve relationships between fields, encapsulate them together:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970
// BAD: Separate fields that must be synchronizedclass RectangleBad { width: number; height: number; area: number; // INVARIANT: area === width * height // Client can break invariant by modifying width without updating area} // GOOD: Encapsulate the relationshipclass RectangleGood { private _width: number; private _height: number; // INVARIANT: getArea() === width * height (always computed, never stale) constructor(width: number, height: number) { if (width <= 0 || height <= 0) { throw new Error("Dimensions must be positive"); } this._width = width; this._height = height; } get width(): number { return this._width; } get height(): number { return this._height; } // Computed property - invariant impossible to violate get area(): number { return this._width * this._height; } setDimensions(width: number, height: number): void { if (width <= 0 || height <= 0) { throw new Error("Dimensions must be positive"); } this._width = width; this._height = height; // area is always correct because it's computed }} // EVEN BETTER: Immutable with computed valuesclass RectangleBest { readonly width: number; readonly height: number; constructor(width: number, height: number) { if (width <= 0 || height <= 0) { throw new Error("Dimensions must be positive"); } this.width = width; this.height = height; } get area(): number { return this.width * this.height; // Always consistent } get perimeter(): number { return 2 * (this.width + this.height); // Always consistent } scale(factor: number): RectangleBest { if (factor <= 0) { throw new Error("Scale factor must be positive"); } return new RectangleBest(this.width * factor, this.height * factor); }}Computed properties (like area = width * height) can never be stale because they're calculated on demand. Cached values must be invalidated on mutation, which is error-prone. Prefer computed properties unless performance requires caching—and if you cache, make invalidation automatic and impossible to forget.
Constructors have a limitation: they can only succeed or throw. Sometimes you need more flexibility in handling invalid inputs while still guaranteeing invariants. Factory methods and patterns provide this flexibility.
Pattern 1: Static Factory Methods
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253
class EmailAddress { // INVARIANT: address is a valid email format private constructor(private readonly address: string) {} // Factory with validation static create(address: string): EmailAddress | null { if (!EmailAddress.isValid(address)) { return null; // Graceful failure } return new EmailAddress(address); } // Factory that throws static createOrThrow(address: string): EmailAddress { if (!EmailAddress.isValid(address)) { throw new Error(`Invalid email: ${address}`); } return new EmailAddress(address); } // Factory with Result type static parse(address: string): Result<EmailAddress, string> { if (!address || address.trim().length === 0) { return { ok: false, error: "Email address is required" }; } if (!EmailAddress.isValid(address)) { return { ok: false, error: `Invalid email format: ${address}` }; } return { ok: true, value: new EmailAddress(address) }; } private static isValid(address: string): boolean { const pattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; return pattern.test(address); } getAddress(): string { return this.address; } getDomain(): string { return this.address.split('@')[1]; }} type Result<T, E> = { ok: true; value: T } | { ok: false; error: E }; // Usage patternsconst email1 = EmailAddress.create("user@example.com"); // EmailAddress or nullconst email2 = EmailAddress.createOrThrow("user@example.com"); // EmailAddress or throwsconst result = EmailAddress.parse("user@example.com"); // Result with error details if (result.ok) { console.log(result.value.getDomain());} else { console.error(result.error);}Pattern 2: Builder Pattern for Complex Objects
When objects have many interrelated invariants, builders help ensure they're all satisfied:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101
class HttpRequest { // INVARIANTS: // - method is a valid HTTP method // - url is a valid URL // - if method is GET/HEAD, body is undefined // - headers have no duplicate keys // - timeout is positive if set private constructor( readonly method: HttpMethod, readonly url: string, readonly headers: ReadonlyMap<string, string>, readonly body: string | undefined, readonly timeout: number | undefined ) {} static builder(): HttpRequestBuilder { return new HttpRequestBuilder(); }} type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'HEAD'; class HttpRequestBuilder { private method: HttpMethod = 'GET'; private url: string = ''; private headers: Map<string, string> = new Map(); private body: string | undefined; private timeout: number | undefined; setMethod(method: HttpMethod): this { this.method = method; return this; } setUrl(url: string): this { this.url = url; return this; } addHeader(name: string, value: string): this { // Normalize header names for consistency this.headers.set(name.toLowerCase(), value); return this; } setBody(body: string): this { this.body = body; return this; } setTimeout(ms: number): this { if (ms <= 0) { throw new Error("Timeout must be positive"); } this.timeout = ms; return this; } build(): HttpRequest { // All invariants validated at build time if (!this.url) { throw new Error("URL is required"); } try { new URL(this.url); } catch { throw new Error(`Invalid URL: ${this.url}`); } if ((this.method === 'GET' || this.method === 'HEAD') && this.body) { throw new Error(`${this.method} requests cannot have a body`); } // Return immutable request - all invariants guaranteed return new (HttpRequest as any)( this.method, this.url, new Map(this.headers), // Defensive copy this.body, this.timeout ); }} // Usage - invariants enforced at build()const request = HttpRequest.builder() .setMethod('POST') .setUrl('https://api.example.com/data') .addHeader('Content-Type', 'application/json') .setBody(JSON.stringify({ key: 'value' })) .setTimeout(5000) .build(); // This would throw at build()// HttpRequest.builder()// .setMethod('GET')// .setUrl('https://api.example.com')// .setBody('data') // GET can't have body// .build();Pattern 3: Type-Safe Builders with Phantom Types
For compile-time enforcement of building steps:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354
// Phantom types to track what's been settype HasUrl = { readonly _hasUrl: unique symbol };type HasMethod = { readonly _hasMethod: unique symbol };type Complete = HasUrl & HasMethod; class TypeSafeRequestBuilder<State = {}> { private method?: HttpMethod; private url?: string; private constructor() {} static create(): TypeSafeRequestBuilder<{}> { return new TypeSafeRequestBuilder(); } // These methods return builders with updated phantom types setUrl(url: string): TypeSafeRequestBuilder<State & HasUrl> { const next = new TypeSafeRequestBuilder<State & HasUrl>(); Object.assign(next, this); next.url = url; return next; } setMethod(method: HttpMethod): TypeSafeRequestBuilder<State & HasMethod> { const next = new TypeSafeRequestBuilder<State & HasMethod>(); Object.assign(next, this); next.method = method; return next; } // build() is only available when State extends Complete build(this: TypeSafeRequestBuilder<Complete>): SimpleRequest { return new SimpleRequest(this.method!, this.url!); }} class SimpleRequest { constructor( readonly method: HttpMethod, readonly url: string ) {}} // Compile-time enforcementconst builder1 = TypeSafeRequestBuilder.create() .setUrl('https://example.com') .setMethod('GET'); const request1 = builder1.build(); // OK - Complete const builder2 = TypeSafeRequestBuilder.create() .setUrl('https://example.com'); // const request2 = builder2.build(); // Compile error! Missing setMethodUse static factory methods for simple validation. Use builders when many optional parameters have complex interactions. Use phantom types when you need compile-time guarantees about construction order. The goal is always the same: make it impossible to create invalid objects.
When you can't prevent bad inputs entirely, defensive programming ensures they're caught early and loudly.
Technique 1: Fail Fast
The moment an invariant would be violated, fail immediately with a clear error:
1234567891011121314151617181920212223242526272829303132333435363738394041
// BAD: Proceeds with invalid data, fails later mysteriouslyfunction processOrderBad(order: Order): void { // No validation - null/invalid items cause cryptic errors later for (const item of order.items) { const price = item.product.price; // NPE if product is null const qty = item.quantity; // Wrong calculations if negative // ... processing continues with wrong data }} // GOOD: Validate immediately, fail with clear contextfunction processOrderGood(order: Order): void { // Precondition checks - fail fast with clear messages if (!order) { throw new Error("processOrder: order is required"); } if (!order.items || order.items.length === 0) { throw new Error(`processOrder: order ${order.id} has no items`); } for (let i = 0; i < order.items.length; i++) { const item = order.items[i]; if (!item.product) { throw new Error(`processOrder: item at index ${i} has no product`); } if (item.quantity <= 0) { throw new Error(`processOrder: item ${i} has invalid quantity: ${item.quantity}`); } if (item.product.price < 0) { throw new Error(`processOrder: product ${item.product.id} has negative price`); } } // Now we know all invariants are satisfied // Processing can proceed safely for (const item of order.items) { // No need for null checks here - already validated const total = item.product.price * item.quantity; // ... }}Technique 2: Assertion Libraries
Centralize your checks with an assertion utility:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465
// Reusable assertion utilitiesconst Invariant = { require(condition: boolean, message: string): asserts condition { if (!condition) { throw new InvariantViolationError(message); } }, notNull<T>(value: T | null | undefined, name: string): asserts value is T { if (value === null || value === undefined) { throw new InvariantViolationError(`${name} must not be null`); } }, positive(value: number, name: string): void { if (value <= 0) { throw new InvariantViolationError(`${name} must be positive, got ${value}`); } }, inRange(value: number, min: number, max: number, name: string): void { if (value < min || value > max) { throw new InvariantViolationError( `${name} must be between ${min} and ${max}, got ${value}` ); } }, nonEmpty<T>(array: T[], name: string): void { if (!array || array.length === 0) { throw new InvariantViolationError(`${name} must not be empty`); } }, matches(value: string, pattern: RegExp, name: string): void { if (!pattern.test(value)) { throw new InvariantViolationError( `${name} must match ${pattern}, got '${value}'` ); } }}; class InvariantViolationError extends Error { constructor(message: string) { super(`Invariant violated: ${message}`); this.name = 'InvariantViolationError'; }} // Clean, consistent invariant checkingclass BankTransfer { constructor( private readonly fromAccount: string, private readonly toAccount: string, private readonly amount: number, private readonly currency: string ) { Invariant.notNull(fromAccount, 'fromAccount'); Invariant.notNull(toAccount, 'toAccount'); Invariant.require(fromAccount !== toAccount, 'Cannot transfer to same account'); Invariant.positive(amount, 'amount'); Invariant.matches(currency, /^[A-Z]{3}$/, 'currency'); }}Technique 3: Design by Contract
Document and verify preconditions, postconditions, and invariants explicitly:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687
class Stack<T> { private items: T[] = []; private readonly capacity: number; // CLASS INVARIANT: 0 <= items.length <= capacity constructor(capacity: number) { Invariant.positive(capacity, 'capacity'); this.capacity = capacity; this.checkInvariant(); } /** * @precondition !this.isFull() * @postcondition this.size() === old(this.size()) + 1 * @postcondition this.peek() === item */ push(item: T): void { // Check precondition Invariant.require(!this.isFull(), 'Stack is full'); const oldSize = this.size(); // Operation this.items.push(item); // Check postconditions Invariant.require( this.size() === oldSize + 1, `Size should have increased by 1` ); Invariant.require( this.peek() === item, 'Pushed item should be on top' ); // Check invariant this.checkInvariant(); } /** * @precondition !this.isEmpty() * @postcondition this.size() === old(this.size()) - 1 * @returns The top item */ pop(): T { // Check precondition Invariant.require(!this.isEmpty(), 'Stack is empty'); const oldSize = this.size(); // Operation const item = this.items.pop()!; // Check postcondition Invariant.require( this.size() === oldSize - 1, 'Size should have decreased by 1' ); // Check invariant this.checkInvariant(); return item; } peek(): T | undefined { return this.items[this.items.length - 1]; } size(): number { return this.items.length; } isEmpty(): boolean { return this.items.length === 0; } isFull(): boolean { return this.items.length >= this.capacity; } private checkInvariant(): void { Invariant.require( this.items.length >= 0 && this.items.length <= this.capacity, `Invariant violated: length=${this.items.length}, capacity=${this.capacity}` ); }}In development, aggressive invariant checking catches bugs early. In production, you may want to disable some checks for performance. Use environment flags: if (process.env.NODE_ENV !== 'production') { checkInvariant(); }. But be careful—disabling checks means violations might go undetected.
Several design patterns inherently support invariant safety. Use these patterns when invariant protection is a primary concern.
Pattern 1: Value Objects
Immutable objects representing concepts defined by their attributes:
1234567891011121314151617181920212223242526272829303132333435
// Value objects are immutable and validate on creationclass DateRange { private constructor( readonly start: Date, readonly end: Date ) {} // INVARIANT: start <= end static create(start: Date, end: Date): DateRange { if (start > end) { throw new Error('Start date cannot be after end date'); } return new DateRange(new Date(start), new Date(end)); } contains(date: Date): boolean { return date >= this.start && date <= this.end; } overlaps(other: DateRange): boolean { return this.start <= other.end && this.end >= other.start; } // New object, invariant guaranteed extendTo(newEnd: Date): DateRange { return DateRange.create(this.start, newEnd); } // Value equality equals(other: DateRange): boolean { return this.start.getTime() === other.start.getTime() && this.end.getTime() === other.end.getTime(); }}Pattern 2: Newtype Pattern
Wrap primitive types to add invariants:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556
// Raw strings/numbers are dangerous - no validationfunction processEmailBad(email: string): void { // No guarantee email is valid - could be "hello world"} // Newtype wrapping adds compile-time safetyclass Email { private constructor(private readonly value: string) {} static create(raw: string): Email | null { if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(raw)) { return null; } return new Email(raw.toLowerCase()); } static createOrThrow(raw: string): Email { const email = Email.create(raw); if (!email) throw new Error(`Invalid email: ${raw}`); return email; } toString(): string { return this.value; } getDomain(): string { return this.value.split('@')[1]; }} class UserId { private constructor(private readonly value: string) {} static create(raw: string): UserId | null { if (!/^[a-z0-9]{8,32}$/.test(raw)) { return null; } return new UserId(raw); } toString(): string { return this.value; }} // Type system ensures only valid values are usedfunction sendEmail(to: Email, subject: string): void { // 'to' is guaranteed valid - invariant enforced by type console.log(`Sending to ${to.toString()}: ${subject}`);} function loadUser(id: UserId): User { // 'id' is guaranteed valid format return database.findUser(id.toString());} // Can't pass raw strings// sendEmail("not-an-email", "Hello"); // Compile error// loadUser("abc"); // Compile error const email = Email.createOrThrow("user@example.com");sendEmail(email, "Welcome!"); // OK - type system ensures validityPattern 3: State Pattern for State-Dependent Invariants
When invariants depend on object state, use the State pattern:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081
// Different states have different invariants// Use separate types to enforce them interface OrderState { getStatus(): OrderStatus;} // INVARIANT: Draft orders can be modifiedclass DraftOrder implements OrderState { constructor(private items: OrderItem[] = []) {} getStatus(): OrderStatus { return 'draft'; } addItem(item: OrderItem): void { this.items.push(item); } removeItem(index: number): void { this.items.splice(index, 1); } // Transition to next state submit(): SubmittedOrder { if (this.items.length === 0) { throw new Error('Cannot submit empty order'); } return new SubmittedOrder([...this.items]); }} // INVARIANT: Submitted orders cannot be modifiedclass SubmittedOrder implements OrderState { constructor(private readonly items: readonly OrderItem[]) {} getStatus(): OrderStatus { return 'submitted'; } getItems(): readonly OrderItem[] { return this.items; } // Methods that would modify are simply not present // addItem() doesn't exist - invariant enforced by type system // Transition to next state ship(trackingNumber: string): ShippedOrder { return new ShippedOrder(this.items, trackingNumber); } cancel(): CancelledOrder { return new CancelledOrder(this.items, 'Customer requested'); }} // INVARIANT: Shipped orders have tracking infoclass ShippedOrder implements OrderState { constructor( private readonly items: readonly OrderItem[], private readonly trackingNumber: string ) { if (!trackingNumber) { throw new Error('Tracking number required for shipped orders'); } } getStatus(): OrderStatus { return 'shipped'; } getTrackingNumber(): string { return this.trackingNumber; } deliver(): DeliveredOrder { return new DeliveredOrder(this.items, this.trackingNumber, new Date()); }} type OrderStatus = 'draft' | 'submitted' | 'shipped' | 'delivered' | 'cancelled'; // State-specific operations are only available on appropriate typesfunction processSubmission(order: SubmittedOrder): void { // Type system guarantees order is submitted and has items const items = order.getItems(); // ...} // function processSubmission(order: DraftOrder): void { }// // DraftOrder doesn't have getItems() that returns readonly - won't compile| Pattern | Best For | Key Benefit |
|---|---|---|
| Value Objects | Domain concepts (Money, Email, DateRange) | Immutability prevents all post-construction violations |
| Newtype | Wrapped primitives with constraints | Type system prevents passing raw invalid values |
| Builder | Complex objects with many invariant relationships | Centralized validation at build time |
| Factory Method | Objects needing validation before creation | Control over construction, multiple creation strategies |
| State Pattern | Objects with state-dependent invariants | Different types for different states; compile-time safety |
Let's consolidate the strategies for designing invariant-safe classes:
The Hierarchy of Protection:
From strongest to weakest guarantee:
Aim for the top of this hierarchy. The higher up you can enforce invariants, the safer your code will be.
You've completed the module on Invariants in Inheritance. You now understand what invariants are, why subclasses must preserve them, how violations manifest and cascade, and most importantly, how to design systems where invariant violations are difficult or impossible. This knowledge is fundamental to correct inheritance hierarchies and true LSP compliance.
Where to Go From Here:
With a solid understanding of invariants, you're prepared to tackle the next module on Detecting and Fixing LSP Violations. You'll learn systematic approaches for finding LSP violations in existing codebases and refactoring strategies that restore behavioral substitutability—often by redesigning invariant-breaking inheritance into safer alternatives like composition.
Remember: every class you design is a promise to every piece of code that uses it. Invariants are those promises made explicit. Honor them, protect them, and make them impossible to break.