Article · Blog
The interface you hid behind a protocol didn't disappear. It just became harder to find.
Extracting a protocol doesn't solve coupling. In most cases, it just moves the dependency somewhere the compiler stops complaining about. The problem is still there, it just became invisible.
The interface you hid behind a protocol didn't disappear. It just became harder to find.
At a fintech I worked at, I inherited a payment flow module with explicit coupling everywhere. PaymentService called UserRepository directly. CheckoutViewModel instantiated dependencies inside its own init. The kind of code any senior engineer looks at and immediately wants to refactor.
The previous team had started a refactor. They created protocols for everything. UserRepositoryProtocol. PaymentServiceProtocol. CheckoutCoordinatorProtocol. The module was full of types with a Protocol suffix.
The problem: the coupling hadn't decreased. It was just hidden.
What most people mean by "decoupling"
There's a very widespread belief that extracting an interface solves dependency. In the iOS world, that translates to: putting a protocol between two types means they no longer depend on each other.
That's not true.
Dependency isn't about which type you reference. It's about which behavior you assume. If your CheckoutViewModel depends on a PaymentServiceProtocol with 12 methods, 9 of them specific to the card payment flow, you have a concrete dependency. The protocol is just an alias.
The abstraction didn't eliminate the coupling. It just removed the compile-time check.
The signal I should have caught earlier
In the fintech project, the first signal showed up in the tests.
Every time we added a new method to PaymentServiceProtocol, we had to update the mocks in six different files. If you forgot one, the compiler complained. If you didn't forget, the test passed, but the mock wasn't doing anything useful because the new method was just being ignored.
The second signal came when we tried to swap out the payment implementation for a new version, compatible with a different gateway. We expected the protocol to be the boundary that would allow that swap. In practice, the new implementation had to honor implicit behaviors that were never documented in the interface, only in the calling code.
The interface had grown to serve the consumer, not to define a boundary.
This has a name. Some people call it interface pollution. I prefer to call it a mirror protocol: an interface that simply reflects the concrete implementation it was supposed to replace.
The real problem behind the problem
When you create a consumer-driven protocol, you create behavioral coupling disguised as abstraction.
A PaymentServiceProtocol with methods like processCardPayment, processPixPayment, and retryLastTransaction is not a boundary. It's a specific implementation contract with a generic name.
A real boundary defines what the consumer needs, not what the implementation offers.
If CheckoutViewModel only needs to confirm a transaction, the interface should express exactly that. A single method. No card context, no Pix context, no retry logic visible to the caller.
protocol TransactionConfirming {
func confirm(_ transaction: Transaction) async throws -> TransactionResult
}
PaymentService implements this protocol. But the protocol wasn't created to map PaymentService. It was created to express what CheckoutViewModel needs.
The difference seems subtle. In practice, it's what separates an abstraction that lasts years from one you're refactoring six months later.
What actually works: consumer-oriented interfaces
In the fintech project, the change that made a difference was simple to describe and painful to execute.
We stopped asking "what does PaymentService offer?" and started asking "what does each consumer actually need?"
CheckoutViewModel needed to confirm a transaction. We created TransactionConfirming.
ReceiptViewController needed to fetch the receipt for a past transaction. We created TransactionReceiptFetching.
PaymentService implemented both protocols. But the protocols existed for the consumers, not for the service.
The practical result: mocks became small. Tests stopped breaking because of methods the test didn't even use. The new gateway implementation came in without rewriting contracts, because the contracts were minimal.
Trade-offs nobody mentions
This approach has a real cost.
You'll end up with more protocols. Twenty different consumers might generate ten or fifteen small interfaces instead of two large ones. That requires discipline in naming things well and organizing the code so the team can understand the structure.
In small teams or short-lived projects, that granularity can be overhead. A mirror protocol is faster to create and more obvious to understand on day one.
The problem is that "more obvious on day one" becomes "harder to change in month ten." And in most projects I've seen, month ten always comes.
What I would do differently today
When I see someone creating a protocol, I ask one question first: was this protocol created for the consumer or for the provider?
If the answer is the provider, the protocol will probably grow in the wrong direction.
The other thing I would do differently: I wouldn't let a protocol's size grow without review. A protocol with more than four or five methods is a signal that it might be serving too many contexts. It's not an absolute rule, but it's a good trigger to ask whether the boundary still makes sense.
At OLX, where the Account module had several teams writing code in parallel, the discipline of small interfaces was what made it possible to review a pull request without having to understand half the system. A protocol with a clear scope is easier to review than one with an undefined scope.
The pattern that survives over time
Abstractions that last aren't abstractions that hide complexity. They're abstractions that define real boundaries.
A protocol that grows alongside the concrete implementation isn't an abstraction. It's a shadow.
The difference between a design you refactor in two years and one you can extend without fear isn't in the number of protocols. It's in whether those protocols were created to communicate intent or to satisfy the compiler.
Satisfying the compiler is easy. Any protocol with a suffix takes care of that.
Communicating intent is the part that requires thinking about the consumer before thinking about the implementation.
And that's the part most people skip.