Architecture With Nico

Practical software architecture and design for iOS engineers.

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, or Manager?
  • 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.