What is SRP?

The Single Responsibility Principle (SRP) is one of the SOLID principles in software design. It states that a component should have only one reason to change. In other words, a class, struct, or any components, should have a single purpose and should not be modified for multiple reasons.

Consider the following Swift struct:

struct RemotePhrase: Decodable {
    let chinese: String
    let pinyin: String
    let english: String
    
    enum CodingKeys: CodingKey {
        case chinese
        case pinyin
        case english
    }
}

This RemotePhrase model is tied to JSON decoding, which means it primarily belongs to the networking layer. However, using the same model for multiple purposes, such as Core Data storage or UI representation, can lead to violations of SRP. Meaning, if later, we have another new to deal with Decodable, we might change the Decodable implementation, but CoreData or UI layer won’t even care about this. And later, in CoreData, we migrated to SwiftData, this indicates that we have more than one reason to change, which a sign that we violates the SRP.

Why is SRP Important?

If a component is modified for multiple reasons, it becomes harder to maintain and scale. Future modifications might introduce conflicts due to unintended dependencies.

For example:

  • If we add a protocol conformance that requires manual implementation, this model might change for reasons unrelated to networking.
  • Other parts of the app might use the same model but do not require the same dependencies.
  • This could result in a God Object, which is a class or struct with too many responsibilities.

What If …

What if we don’t follow SRP? Imagine you have a component that handle many things and has many dependencies. What does the component looks like?

God Object
Violation of SRP visualized in a dependency diagram

We can see from the image above clearly that the PhraseModel is a God Object, has too many things to do, which violates the Single Responsibility Principle. Overtime, this component will be easily hard to maintain, due to the nature of SRP violation.

Applying SRP

To follow SRP, we can create separate models for different purposes:

Networking Model

struct RemotePhrase: Decodable {
    let chinese: String
    let pinyin: String
    let english: String
    
    enum CodingKeys: String, CodingKey {
        case chinese = "text_chinese"
        case pinyin = "text_pinyin"
        case english = "text_english"
    }
}

This model is strictly for handling JSON data received from a remote API.

Persistence Model (Core Data / SwiftData)

import SwiftData

@Model
struct ManagedPhrase {
    @Attribute(.unique) var id: Int
    let chinese: String
    let pinyin: String
    let english: String
}

This model is meant for data persistence and is independent of the networking code.

Domain Model (Entity Layer)

struct PhraseEntity {
    let id: Int
    let chinese: String
    let pinyin: String
    let english: String
}

This model is pure and can be used freely across the app without dependencies on network or database logic.

Diagram Representation

All 3 model components are separated depending on the module or purpose

The circled elements in the diagram show how the Single Responsibility Principle (SRP) is applied to Swift models by separating their roles. The PhraseEntity, circled in green, is the domain model. It represents the core data structure used throughout the app, free from any networking or persistence details. This makes it flexible and reusable in different layers like UseCases and ViewModels.

The RemotePhrase, circled in purple under Networking, is designed for API responses. It uses Codable to handle JSON decoding and is closely tied to the server’s data structure. This keeps the networking logic separate, so changes in API formats don’t affect other parts of the app.

The ManagedPhrase, also circled in purple but under Persistence, is used for local storage. It works with CoreData or SwiftData to handle database-related logic. By keeping networking, persistence, and domain models separate, the app stays clean, modular, and easier to maintain.

Since we have 3 different model types, later for convenience purposes, we can create some extension mapper and place it in the correct layer.

// Extension for RemotePhrase to map to PhraseEntity
extension RemotePhrase {
    func toEntity(id: Int) -> PhraseEntity {
        PhraseEntity(
            id: id,
            chinese: chinese,
            pinyin: pinyin,
            english: english
        )
    }
}

// Extension for ManagedPhrase to map to PhraseEntity
extension ManagedPhrase {
    func toEntity() -> PhraseEntity {
        PhraseEntity(
            id: id,
            chinese: chinese,
            pinyin: pinyin,
            english: english
        )
    }
}

// Extension for PhraseEntity to map back to ManagedPhrase
extension PhraseEntity {
    func toManagedPhrase() -> ManagedPhrase {
        ManagedPhrase(
            id: id,
            chinese: chinese,
            pinyin: pinyin,
            english: english
        )
    }
}

Conclusion

The Single Responsibility Principle (SRP) ensures each model serves a distinct purpose, reducing tight coupling and making the codebase easier to maintain. For example, RemotePhrase handles API responses, ManagedPhrase deals with persistence, and PhraseEntity focuses on domain logic. This separation prevents changes in one layer from affecting others, improving scalability and maintainability.

For small apps, starting simple is fine. As the app grows, refactoring towards SRP ensures the code remains clean and adaptable. SRP also promotes better collaboration, allowing developers to work independently on different layers. Ultimately, SRP creates a future-proof, testable, and modular codebase.

References

Leave a comment