Understanding SOLID Principles in Swift: A Comprehensive Guide with Real-World Examples
Writing clean, maintainable, and scalable code is essential in software development. The SOLID principles provide a set of guidelines that help developers achieve these goals. In this blog, we’ll explore each SOLID principle with real-world examples in Swift, showing you the problematic code first, identifying the issues, and then explaining how to fix those issues with the correct code.
Single Responsibility Principle (SRP)
Definition: A class should have only one reason to change, meaning it should have only one responsibility.
Example 1: E-commerce Order Processing
Problematic Code:
class OrderManager {
func processOrder(orderId: String) {
// Process the order
}
func updateInventory(for orderId: String) {
// Update inventory for the order
}
func notifyCustomer(orderId: String) {
// Notify customer about the order status
}
}
Issues:
1. Multiple Responsibilities: OrderManager handles order processing, inventory updates, and customer notifications.
2. Tightly Coupled Logic: Changes in one responsibility (e.g., notification logic) might unintentionally affect others.
Corrected Code:
class OrderProcessor {
func processOrder(orderId: String) {
// Process the order
}
}
class InventoryManager {
func updateInventory(for orderId: String) {
// Update inventory for the order
}
}
class NotificationService {
func notifyCustomer(orderId: String) {
// Notify customer about the order status
}
}
// Usage
let orderProcessor = OrderProcessor()
let inventoryManager = InventoryManager()
let notificationService = NotificationService()
orderProcessor.processOrder(orderId: "12345")
inventoryManager.updateInventory(for: "12345")
notificationService.notifyCustomer(orderId: "12345")
How It Solves the Issues:
1. Single Responsibility: Each class now has one clear responsibility, making the code more modular.
2. Decoupled Logic: Changes to one class (e.g., NotificationService) do not affect the others, improving maintainability.
Example 2: Social Media App — User Profile Management
Problematic code:
class UserProfileManager {
func updateProfileData(for userId: String, with data: [String: Any]) {
// Update profile data
}
func uploadAvatar(for userId: String, imageData: Data) {
// Upload avatar image
}
func updatePrivacySettings(for userId: String, settings: [String: Any]) {
// Update privacy settings
}
}
Issues:
1. Multiple Responsibilities: UserProfileManager is responsible for profile data, avatar uploading, and privacy settings.
2. Complex Maintenance: Changes in any of these functionalities would require altering the UserProfileManager class, leading to potential errors.
Corrected Code:
class ProfileDataManager {
func updateProfileData(for userId: String, with data: [String: Any]) {
// Update profile data
}
}
class AvatarUploader {
func uploadAvatar(for userId: String, imageData: Data) {
// Upload avatar image
}
}
class PrivacySettingsManager {
func updatePrivacySettings(for userId: String, settings: [String: Any]) {
// Update privacy settings
}
}
// Usage
let profileManager = ProfileDataManager()
let avatarUploader = AvatarUploader()
let privacyManager = PrivacySettingsManager()
profileManager.updateProfileData(for: "user123", with: ["name": "John"])
avatarUploader.uploadAvatar(for: "user123", imageData: Data())
privacyManager.updatePrivacySettings(for: "user123", settings: ["isPublic": false])
How It Solves the Issues:
1. Single Responsibility: Each class now focuses on one specific task, making the code easier to understand and maintain.
2. Independent Maintenance: You can modify any one of these classes without affecting the others, reducing the risk of bugs.
Open/Closed Principle (OCP)
Definition: Software entities should be open for extension but closed for modification.
Example 1: Analytics Event Tracking
Problematic Code:
class Tracker {
func trackEvent(type: String) {
if type == "click" {
print("Tracking click event")
} else if type == "purchase" {
print("Tracking purchase event")
}
// Additional event types require more else-if clauses
}
}
Issues:
1. Closed for Extension: Adding new event types requires modifying the Tracker class, leading to fragile code.
2. Maintenance Burden: The trackEvent method becomes increasingly complex as more event types are added.
Corrected Code:
protocol Event {
func track()
}
class ClickEvent: Event {
func track() {
print("Tracking click event")
}
}
class PurchaseEvent: Event {
func track() {
print("Tracking purchase event")
}
}
class Tracker {
func track(event: Event) {
event.track()
}
}
// Usage
let clickEvent = ClickEvent()
let purchaseEvent = PurchaseEvent()
let tracker = Tracker()
tracker.track(event: clickEvent)
tracker.track(event: purchaseEvent)
How It Solves the Issues:
1. Open for Extension: New event types can be added by creating new classes that conform to the Event protocol, without modifying the Tracker class.
2. Simplified Maintenance: The Tracker class remains simple and robust, regardless of how many event types are added.
Example 2: Payment Gateways in an Online Store
Problematic Code:
class PaymentService {
func processPayment(method: String, amount: Double) {
if method == "CreditCard" {
print("Processing payment through Credit Card: \(amount)")
} else if method == "PayPal" {
print("Processing payment through PayPal: \(amount)")
}
// New payment methods require more else-if clauses
}
}
Issues:
1. Closed for Extension: Every time a new payment method is added, the PaymentService class needs to be modified.
2. Increased Complexity: The processPayment method becomes cluttered and harder to maintain with each new payment method.
Corrected Code:
protocol PaymentGateway {
func processPayment(amount: Double)
}
class CreditCardGateway: PaymentGateway {
func processPayment(amount: Double) {
print("Processing payment through Credit Card: \(amount)")
}
}
class PayPalGateway: PaymentGateway {
func processPayment(amount: Double) {
print("Processing payment through PayPal: \(amount)")
}
}
class PaymentService {
private let paymentMethod: PaymentGateway
init(paymentMethod: PaymentGateway) {
self.paymentMethod = paymentMethod
}
func processPayment(amount: Double) {
paymentMethod.processPayment(amount: amount)
}
}
// Usage
let creditCardPayment = CreditCardGateway()
let paymentService = PaymentService(paymentMethod: creditCardPayment)
paymentService.processPayment(amount: 50.0)
How It Solves the Issues:
1. Open for Extension: New payment methods can be added by creating classes that conform to PaymentGateway, without altering the PaymentService.
2. Reduced Complexity: The PaymentService remains clean and easy to maintain, with each payment method encapsulated in its own class.
Liskov Substitution Principle (LSP)
Definition: Subtypes must be substitutable for their base types without affecting the correctness of the program.
Example 1: E-commerce Discount System
Problematic Code:
class Discount {
func applyDiscount(to amount: Double) -> Double {
return amount
}
}
class PercentageDiscount: Discount {
override func applyDiscount(to amount: Double) -> Double {
return amount - (amount * 0.1) // 10% discount
}
}
class FixedAmountDiscount: Discount {
override func applyDiscount(to amount: Double) -> Double {
if amount < 20 {
return amount // No discount for amounts less than $20
}
return amount - 20
}
}
Issues:
1. Behavioral Inconsistency: FixedAmountDiscount introduces unexpected behavior (no discount for amounts less than $20), violating LSP.
2. Unexpected Results: Substituting FixedAmountDiscount for Discount may lead to unexpected results, breaking the client code.
Corrected Code:
class Discount {
func applyDiscount(to amount: Double) -> Double {
return amount
}
}
class PercentageDiscount: Discount {
private let percentage: Double
init(percentage: Double) {
self.percentage = percentage
}
override func applyDiscount(to amount: Double) -> Double {
return amount - (amount * percentage / 100)
}
}
class FixedAmountDiscount: Discount {
private let discountAmount: Double
init(discountAmount: Double) {
self.discountAmount = discountAmount
}
override func applyDiscount(to amount: Double) -> Double {
return amount - discountAmount
}
}
How It Solves the Issues:
1. Behavioral Consistency: Both PercentageDiscount and FixedAmountDiscount now behave consistently with the base class, ensuring substitutability.
2. Predictable Results: Clients can safely use any Discount subclass without worrying about unexpected behavior.
Example 2: Document Management System
Problematic Code:
class Document {
func printContent() {
print("Printing document content")
}
}
class ReadOnlyDocument: Document {
override func printContent() {
fatalError("Cannot print read-only document")
}
}
Issues:
1. Violation of LSP: ReadOnlyDocument violates LSP by breaking the contract of the Document class, where printContent should always be safe to call.
2. Runtime Errors: Substituting ReadOnlyDocument for Document can cause runtime errors, making the system unreliable.
Corrected Code:
class Document {
func printContent() {
print("Printing document content")
}
}
class ReadOnlyDocument: Document {
override func printContent() {
print("Printing read-only document content")
}
}
How It Solves the Issues:
1. LSP Compliance: ReadOnlyDocument now conforms to the contract of the Document class, ensuring that the printContent method works consistently.
2. Safe Substitution: The ReadOnlyDocument can be used wherever a Document is expected, without risking runtime errors.
Interface Segregation Principle (ISP)
Definition: Clients should not be forced to depend on interfaces they do not use.
Example 1: Smart Home Devices
Problematic Code:
protocol SmartDevice {
func turnOn()
func turnOff()
func adjustSettings()
}
class LightBulb: SmartDevice {
func turnOn() {
print("Light bulb turned on")
}
func turnOff() {
print("Light bulb turned off")
}
func adjustSettings() {
fatalError("Light bulb has no adjustable settings")
}
}
Issues:
1. Interface Overload: LightBulb is forced to implement adjustSettings() even though it doesn’t need it, violating ISP.
2. Fragile Code: The unnecessary method (adjustSettings) can lead to potential misuse or runtime errors.
Corrected Code:
protocol Switchable {
func turnOn()
func turnOff()
}
protocol Adjustable {
func adjustSettings()
}
class LightBulb: Switchable {
func turnOn() {
print("Light bulb turned on")
}
func turnOff() {
print("Light bulb turned off")
}
}
class SmartThermostat: Switchable, Adjustable {
func turnOn() {
print("Thermostat turned on")
}
func turnOff() {
print("Thermostat turned off")
}
func adjustSettings() {
print("Thermostat settings adjusted")
}
}
How It Solves the Issues:
1. Proper Interface Segregation: The Switchable and Adjustable interfaces are split, ensuring that LightBulb only implements what it needs.
2. Increased Robustness: The code is now more robust, with each device class implementing only the necessary methods.
Example 2: Social Media Sharing
Problematic Code:
protocol Shareable {
func shareText()
func shareImage()
func shareVideo()
}
class TextPost: Shareable {
func shareText() {
print("Sharing text post")
}
func shareImage() {
fatalError("Text post has no image to share")
}
func shareVideo() {
fatalError("Text post has no video to share")
}
}
Issues:
1. Interface Overload: TextPost is forced to implement shareImage() and shareVideo(), which it doesn’t use, violating ISP.
2. Error-Prone Code: Implementing irrelevant methods can lead to runtime errors or misuse.
Corrected Code:
protocol TextShareable {
func shareText()
}
protocol ImageShareable {
func shareImage()
}
protocol VideoShareable {
func shareVideo()
}
class TextPost: TextShareable {
func shareText() {
print("Sharing text post")
}
}
class ImagePost: ImageShareable {
func shareImage() {
print("Sharing image post")
}
}
class VideoPost: VideoShareable {
func shareVideo() {
print("Sharing video post")
}
}
How It Solves the Issues:
1. Proper Interface Segregation: The sharing functionality is divided into separate protocols, allowing TextPost to implement only what it needs.
2. Reduced Risk: The risk of runtime errors is minimized since each class only implements relevant methods.
Dependency Inversion Principle (DIP)
Definition: High-level modules should not depend on low-level modules. Both should depend on abstractions.
Example 1: Ride-Sharing App — Payment System
Problematic Code:
class PaymentService {
func processCreditCardPayment(amount: Double) {
print("Processing credit card payment of \(amount)")
}
func processPayPalPayment(amount: Double) {
print("Processing PayPal payment of \(amount)")
}
}
Issues:
1. Tight Coupling: PaymentService is tightly coupled to specific payment methods, making it hard to extend.
2. Difficult to Maintain: Adding or modifying payment methods requires changes to PaymentService.
Corrected Code:
protocol PaymentMethod {
func pay(amount: Double)
}
class CreditCardPayment: PaymentMethod {
func pay(amount: Double) {
print("Paying \(amount) with credit card")
}
}
class PayPalPayment: PaymentMethod {
func pay(amount: Double) {
print("Paying \(amount) with PayPal")
}
}
class PaymentService {
private let paymentMethod: PaymentMethod
init(paymentMethod: PaymentMethod) {
self.paymentMethod = paymentMethod
}
func processPayment(amount: Double) {
paymentMethod.pay(amount: amount)
}
}
How It Solves the Issues:
1. Loose Coupling: PaymentService depends on the PaymentMethod abstraction, not concrete implementations, allowing for easy extension.
2. Simplified Maintenance: New payment methods can be added without changing PaymentService.
Example 2: News Aggregator App — Content Fetching
Problematic Code:
class ContentFetcher {
func fetchRSSFeed() -> [String] {
return ["RSS feed content 1", "RSS feed content 2"]
}
func fetchAPIContent() -> [String] {
return ["API content 1", "API content 2"]
}
}
Issues:
1. Tight Coupling: ContentFetcher is tightly coupled to specific content-fetching mechanisms, making it hard to adapt.
2. Inflexible Design: Any changes in content sources require modifications to ContentFetcher.
Corrected Code:
protocol ContentFetcherStrategy {
func fetchContent() -> [String]
}
class RSSFeedFetcher: ContentFetcherStrategy {
func fetchContent() -> [String] {
return ["RSS feed content 1", "RSS feed content 2"]
}
}
class APIContentFetcher: ContentFetcherStrategy {
func fetchContent() -> [String] {
return ["API content 1", "API content 2"]
}
}
class ContentFetcher {
private let fetcher: ContentFetcherStrategy
init(fetcher: ContentFetcherStrategy) {
self.fetcher = fetcher
}
func getContent() -> [String] {
return fetcher.fetchContent()
}
}
How It Solves the Issues:
1. Abstraction: ContentFetcher now depends on the ContentFetcherStrategy abstraction, allowing for flexible content-fetching strategies.
2. Adaptability: You can switch content sources without modifying the ContentFetcher class, making the design more adaptable.
General Issues when SOLID principles are not followed.
• Scalability Problems: As the codebase grows, it becomes increasingly difficult to scale the application due to the complexity and tight coupling between components.
• Increased Technical Debt: Ignoring SOLID principles leads to a significant accumulation of technical debt, making future development more costly and time-consuming.
• Longer Development Cycles: Development cycles become longer as new features require extensive refactoring of existing code.
• Reduced Code Quality: Overall code quality deteriorates, leading to more bugs, harder maintenance, and a less stable product.
• Team Communication Issues: Misaligned understanding of code responsibilities can lead to miscommunication and inconsistent implementations across a large development team.
Conclusion
Applying SOLID principles in Swift helps create well-structured, maintainable, and scalable software. By starting with the problematic code examples and resolving them using SOLID principles, we can see how these guidelines help improve code quality. Whether you’re building an e-commerce platform, a social media app, or a ride-sharing service, adhering to these principles will ensure that your code remains clean and adaptable to future changes.