System Design Case Study: Designing a Payment Processing System
Designing a Flexible and Testable Payment System for E-Commerce Applications
System design interviews can feel overwhelming at first. There’s so much to think about — scalability, reliability, trade-offs — but it doesn’t have to be confusing at all. In this article, we’ll look at one of the most common system design questions asked in interviews. I’ll guide you step by step through the process of solving it, explaining the choices we make along the way and why they matter. If you’re preparing for an interview or just want to improve your design skills, this is a great place to start.
Unable to view the entire article? You can read it here!

Scenario:
You’re building an e-commerce application where users can pay using different payment methods such as:
Credit Card
PayPal
Apple Pay
Google Pay
The application needs to support:
Adding new payment methods without modifying the existing codebase.
Writing unit tests for each payment method without relying on actual payment services.
Solution
We need to begin by first identifying the requirements for the above scenario:
If you want your code to be able to add new payment methods as and when needed without making changes to the existing code base, it is important to focus on the flexibility and the scalability aspect while designing the code flow.
To write unit tests for payment methods, the code should not be tightly coupled. This can be achieved by taking testability and abstraction aspects into consideration while designing the code flow.
Based on the above reasoning, we can list down the requirements as follows:
Abstraction: Define a common interface for all payment methods.
Flexibility: The system should allow switching between payment methods at runtime.
Testability: The payment processor should be testable using mocks or stubs for payment methods.
Scalability: Adding new payment methods should not involve changing the payment processor logic.
Now that we have managed to come up with a list of requirements, we can go ahead with the solution:
Steps to Solve:
1. Define the Payment Interface
We will start by defining an interface or protocol that all payment methods must implement.
// Swift Example
protocol PaymentMethod {
func processPayment(amount: Double)
}
2. Implement Payment Methods
Each payment method implements the PaymentMethod
interface:
class CreditCardPayment: PaymentMethod {
func processPayment(amount: Double) {
print("Processing Credit Card Payment of \(amount)")
}
}
class PayPalPayment: PaymentMethod {
func processPayment(amount: Double) {
print("Processing PayPal Payment of \(amount)")
}
}
3. Create the Payment Processor
The PaymentProcessor
class uses Dependency Injection to accept a PaymentMethod
.
Using Dependency Injection, new payment methods can be added in future without making any changes to the existing code base. Unit Testing can also be done very easily, by passing MockPaymentMethod
in the initializer.
class PaymentProcessor {
private let paymentMethod: PaymentMethod
// Constructor Injection
init(paymentMethod: PaymentMethod) {
self.paymentMethod = paymentMethod
}
func process(amount: Double) {
paymentMethod.processPayment(amount: amount)
}
}
4. Use Dependency Injection to Switch Payment Methods
// Switch between payment methods at runtime
let creditCardPayment = CreditCardPayment()
let payPalPayment = PayPalPayment()
let paymentProcessor1 = PaymentProcessor(paymentMethod: creditCardPayment)
paymentProcessor1.process(amount: 100.0)
let paymentProcessor2 = PaymentProcessor(paymentMethod: payPalPayment)
paymentProcessor2.process(amount: 200.0)
Testing the System
You can easily mock the PaymentMethod
to test the PaymentProcessor
without relying on real services:
class MockPaymentMethod: PaymentMethod {
var isCalled = false
func processPayment(amount: Double) {
isCalled = true
print("Mock payment processed for \(amount)")
}
}
// Test
let mockPayment = MockPaymentMethod()
let paymentProcessor = PaymentProcessor(paymentMethod: mockPayment)
paymentProcessor.process(amount: 50.0)
assert(mockPayment.isCalled, "Payment method was not called!")
Benefits of using Dependency Injection (DI) in This Case Study:
From the above code, it is evident that the use of Dependency Injection helped in satisfying all the listed requirements. Following are the list of benefits obtained by using DI:
Decoupling: The
PaymentProcessor
does not depend on concrete payment implementations.Scalability: Adding a new payment method (e.g., Apple Pay) only requires implementing the
PaymentMethod
interface without altering the processor logic.Testability: Mock implementations make it easy to test the processor.
Flexibility: Switching payment methods at runtime is seamless.
Additionally, you can also use DI frameworks like Swinject (Swift) or Dagger/Hilt (Android) to automate dependency management.
However, in this article we have focused on a very simple implementation of DI.
By breaking down this scenario, we’ve seen how Dependency Injection and design principles like abstraction and modularity can create scalable, testable systems. These principles aren’t just useful for interviews — they’re critical for building robust real-world applications. Hopefully, this case study has given you a solid foundation to tackle similar challenges with confidence
If you found this article valuable, I’d love for you to subscribe to my Substack channel. Feel free to share this article with anyone you think would benefit from it. Your support means the world to me, and I look forward to bringing you more insightful content .