Protocols Are Not Architecture
Swift makes protocols feel lightweight, so it is easy to reach for them whenever we want code to look decoupled.
But protocols are not architecture by themselves.
A protocol can express a meaningful boundary. It can also be a thin layer of indirection that hides nothing, clarifies nothing, and makes navigation harder.
The difference is not syntax. The difference is responsibility.
#The Protocol Reflex
This is a common shape:
protocol ProductsServiceProtocol {
func getProducts() async throws -> [Product]
}
final class ProductsService: ProductsServiceProtocol {
func getProducts() async throws -> [Product] {
// ...
}
}
The protocol exists because the class exists. The class conforms because the protocol exists. The names do not tell us much.
This may help with mocking in tests, but it does not automatically create a useful architecture. The boundary is still vague.
What does ProductsService own? Networking? Caching? Mapping? Business rules? Persistence? All of the above?
The protocol did not answer that question.
#A Good Protocol Names a Capability
A stronger protocol describes what a collaborator can do from the caller's point of view.
protocol ProductsRepository {
func products() async throws -> [Product]
}
This is better because it names a role. A feature that depends on ProductsRepository is asking for product data. It does not need to know where the data comes from.
Concrete implementations can explain themselves:
final class RemoteProductsRepository: ProductsRepository { ... }
final class CachedProductsRepository: ProductsRepository { ... }
final class InMemoryProductsRepository: ProductsRepository { ... }
Now the protocol and implementations carry architectural information.
I wrote more about this naming pressure in Naming Protocols and Implementations in Swift.
#Protocols Should Protect Change
Before adding a protocol, ask what change it protects.
Useful answers:
- We have multiple implementations.
- Tests need a replacement that behaves differently.
- A feature module should not depend on infrastructure.
- We want to decorate behavior.
- The concrete type belongs to another layer.
Weak answers:
- We always add protocols.
- It makes the code clean.
- We might need it someday.
- The class would be too concrete otherwise.
Abstractions are not free. Every protocol adds a name, a file, a search result, and a mental hop. It should earn that cost.
#Protocols Do Not Fix Bad Boundaries
If a type has too many responsibilities, extracting a protocol for it does not solve the responsibility problem.
protocol CheckoutManaging {
func validateCart()
func calculateTaxes()
func chargePayment()
func createOrder()
func trackAnalytics()
func navigateToConfirmation()
}
This protocol is not a clean boundary. It is a list of everything checkout currently does.
The better move is to split responsibilities:
protocol PaymentProcessing {
func charge(_ amount: Money) async throws -> Payment
}
protocol OrdersRepository {
func createOrder(from cart: Cart, payment: Payment) async throws -> Order
}
protocol AnalyticsTracking {
func track(_ event: String)
}
Now each abstraction has a narrower reason to exist.
#Protocols and Decorators
Protocols are powerful when they let us compose behavior.
protocol ProductsProvider {
func products() async throws -> [Product]
}
struct AnalyticsProductsProvider: ProductsProvider {
let decoratee: ProductsProvider
let analytics: AnalyticsTracking
func products() async throws -> [Product] {
let products = try await decoratee.products()
analytics.track("products_loaded")
return products
}
}
Here the protocol is useful because it lets multiple implementations share the same role. A remote provider can fetch products. A cached provider can add fallback. An analytics provider can add tracking.
The caller still depends on ProductsProvider. The composition root decides which chain exists.
That is architecture. The protocol is only one tool enabling it.
#Protocols and Associated Types
Swift protocols can also become too abstract.
protocol Repository {
associatedtype Entity
func getAll() async throws -> [Entity]
}
This looks reusable, but it may erase important domain language. ProductsRepository tells me something about the app. Repository<Entity == Product> tells me more about a generic programming exercise.
Generic abstractions are useful when many things truly share behavior. They are harmful when they flatten meaningful differences.
Architecture should reveal the domain, not hide it behind reusable shapes too early.
#Existentials, Generics, and Simplicity
Modern Swift gives us several ways to depend on protocols:
let repository: any ProductsRepository
or:
struct ProductsFeatureAdapter<Repository: ProductsRepository> {
let repository: Repository
}
Both can be valid. But do not let the language feature drive the architecture.
Use existentials when runtime substitution and simpler call sites matter. Use generics when static specialization or associated types make sense. For most app-level architecture, clarity matters more than cleverness.
#A Protocol Checklist
Before adding a protocol, I like to ask:
- Can I name the role without using
Protocol,Impl, orManager? - Does the caller become easier to understand?
- Does the protocol hide a concrete detail the caller should not know?
- Is there more than one useful implementation, now or in tests?
- Could a decorator add behavior through this boundary?
If the answer is mostly no, the concrete type may be enough.
#What You Should Remember
Protocols are not architecture. Boundaries are architecture.
A protocol is useful when it names a role, protects a change, enables substitution, or supports composition. It is noise when it merely mirrors a concrete class.
Use protocols deliberately. Let them express the design, not decorate it.
Read next
The Open-Closed Principle in Swift: Add Behavior Without Breaking Existing Code
Learn the Open-Closed Principle in Swift with practical examples using protocols, composition, strategies, and decorators.