After tackling the Single Responsibility Principle and Open-Closed Principle, it’s time to explore the Liskov Substitution Principle (LSP) and how it can help us write better code.
At first glance, it’s straightforward: if we have a component X conforming to protocol A, and another component (Y) conforming to the same protocol, they should be freely interchangeable – the application shouldn’t notice any difference when we swap one component for the other.
But is it really that simple? Is merely making a component conform to a protocol enough to treat it like a Lego piece you can freely snap into place? What if the component has side effects, changes the application state in unrelated areas, or throws exceptions when certain parameters are missing?
You see, LSP isn’t just about protocol conformance or interchangeability – it’s primarily about trust. It’s about ensuring the components you implement not only conform to the desired API, but also deliver exactly what they advertise. Without unexpected side effects, performance issues, memory leaks, or crashes.
Like in previous posts in the S.O.L.I.D. series, let’s explore a real-world example of how seemingly conforming to LSP can get us into trouble. And how doing it properly can make our iOS codebases more robust and testable. Without further ado, let’s dive in!
Building a LSP-friendly Payment Service
Let’s start with a concept we all know: a simple payment service. Its only goal is to process a payment – initially through a credit card and bank transfer. To facilitate that, we can define a simple API that accepts the transaction amount and chosen payment method:
public protocol PaymentService: Sendable {
/// Makes a payment of the specified amount using the given payment processor type.
/// - Parameters:
/// - amount: The amount to pay.
/// - paymentType: The type of payment.
/// - Returns: `true` if the payment was successful, otherwise `false`.
/// - Throws: An error if the payment could not be completed.
func makePayment(amount: Double, paymentType: PaymentType) async throws -> Bool
}To make the service extendible, it’s good to move all the processing logic into a separate component – a PaymentProcessor:
public protocol PaymentProcessor: Sendable {
/// The type of payment processor.
var type: PaymentProcessorType { get }
/// Processes a payment of the specified amount asynchronously.
/// - Parameter amount: The amount to process.
/// - Returns: `true` if the payment was successful, otherwise `false`.
/// - Throws: An error if the payment could not be processed.
func processPayment(amount: Double) async throws -> Bool
/// Verifies if sufficient funds are available for the specified amount asynchronously.
/// - Parameter amount: The amount to verify.
/// - Returns: `true` if funds are sufficient, otherwise `false`.
/// - Throws: An error if the funds could not be verified.
func verifyFunds(amount: Double) async throws -> Bool
}Now, let’s implement the logic for processing a payment method:
public struct CreditCardProcessor: PaymentProcessor {
...
/// - SeeAlso: `PaymentProcessor.type`
public let type = PaymentProcessorType.creditCard
/// - SeeAlso: `PaymentProcessor.processPayment(amount:)`
public func processPayment(amount: Double) async throws -> Bool {
// Process payment logic:
try await makeCreditCardPayment(amount: amount)
...
print("Processing $\\(amount) via credit card succeeded")
return true
}
/// - SeeAlso: `PaymentProcessor.verifyFunds(amount:)`
public func verifyFunds(amount: Double) async throws -> Bool {
// Verify credit limit:
try await verifyCreditCardPayment(amount: amount)
...
print("Verifying credit card funds: $\\(amount)")
return true
}
}Looks good, right? The solution seems to be:
- Readable: The responsibilities are clearly distributed between the main components. The processors handle the chosen payment method’s logic, while the service orchestrates the payment. Nice!
- Scalable: We can easily add new payment methods and integrate them with the service. Apple Pay, or maybe crypto payments? Pick your poison!
- Testable: The
PaymentProcessorabstraction allows us to conveniently implement fakes/mocks we could use in tests to verify interactions between the service and the processors. - LSP-friendly: Let’s imagine we implemented
CreditCardProcessorin a hurry, incurring tech debt. If the business gave us the green light, we could easily implement a new shiny, debt-free processor to replace the legacy version. And the rest of the application would not even notice!
Or would it?
Adding a New Payment Method
As expected, new payment methods were added quickly, starting with Crypto payments:
public struct CryptoPaymentProcessor: PaymentProcessor {
...
/// - SeeAlso: PaymentProcessor.type
public let type = PaymentProcessorType.crypto
/// - SeeAlso: PaymentProcessor.processPayment(amount:)
public func processPayment(amount: Double) async throws -> Bool {
// Process crypto payment logic
try await makeCryptoPayment(amount: amount)
...
print("Processing $\\(amount) via crypto succeeded")
return true
}
/// - SeeAlso: PaymentProcessor.verifyFunds(amount:)
public func verifyFunds(amount: Double) async throws -> Bool {
// Check crypto wallet balance
try await verifyCryptoFunds(amount: amount)
...
print("Verifying crypto funds: $\\(amount)")
return true
}
}It was integrated into the PaymentService without issues and quickly gained popularity among users.
However, there are no roses without thorns. Crypto payments are generally not well-regulated. In some countries, even owning crypto assets is illegal, let alone using them for payments. To stay compliant, the business introduced an additional check before approving crypto payments. Based on the user’s physical location, the service would determine if the local jurisdiction allows for the payment to be processed. If not, an error will be thrown.
public func verifyFunds(amount: Double) async throws -> Bool {
// Check if crypto payments are supported in the user's location
let country = try await locationService.localizeUserCountry()
guard cryptoSupportChecker.isCryptoSupported(in: country) else {
throw PaymentError.cryptoNotSupportedInLocation
}
// Check crypto wallet balance
try await verifyCryptoFunds(amount: amount)
print("Verifying crypto funds: $\\(amount)")
return true
}where:
public protocol LocationService: Sendable {
/// Asynchronously determines the user's current country.
///
/// - Returns: The user's current `Country`.
/// - Throws: An error if localization fails.
func localizeUserCountry() async throws -> Country
}and:
public protocol CryptoSupportChecker: Sendable {
/// Checks if cryptocurrency payments are supported in the specified country.
///
/// - Parameter country: The country to check for crypto support.
/// - Returns: `true` if crypto payments are supported in the given country, `false` otherwise.
func isCryptoSupported(in country: Country) -> Bool
}As expected, accurately determining a user’s true location is not trivial. Simply checking the IP address isn’t sufficient since users can easily mask it with a VPN. To maintain compliance and practice due diligence, the app would need to employ multiple verification methods – from detecting nearby WiFi networks to identifying VPN usage.
Fortunately, there are out-of-the-box solutions to handle these verification tasks. On the flip side, they are expensive and can cut into the company profit margins. In addition, the location checks often produce false positives and can take a while. What’s even worse, the cost must be paid for each location check upfront, whether or not the user completes the payment. Over time, these costs can become substantial…
A Proper Refactoring with LSP in Mind
The CryptoPaymentProcessor is compliant and attracts many new customers, but it can be improved. Sounds like famous last words…
First of all, the processor should support more blockchains and allow for faster future integrations. This makes perfect sense. The crypto space is incredibly dynamic and surges in blockchain activity might not last long. To capitalize on increased interest, companies must act quickly, integrating with emerging blockchains as soon as possible.
Additionally, the cost of verifying user location can be significantly reduced. As we discussed, every localization attempt costs money. Regardless it it’s successful or not. For a single transaction, this expense might seem negligible, but at scale? It’s substantial enough to significantly impact the company’s profit margins.
Ok, so what can be done about it? Well, the team could try improving the current PaymentProcessor on the fly, likely incurring technical debt and causing regressions. In most cases, due to poor separation of concerns and insufficient modularity, that way forward is the only available option. Fortunately, in case of the payment app, the team could utilize the LSP and start working on a new crypto payment module, eventually replacing the current one.
While integrating with new blockchains presents its own challenges, the key question remains: how can the app verify a user’s location more cost-effectively while maintaining compliance? The obvious solution would be to cache the user’s location throughout the payment session. According to the legal department, this approach is acceptable as long as the user remains logged in and the payment session stays active. Although this approach may seem questionable initially, the business decided to cache the user’s location throughout the payment session:
public final class NextGenCryptoPaymentsProcessor: PaymentProcessor, Sendable {
...
/// Actor for safely caching the user's country in concurrent contexts.
private let countryCache = CountryCache()
public init(
...
) {
...
Task {
let country = try await locationService.localizeUserCountry()
await countryCache.set(country)
}
}
...
/// - SeeAlso: `PaymentProcessor.verifyFunds(amount:)`
public func verifyFunds(amount: Double) async throws -> Bool {
var country = await countryCache.get()
if country == nil {
let localized = try await locationService.localizeUserCountry()
await countryCache.set(localized)
country = localized
}
print("Verifying crypto funds: $\\(amount) (country: \\(country.orOther.rawValue))")
guard cryptoSupportChecker.isCryptoSupported(in: country.orOther) else {
throw PaymentError.cryptoNotSupportedInLocation
}
...
// Continue with the payment.
}
}As you can see, the NextGenCryptoPaymentProcessor and CryptoPaymentProcessor share identical APIs. According to LSP, this should allow seamless substitution between them. But does this theoretical compatibility translate to real-world interchangeability?
Live Demo: What Could Possibly Go Wrong?
The work on the new, improved payment service progressed better than expected. The team finished most of the work ahead of schedule, prompting the business to present it at the upcoming crypto conference in Portugal. The CEO himself volunteered to take the stage himself. Portugal is a very nice place in the summer after all.
The presentation plan was simple: use two identical iPhones – one with an app showcasing the old payment service, and the other with an app demonstrating the new one. The CEO would complete a payment with the current method first, then switch to the new one to highlight the additional blockchain integrations. After multiple successful test runs, everyone felt confident about the presentation. And that’s when disaster struck…
The first part of the presentation went smoothly. To save time, the CEO had his payment session set up ahead of time, and, as Portugal is crypto-friendly, the old payment service allowed him through without problems. Sadly, it was not the case for the new service he wanted to showcase. Even though the CEO tried to switch between blockchains, the service wouldn’t let him through, citing regulatory compliance.
So what happened? You don’t need to be Sherlock Holmes to figure this out….
Everything would’ve been fine if the CEO hadn’t done his final rehearsal just before departing for the conference. That rehearsal took place in Morocco – a country that is not very crypto-friendly. As expected, both payment services prevented the CEO from making a transaction there. Because crypto payments can take a significant amount of time to be finally confirmed, the payment session was saved with the user login session, along with the CEO’s location at that time – Morocco. Upon arriving in Portugal, the app was still running in the background, and the payment session was still valid, so both were re-used when the CEO took the stage. And, because the new service used the cached user location to save money on geolocation checks, the service concluded that the user was still in a country where crypto payments were banned, preventing the transaction from being finalized.
So, apparently, by utilizing the LSP to meet the demands of the business, the team has accidentally put the CEO’s ability to deal with the presenter’s curse to the test. If Liskov’s principle cannot protect us from safely substituting a piece of software with its matching counterpart, why does it even exist?
That’s an excellent question! You see – Liskov’s does not guarantee that two components are freely interchangeable simply because they share a common API. Liskov’s is all about trust. The trust that the components you are switching don’t have hidden side effects, technical limitations, or internal constraints affecting their work. Unless you can guarantee this is the case, you should not make a switch, even if the APIs are a perfect match!
Naturally, the example is largely exaggerated. It would’ve been enough if the CEO had killed the app, logged out, or made a rehearsal before taking the stage. Similarly, the team could’ve implemented additional checks into the service to enforce geolocation verification if the payment session took longer than, say, 15 minutes. And they probably will. Still, the underlying reasoning remains the same: unless you absolutely trust the new component to match the current one 1:1 in terms of the API, performed service, and produced side effects, you should not make the swap.
How about us? How often do we make such calls? Actually, each time we decide to bump an SPM dependency, switch to a new version of a framework or the iOS SDK, etc. Are we guaranteed that no new, potentially dangerous side effects have been introduced? No. Do we still decide to make the switch? Often times: yes.
It’s just part of our job descriptions and a calculated risk we are willing to make…
Summary
So, we’ve made it to the end. If you’re like me, you might initially feel disappointed with the reality of applying the Liskov‘s Substitution Principle to our projects. After all, we’ve been told that upholding S.O.L.I.D. principles guarantees beautiful, scalable, and issue-free code. And the LSP, a critical component of this paradigm, was supposed to guarantee interchangeability of our app components. So, what did we do wrong?
Fortunately, as experienced developers, we understand that there is no certainty in this business. The only constant thing in IT is… change. Likewise, no amount of effort spent implementing S.O.L.I.D. code can guarantee issue-free, maintainable and scalable apps. It can only increase the probability of creating them!
And flipping the script: what if we are the ones introducing a new version of our SDK, library, or module? Ideally, we should cover the swap with a comprehensive suite of integration tests (manual or automatic). Needless to say, we should also document every side effect a component produces.
But what benefits do we gain from this additional effort? As we’ve seen, the LSP can speed up development without accumulating technical debt. We demonstrated this when the team developed the NextGenCryptoPaymentsProcessor alongside the original component. The Liskov’s also greatly enhances code testability. When an app allows components to be freely swapped for others with matching APIs and behaviors, we can inject Mocks and Fakes in our unit tests to better control the testing environment. Finally, if you’re unsure about the effects of directly swapping one component for another, why not leverage feature flags to limit user exposure? If two components function identically, the app should be able to decide at runtime which version to inject into the app DI container.
If I had to sum up the LSP with one sentence I’d probably use an old Russian saying: Trust but verify.
Don’t miss any new blogposts!
By subscribing, you agree with our privacy policy and our terms and conditions.
Disclaimer: All memes used in the post were made for fun and educational purposes only
They were not meant to offend anyone
All copyrights belong to their respective owners
The thumbnail was generated with text2image![]()
Some images were generated using Leonardo AI
Audio version generated with TTS Maker
