Files
gruperly/.agents/skills/clean-code-principles/rules/solid-lsp-preconditions.md
2026-09-04 16:49:24 -03:00

8.2 KiB

id, title, category, priority, tags, related
id title category priority tags related
solid-lsp-preconditions SOLID - Liskov Substitution (Preconditions) solid-principles critical
SOLID
LSP
liskov-substitution
preconditions
postconditions
solid-lsp-contracts
core-fail-fast
solid-ocp-abstraction

Liskov Substitution Principle - Preconditions and Postconditions

Subtypes cannot strengthen preconditions (require more) or weaken postconditions (guarantee less) compared to their base types.

Bad Example

// Anti-pattern: Subclass strengthens preconditions and weakens postconditions

interface PaymentProcessor {
  // Contract: accepts any amount > 0, returns transaction ID
  processPayment(amount: number): Promise<string>;
}

class StandardPaymentProcessor implements PaymentProcessor {
  async processPayment(amount: number): Promise<string> {
    if (amount <= 0) {
      throw new Error('Amount must be positive');
    }
    // Process payment and return transaction ID
    return `TXN-${Date.now()}`;
  }
}

// Violates LSP: Strengthens preconditions
class PremiumPaymentProcessor implements PaymentProcessor {
  private readonly minimumAmount = 100; // Stronger precondition!
  private readonly maximumAmount = 10000; // Stronger precondition!

  async processPayment(amount: number): Promise<string> {
    if (amount <= 0) {
      throw new Error('Amount must be positive');
    }
    // Additional restrictions not in base contract
    if (amount < this.minimumAmount) {
      throw new Error(`Minimum amount is ${this.minimumAmount}`); // Violates LSP!
    }
    if (amount > this.maximumAmount) {
      throw new Error(`Maximum amount is ${this.maximumAmount}`); // Violates LSP!
    }
    return `PREM-TXN-${Date.now()}`;
  }
}

// Violates LSP: Weakens postconditions
class UnreliablePaymentProcessor implements PaymentProcessor {
  async processPayment(amount: number): Promise<string> {
    if (amount <= 0) {
      throw new Error('Amount must be positive');
    }

    // Sometimes returns null instead of transaction ID - weaker postcondition!
    if (Math.random() > 0.5) {
      return null as any; // Violates the guarantee of returning a string
    }

    return `TXN-${Date.now()}`;
  }
}

// Client code breaks when substituting implementations
async function checkout(processor: PaymentProcessor, amount: number): Promise<void> {
  const txnId = await processor.processPayment(amount);
  // Assumes txnId is always a valid string (as per contract)
  console.log(`Payment complete: ${txnId.toUpperCase()}`); // Crashes with null!
}

// Works with StandardPaymentProcessor
await checkout(new StandardPaymentProcessor(), 50);

// Fails with PremiumPaymentProcessor - amount too low
await checkout(new PremiumPaymentProcessor(), 50); // Error: Minimum amount is 100

// Fails with UnreliablePaymentProcessor - null txnId
await checkout(new UnreliablePaymentProcessor(), 50); // Error: Cannot read toUpperCase of null

Good Example

// Correct approach: Subtypes honor preconditions and postconditions

// Clear contract with documented preconditions and postconditions
interface PaymentProcessor {
  /**
   * Process a payment transaction.
   *
   * @precondition amount > 0
   * @postcondition returns a non-empty transaction ID string
   * @throws PaymentError if payment fails (not for invalid preconditions)
   */
  processPayment(amount: number): Promise<PaymentResult>;

  /**
   * Check if this processor can handle the given amount.
   * Allows clients to check before attempting payment.
   */
  canProcess(amount: number): boolean;

  /**
   * Get the constraints of this processor.
   */
  getConstraints(): ProcessorConstraints;
}

interface PaymentResult {
  readonly transactionId: string;
  readonly processedAt: Date;
  readonly amount: number;
  readonly status: 'success' | 'pending';
}

interface ProcessorConstraints {
  readonly minAmount: number;
  readonly maxAmount: number;
  readonly supportedCurrencies: string[];
}

