It’s Monday morning. You’re halfway through your coffee when a Slack message pops up from the Platform team: Hey, we noticed UserService.fetchLoyaltyPoints() is throwing an error for 12% of users. Can you take a look?
You open UserService.swift, and your heart sinks. Eleven dependencies. Twenty-five methods. Eight hundred forty-seven lines of code. Two years ago, you wrote the first version of this protocol. It had four methods. It was clean and focused – exactly what the Interface Segregation Principle encourages. It was beautiful.
Now it’s a monster. And six teams depend on it.
If you’ve worked on a large iOS codebase, you’ve probably seen a protocol like this.
The worst part? You can’t fix it. At least not immediately. The Payments team is in feature freeze. The Loyalty team already shipped an SDK built around your bloated API. The Platform team has 47 integration tests that would need rewriting.
I know this place. I’ve written the migration guides nobody read and posted the Slack reminders that got emoji reactions but no action. Here’s how to avoid earning that experience yourself. And if you haven’t met SRP, OCP, or LSP yet, this one still stands on its own.
The Innocent Beginning
Let’s rewind two years. Imagine you’re starting a new fintech project. Clean slate, modern Swift with async/await everywhere, SwiftUI for the UI layer. The kind of greenfield setup that makes you believe this time you’ll do everything right.
First order of business: authentication. Users need to sign in, sign out, and you need to keep their session alive. Nothing fancy.
You create a protocol that would make any SOLID enthusiast proud – exactly what good Swift protocol design should look like:
protocol UserService: Sendable {
func currentUser() async -> User?
func signIn(email: String, password: String) async throws -> User
func signOut() async throws
func refreshSession() async throws -> AuthToken
}Four methods. Focused on one thing: session management.
You’ve even added Sendable conformance – perfect for a service that will be injected throughout the app. Writing mocks for tests takes maybe ten lines of code. SwiftUI previews work flawlessly.
You feel genuinely satisfied. This is the kind of code you want to maintain.
Here’s the thing about the Interface Segregation Principle: it states that clients shouldn’t be forced to depend on methods they don’t use.
Looking at your pristine 4-method protocol, you think you have it nailed. Every method is essential. Every client needs all of them.
What you don’t realize is that you’ve already planted the seed of your own destruction.
It isn’t in the code – it’s in the name: UserService.
Not SessionProvider. Not AuthenticationManager. Just… UserService. A name so generic it might as well be StuffDoer. It’s a bucket with a label that says put user-related things here.
And over the next eighteen months, that’s exactly what everyone will do – including you.
The First Crack
Month four. The app is taking shape.
The Settings screen needs a new feature: users should be able to configure their notification preferences. Push notifications on or off. Email digests daily or weekly. Standard stuff.
You open Xcode and start typing protocol NotificationPreferences… . Then you stop.
The UserService already has access to the current user. The preferences are user preferences.
Why create a whole new protocol and a whole new dependency when you can just add two methods to what you already have?
It’s still user-related you tell yourself. And technically, you’re not wrong.
// UserService.swift -- Month 4
protocol UserService: Sendable {
func currentUser() async -> User?
func signIn(email: String, password: String) async throws -> User
func signOut() async throws
func refreshSession() async throws -> AuthToken
// "It's just preferences, still user-related"
func updateNotificationPreferences(_ prefs: NotificationPreferences) async throws
func getNotificationPreferences() async throws -> NotificationPreferences
}Six methods now. Still reasonable, right?
The LiveUserService implementation grows a bit. It needs a new dependency – PreferencesAPIClient – to talk to the backend. The initializer gets longer. Tests require a bit more setup. But nothing alarming.
What you don’t notice is the subtle shift that has occurred.
The protocol is no longer answering the question how do users authenticate? It’s now answering what can we do with users? – a question with infinite answers.
This is exactly where the Interface Segregation Principle starts to matter.
The Settings team is happy. The feature ships on time. You move on to the next ticket.
The bucket has accepted its first non-authentication item. And nobody complained.
And now it gets easier to do it again.
The Slide
The changes to the API are so gradual you barely notice them. KYC compliance lands in month six. Three extra verification methods, because identity is obviously user-related.
Month nine brings profile customization. You briefly consider a ProfileService, then decide injecting two services into one screen is overkill. Three more methods.
Month thirteen, the Growth team ships a referral program. You don’t even argue anymore. You just open UserService.swift and type. By month eighteen, the rewards program goes live and adds its own three methods. Then account management. Password resets. Two-factor authentication. Session termination.
Each addition made sense. Each PR got approved. You never crossed a line – you just kept moving it. One day you open UserService.swift and scroll. And scroll. And scroll.
// UserService.swift -- Month 18
protocol UserService: Sendable {
// Session (original)
func currentUser() async -> User?
func signIn(email: String, password: String) async throws -> User
func signOut() async throws
func refreshSession() async throws -> AuthToken
// Preferences
func updateNotificationPreferences(_ prefs: NotificationPreferences) async throws
func getNotificationPreferences() async throws -> NotificationPreferences
// Verification / KYC
func submitVerificationDocuments(_ docs: [VerificationDocument]) async throws
func getVerificationStatus() async throws -> VerificationStatus
func requestManualReview() async throws
// Profile
func updateAvatar(_ image: Data) async throws -> URL
func updateDisplayName(_ name: String) async throws
func deleteAccount() async throws
// Security
func changePassword(current: String, new: String) async throws
func changeEmail(to email: String, password: String) async throws
func requestPasswordReset(email: String) async throws
func enableTwoFactor(_ method: TwoFactorMethod) async throws
func disableTwoFactor() async throws
func getActiveSessions() async throws -> [Session]
func terminateSession(id: Session.ID) async throws
// Referrals
func generateReferralCode() async throws -> ReferralCode
func applyReferralCode(_ code: String) async throws -> ReferralReward
func getReferralStats() async throws -> ReferralStats
// Loyalty
func fetchLoyaltyPoints() async throws -> LoyaltyPoints
func redeemPoints(_ points: Int, for rewardID: Reward.ID) async throws
func getLoyaltyHistory() async throws -> [LoyaltyTransaction]
}The final numbers are staggering:
- 25 methods defined in the protocol
- 847+ lines in the implementation
- 11 dependencies injected
- 4 internal and 2 external teams relying on the API
And all of this resulting from one reasonable request you agreed to all these months ago…
The Symptoms
You know what’s the worst part? The app works. It ships. Users are happy – at least for a while. And we know all too well: as long as things work, the business doesn’t care how they’re implemented.
The development team? No matter how loudly we complain, it’s never enough to start a discussion about refactoring… But the cracks are forming beneath the surface, and they’re about to become impossible to ignore.
First, maintaining automated testing becomes an exercise in suffering. Every dev team, internal or external, that relies on UserService needs to mock it – all 25 methods. Even the ones they don’t need. And then update the mock every time the methods they don’t need and don’t rely on change:
final class MockUserService: UserService {
func currentUser() async -> User? { fatalError() }
func signIn(email: String, password: String) async throws -> User { fatalError() }
func signOut() async throws { fatalError() }
// ...
func fetchLoyaltyPoints() async throws -> LoyaltyPoints { fatalError() }
func redeemPoints(_ points: Int, for rewardID: Reward.ID) async throws { fatalError() }
func getLoyaltyHistory() async throws -> [LoyaltyTransaction] { fatalError() }
// The ONE method you rely on:
func getReferralStats() async throws -> ReferralStats {
return .mock
}
}Imagine that, 24 fatalError() stubs to make a single test work. Multiply this by every team and every feature that rely on your service. And now imagine if the signature of the API methods changed… Sure, AI should be able to fix the compilation errors quickly, fix the broken tests, or the new API methods can be implemented as protocol extensions. Still, it does not change the fact that, with such an elaborated API, the tests seem to grow faster than the production code.
Naturally, flaky tests on CI start to appear too. Maybe it’s some residue from a test suite that finished a second ago but is still polluting remaining tests? Or the API method you didn’t stub properly? Or a test indirectly relying on the live implementation of UserService? After hours of investigation, you identify the culprit: a background task triggering a token refresh, calling one of the fatalError()ed methods on MockUserService. Difficult to find, fairly easy to fix. The symptom disappears. The disease remains. See you at the next crisis…
Finally, there’s the 12% bug from the Slack message. Remember it? fetchLoyaltyPoints() throwing an error for 12% of users? The Loyalty team investigated – checked the server logs, analytics, everything. It all seemed fine on their end. The problem? fetchLoyaltyPoints() assumes currentUser() returns a non-nil value. It’s a reasonable assumption, since the user must be logged in at this point. But is it always so? When the authentication token expires, the service attempts to call refreshSession(). While this happens, currentUser() is briefly set to return nil as a safety measure to prevent stale data from being used. The Loyalty team’s LoyaltyDashboardViewModel observes the UserService for changes (because that’s how they get user context). When currentUser() flips to nil and back, SwiftUI triggers a view update, which re-fetches loyalty points. Usually, the token refresh happens really quickly, but not always. And 12% of users, apparently, are paying the price.
The cruel part? The Loyalty team did nothing wrong. They consumed the API exactly as intended. The invisible coupling wasn’t in their code. It was built into the protocol design two years earlier, in a decision that felt completely reasonable at the time.
If a dedicated LoyaltyPointsProvider had existed from the start, the loyalty dashboard would have had no reason to observe the session state at all. No shared protocol. No hidden dependency. No 12% bug. Just a focused interface, doing one thing, making one promise.
This is the true cost of a fat protocol: not the boilerplate, not the compile times, not the awkward faking in unit tests. It’s the invisible coupling. The assumptions that travel silently between teams through shared interfaces. The dependencies of your dependencies – the ones you didn’t know you had.
The Awakening
That 12% bug is the last straw. How much longer can you keep spinning between debugging invisible couplings and explaining to stakeholders why a simple loyalty feature doesn’t work for some users? Enough is enough. It’s time to fix this properly.
You block off a Friday afternoon, close Slack, and sketch out what UserService should have been from the start. Not one giga-chad protocol, but many specialized ones:
protocol SessionProvider: Sendable {
func currentUser() async -> User?
func signIn(email: String, password: String) async throws -> User
func signOut() async throws
func refreshSession() async throws -> AuthToken
}
protocol NotificationPreferencesProvider: Sendable {
func updateNotificationPreferences(_ prefs: NotificationPreferences) async throws
func getNotificationPreferences() async throws -> NotificationPreferences
}
protocol VerificationProvider: Sendable {
func submitVerificationDocuments(_ docs: [VerificationDocument]) async throws
func getVerificationStatus() async throws -> VerificationStatus
func requestManualReview() async throws
}
...
protocol ReferralProvider: Sendable {
func generateReferralCode() async throws -> ReferralCode
func applyReferralCode(_ code: String) async throws -> ReferralReward
func getReferralStats() async throws -> ReferralStats
}
protocol LoyaltyPointsProvider: Sendable {
func fetchLoyaltyPoints() async throws -> LoyaltyPoints
func redeemPoints(_ points: Int, for rewardID: Reward.ID) async throws
func getLoyaltyHistory() async throws -> [LoyaltyTransaction]
}Focused protocols instead of one monster. Each consumer is able to integrate only what they really need. No matter if it’s a tiny view model or an external development team.
Mocking becomes simple and automatic. 3 methods instead of 25. No more invisible coupling. No more 12% bugs.
Let’s draft a migration plan then:
Step 1: Make UserService a composition of the new protocols:
@available(*, deprecated, message: "Use specific protocols: SessionProvider, LoyaltyPointsProvider, etc.")
protocol UserService: SessionProvider,
NotificationPreferencesProvider,
...
LoyaltyPointsProvider { }The existing LiveUserService already implements all these methods. It automatically conforms to every atomic protocol. Zero code changes in the implementation.
Step 2: Register the new protocols in your DI container:
// Before: only UserService was registered
container.register(UserService.self) { LiveUserService(...) }
// After: same instance, multiple registrations
let liveUserService = LiveUserService(...)
container.register(UserService.self) { liveUserService }
container.register(SessionProvider.self) { liveUserService }
container.register(LoyaltyPointsProvider.self) { liveUserService }
container.register(ReferralProvider.self) { liveUserService }
// ... etcNow teams can inject e.g.LoyaltyPointsProvider instead of the entire UserService. Although, underneath, they are still getting the same LiveUserService, their dependency is explicit and narrow.
Step 3: Migrate team by team, screen by screen.
No deadlines. No coordination. Each team updates their view models when they have bandwidth:
// Before
final class LoyaltyDashboardViewModel {
let userService: UserService // Depends on 25 methods
// ...
}
// After
final class LoyaltyDashboardViewModel {
let loyaltyProvider: LoyaltyPointsProvider // Depends on 3 methods
// ...
}Step 4: Once all the users migrate, remove UserService from DI and delete the protocol.
Once UserService is gone, you can freely split the implementation into multiple, dedicated components. At that point, every change will be considered an implementation detail – free to refactor without affecting API users.
Step 5: The big wait
You draft the plan, create the atomic protocols, submit the PR. No changes to the API so nothing breaks. Existing code keeps working. The compiler shows deprecation warnings, gently nudging teams toward the new protocols.
You feel good. This is the right approach – incremental, backwards-compatible, low-risk.
All we need to do now is wait.
The Wall
Your PR sits in review for a day. Then the comments start rolling in.
The first review is technical. A fellow developer from the Platform team raises a concern: If we register one implementation under multiple protocols, is that safe? What happens if a view model injects both SessionProvider and LoyaltyPointsProvider? Do we get two instances? Could that cause state synchronization issues? You explain: It’s the same instance, registered multiple times. Reference semantics. When a view model injects both protocols, it gets two references to the same LiveUserService object in memory. No duplication. No sync issues. The Platform team approves. One down. But then it got worse…
The Loyalty team has a real problem. Their loyalty SDK, shipped to partner apps, exposes UserService in its public API. They don’t expose the protocol directly, but use a wrapper that depends on it internally: If we deprecate UserService, our SDK users will see warnings when they build. We just signed a new partner last week. We can’t ship deprecation warnings to a brand-new customer during their first integration. You suggest they create an internal typealias or wrap the new LoyaltyPointsProvider instead: That’s a breaking change for our SDK. We’d need to bump the major version. That means a migration guide, updated documentation, partner communication… You know they’re right. Public API management is hard. And you’re experiencing it firsthand.
Then comes the showstopper. Your tech lead pings you privately: Code freeze starts next week. The release candidate branch is already cut. We shouldn’t merge anything that touches core infrastructure until after the release. That’s three weeks from now. Oh, and afterwards you need to get it in before the end-of-year code freeze. For now – just hang in there! You’re doing God’s work!
You check the calendar. You have roughly a two-week window to drive the change through. You and everyone else are waiting with their changes.
You could argue that your changes are additive and non-breaking, but deep down you know it’s pointless. Code freeze means code freeze. No exceptions.
This is the part they don’t teach you in the SOLID tutorials:
Refactoring shared infrastructure isn’t purely a technical problem. It’s a planning and coordination problem,
Both of which come at a steep price though:
- Timing cost: There’s never a good time. Someone is always in code freeze, or about to launch, or waiting for QA sign-off.
- Public API cost: Once a protocol escapes into a published SDK, deprecation becomes a customer communication problem.
- Engagement cost: Every team must be made aware of the change and understand it. That takes time, even if the migration itself is trivial.
- Prioritization cost: Migration work competes with feature work. Features have stakeholders lobbying for them. Loudly. Refactoring efforts have… you.
Finally, the change gets merged after the big code freeze! Roughly two months after your PR was set for review. Trust me, this isn’t that bad. The new protocols land in main. The DI registrations are in place. Teams can migrate whenever they want.
Most of them don’t.
Six months later, you check the codebase. The deprecation warnings are still there. A handful of view models use the new protocols – mostly ones written after the merge. That’s understandable, but disheartening.
Only one team has migrated fully: the one that had issues maintaining their unit tests. The rest? They have migrate to new protocols sitting in their backlog, collecting dust.
The monster is still alive. It’s just got shiny new deprecation warnings…
The Lesson
So technically, the migration was a success. The new protocols landed in main. Some teams even adopted them. The new architecture is undeniably better.
But here’s the scorecard:
- 2 years of invisible coupling before anyone connected the dots.
- 2 Days of debugging a race condition nobody knew existed.
- 3 months of code freezes.
- 6 months of deprecation warnings fading into build log noise.
- 1 SDK that will probably never migrate because the public API is locked.
And the monster? Still alive. Still being injected into new code by developers copy-pasting from existing view models.
The real lesson of the ISP isn’t about clean code aesthetics. It’s about preserving your ability to change things later.
A fat protocol is easy to create – one perfectly reasonable method at a time. But every method is a contract. Not with the compiler. With the teams, partners, and clients who start relying on it. Scratch that – not a contract, a lifelong mortgage. And, as you surely recall from real life, contracts are notoriously difficult to renegotiate…
Another useful analogy I often use when describing fat protocols: they’re the buy now, pay later software architecture. Just like paying with Klarna, adding that method feels free in the moment. The feature ships, stakeholders are happy, everyone celebrates. But the interest compounds silently: harder testing, invisible coupling, blocked refactors, coordination overhead. By the time you realize you’re paying too much, it’s already too late.
Fat protocols don’t get paid off. They get refinanced…
The Vaccine
We’ve talked about cleaning up the mess long enough. Let’s discuss how to avoid it in the first place.
There’s one simple approach that has always worked for me: name protocols for what they do, not what they touch.
Remember the first mistake? Naming the service protocol after what it originally worked on – the user. Because it touched everything user-related, it became a nearly bottomless bucket.
How can we spot such buckets in our code? Look for names ending with the usual suspects: Service, Manager, Handler. These protocols are magnets for additional functionality.
Now, look at the targeted protocols: SessionProvider, LoyaltyPointsProvider, ProfileEditor. These names practically scream boundaries. When someone tries to add fetchLoyaltyPoints() to SessionProvider, the name itself pushes back.
Before adding a method to a protocol, I now run a quick internal check – one I’ve seen a few teams paste directly into their PR templates:
- Does this method fit the protocol’s name? If you have to pause to justify it, it probably doesn’t.
- Would all existing clients of this protocol reasonably need this method? If it’s only useful to one team, it belongs elsewhere.
- Is creating a new protocol overkill? Almost never.
If any answer is no: create a new protocol. Yes, it costs something upfront. Everything does. The question is whether you pay now or with interest later.
Proposing these checks will get some pushback. That’s fine. You’re not asking everyone to rewrite the codebase – you’re just adding a speed bump before the next method gets added to a shared interface. Most developers, when forced to pause and think, will make the right call.
The true problem is that developers forget. And new joiners never knew in the first place. So instead of catching every protocol size violation in code reviews, use automation to enforce it. Start with SwiftLint. Add a rule that flags protocols growing too large:
# .swiftlint.yml
custom_rules:
large_protocol:
name: "Large Protocol"
regex: "protocol\\s+\\w+[^{]*\\{([^}]*func[^}]*){5,}"
message: "Protocol has 5+ methods. Consider splitting."
severity: warningWhy warn at 5, and not 6? Most protocols implement CRUD operations – Create, Read, Update, Delete. Reserving one spot per operation gives you a natural ceiling of 4. Five is the first sign the scope of the protocol has increased.
Naturally, SwiftLint is a simple, non-discriminating tool and some protocols genuinely require more methods. Nevertheless, seeing a linter warning in Xcode and later in the PR forces a discussion before the changes hit production. And sometimes that’s all it takes to prevent monolithic, giga-chad protocols from ever being created. An ounce of prevention…
For deeper analysis, I recommend Periphery. It excels at detecting unused code, including unused protocol methods. If half the implementations stub a method with // Noop, the protocol is in dire need of a diet.
There’s also a nuclear option: enforce boundaries with module structure. If SessionProvider lives in an Authentication module and LoyaltyPointsProvider lives in a Loyalty module, merging them becomes physically impossible. The compiler won’t allow it. And if you try extending them with methods beyond the module’s domain, it’ll immediately show in the PR. You might ask: where should the implementation live? Ideally, each module should have its own implementation, producible by a dedicated factory. Yes, I’m a factory freak – guilty as charged. But what if we already have a giga-chad implementation like LiveUserService? You can put it in the module that depends on the Authentication and Loyalty modules, or in the app itself. Either way, the main goal of preventing protocols from growing beyond their initial domains, will be achieved:
Modules/
├── Authentication/
│ └── SessionProvider.swift
├── Loyalty/
│ └── LoyaltyPointsProvider.swift
│ ...
└── App/
└── LiveUserService.swiftThis requires upfront investment in modularization, or at least a gradual transition. If you’re building an app to last for years, this investment pays for itself many times over. Especially if the work is divided between multiple development teams.
It sounds like a lot of work (and it is). But a good AI assistant and a clear module boundary will get you most of the way there on a slow afternoon.
Summary
The Interface Segregation Principle is simple: clients shouldn’t depend on methods they don’t use. Unfortunately, it’s also remarkably easy to violate – adding one reasonable method at a time. As human beings, developers tend to follow the path of least resistance. If the friction of creating a new protocol feels higher than extending an existing one, you can count on the protocols in your app will likely keep growing nice and fat.
How can we prevent this from happening? I’d start with naming protocols for what they do, not what they touch. UserService is a bucket that invited every new method even remotely associated with the User. SessionProvider is a boundary. A good name pushes back on scope creep before you even open a PR.
Also, treat every method as a contract. The more methods in a protocol, the more parties you need approval from to change it. Keep that list short!
In addition, automation and processes beat discipline. You’ll understand the consequences of violating the ISP after reading this post. But the contractor who joins next quarter won’t. SwiftLint, however, doesn’t forget. A warning in Xcode or a Danger annotation in the PR makes people pause. And often, that’s enough.
If I had to sum up the ISP in one sentence: Don’t build the prison! By the time you see the bars, six teams are already living in it.
Finally, keep in mind that not every large protocol automatically warrants refactoring. If all clients use almost all the methods, splitting the protocol adds unnecessary complexity. Like all S.O.L.I.D. principles, the ISP is a useful tool, not a religious doctrine. You should apply it within reason.
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
