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 |
|
|
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
-
Substitutability: Any
PaymentProcessorcan be used interchangeably wherePaymentProcessoris expected. -
Client Safety: Clients can rely on the contract. If preconditions are met, postconditions are guaranteed.
-
Explicit Constraints: Instead of silently strengthening preconditions, processors expose their constraints through
getConstraints()andcanProcess(). -
Proper Error Handling: Constraint violations throw appropriate errors that clients can handle, rather than unexpected failures.
-
Factory Pattern: The factory selects the appropriate processor, keeping constraint logic out of client code.
-
Design by Contract: Clear documentation of preconditions and postconditions makes the contract explicit.
-
Defensive Programming: Clients can check
canProcess()before attempting payment, avoiding errors entirely.