// Base implementation with standard constraints
class StandardPaymentProcessor implements PaymentProcessor {
  private readonly constraints: ProcessorConstraints = {
    minAmount: 0.01,
    maxAmount: Infinity,
    supportedCurrencies: ['USD', 'EUR', 'GBP']
  };

  getConstraints(): ProcessorConstraints {
    return this.constraints;
  }

  canProcess(amount: number): boolean {
    return amount >= this.constraints.minAmount &&
           amount <= this.constraints.maxAmount;
  }

  async processPayment(amount: number): Promise<PaymentResult> {
    // Precondition check (same as interface contract)
    if (amount <= 0) {
      throw new InvalidAmountError('Amount must be positive');
    }

    // Implementation
    const transactionId = `TXN-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;

    // Postcondition: always returns valid PaymentResult
    return {
      transactionId,
      processedAt: new Date(),
      amount,
      status: 'success'
    };
  }
}

// Premium processor with different constraints but same contract behavior
class PremiumPaymentProcessor implements PaymentProcessor {
  private readonly constraints: ProcessorConstraints = {
    minAmount: 100,
    maxAmount: 100000,
    supportedCurrencies: ['USD', 'EUR', 'GBP', 'JPY', 'CHF']
  };

  getConstraints(): ProcessorConstraints {
    return this.constraints;
  }

  // Clients can check constraints before calling processPayment
  canProcess(amount: number): boolean {
    return amount >= this.constraints.minAmount &&
           amount <= this.constraints.maxAmount;
  }

  async processPayment(amount: number): Promise<PaymentResult> {
    // Same precondition as interface - not strengthened
    if (amount <= 0) {
      throw new InvalidAmountError('Amount must be positive');
    }

    // Business logic can still reject, but with proper error handling
    if (!this.canProcess(amount)) {
      throw new PaymentError(
        `Amount ${amount} outside processor range [${this.constraints.minAmount}, ${this.constraints.maxAmount}]`
      );
    }

    // Premium processing logic
    const transactionId = `PREM-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;

    // Postcondition: always returns valid PaymentResult (same guarantee)
    return {
      transactionId,
      processedAt: new Date(),
      amount,
      status: 'success'
    };
  }
}

// Factory that selects appropriate processor
class PaymentProcessorFactory {
  private processors: PaymentProcessor[] = [
    new StandardPaymentProcessor(),
    new PremiumPaymentProcessor()
  ];

  getProcessorFor(amount: number): PaymentProcessor {
    const suitable = this.processors.find(p => p.canProcess(amount));
    if (!suitable) {
      throw new NoSuitableProcessorError(amount);
    }
    return suitable;
  }
}

// Client code works correctly with any processor
async function checkout(amount: number, factory: PaymentProcessorFactory): Promise<void> {
  const processor = factory.getProcessorFor(amount);
  const result = await processor.processPayment(amount);

  // Postcondition guarantees result is valid
  console.log(`Payment complete: ${result.transactionId}`);
  console.log(`Processed at: ${result.processedAt.toISOString()}`);
}

// Usage
const factory = new PaymentProcessorFactory();

await checkout(50, factory);    // Uses StandardPaymentProcessor
await checkout(500, factory);   // Uses PremiumPaymentProcessor
await checkout(5000, factory);  // Uses PremiumPaymentProcessor

Why

  1. Substitutability: Any PaymentProcessor can be used interchangeably where PaymentProcessor is expected.

  2. Client Safety: Clients can rely on the contract. If preconditions are met, postconditions are guaranteed.

  3. Explicit Constraints: Instead of silently strengthening preconditions, processors expose their constraints through getConstraints() and canProcess().

  4. Proper Error Handling: Constraint violations throw appropriate errors that clients can handle, rather than unexpected failures.

  5. Factory Pattern: The factory selects the appropriate processor, keeping constraint logic out of client code.

  6. Design by Contract: Clear documentation of preconditions and postconditions makes the contract explicit.

  7. Defensive Programming: Clients can check canProcess() before attempting payment, avoiding errors entirely.