Dev Genius

Coding, Tutorials, News, UX, UI and much more related to development

Follow publication

Understanding SOLID Principles in Swift: A Comprehensive Guide with Real-World Examples

--

Photo by engin akyurt on Unsplash

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.

Sign up to discover human stories that deepen your understanding of the world.

Free

Distraction-free reading. No ads.

Organize your knowledge with lists and highlights.

Tell your story. Find your audience.

Membership

Read member-only stories

Support writers you read most

Earn money for your writing

Listen to audio narrations

Read offline with the Medium app

--

--

Published in Dev Genius

Coding, Tutorials, News, UX, UI and much more related to development

Written by Jatin Mishra

Senior iOS Engineer with 6+ years of experience, specializing in high-performance app development and architecture optimization. Join me for special insights.

No responses yet

Write a response