How to Design Swift Protocols That Do Not Over-Constrain

Clean Swift code on a screen with protocol declarations
Swift protocol design on a development screen

Every Swift developer hits a wall where a protocol gets too rigid. You start with a neat abstraction, then pile on requirements until conforming types crack. Yuki Tanaka spots this in code reviews all the time—protocols that demand way more than they actually need, twisting types into shapes that don’t fit. The fix is designing protocols that only specify what matters. This article walks through concrete ways to write Swift protocols that stay flexible and maintainable without boxing anything in.

Recognising Over-Constrained Protocols

An over-constrained protocol pushes requirements nobody asked for. You’ll see it when a protocol insists on properties or methods only some conforming types genuinely need. Take a DataProvider protocol that demands both synchronous and asynchronous fetching—it forces every provider to implement both, even if one path never gets used. Another giveaway is coupling unrelated concerns, like jamming UI configuration and data parsing together.

Over-constraining also sneaks in through implementation details. Requiring a specific initialiser or locking a property to a concrete type instead of an associated type ties conformers to one way of doing things. That kills the protocol’s ability to handle different use cases. Yuki Tanaka recommends putting every requirement under a microscope: ask yourself if it directly supports the protocol’s core job. If the answer’s no, it’s probably dead weight.

Diagram showing protocol segregation into smaller units
Breaking a large protocol into focused components

Principle 1: Define Minimal Interfaces

Figure out the absolute minimum a conforming type has to offer. A protocol called Serializable should require a method to convert an instance to data—not also a method to rebuild from data unless reconstruction is always needed. If some contexts need both directions, split the responsibilities.

Use Swift’s associated types to keep requirements generic. Instead of var items: [String], declare associatedtype Item and var items: [Item]. The constraint shifts to the conformer, which picks the exact type. The protocol stays indifferent about the element type, cutting unnecessary coupling.

Also ask whether a requirement can be optional. Swift protocols support optional requirements only inside @objc contexts, which limits where you can use them. A cleaner path is supplying a default implementation through a protocol extension. Conformers inherit a sensible default and override only when they need something different. For instance:

protocol Logger {
    func log(_ message: String)
    var logLevel: LogLevel { get }
}

extension Logger {
    var logLevel: LogLevel { return .info }
}

Most types accept the default log level here, so you avoid a blanket mandate that would trip up simple loggers.

Principle 2: Prefer Composition Over Inheritance

Swift’s protocol-oriented design nudges you to compose small protocols rather than build monolithic ones. Instead of one NetworkClient protocol covering request building, sending, and response parsing, break it into RequestBuilder, RequestSender, and ResponseParser. A type conforms only to the capabilities it actually uses.

This also makes testing less of a headache. A mock only implements the relevant protocol, not some giant interface. Yuki Tanaka frequently uses protocol composition in generic constraints: func fetch<T: RequestBuilder & RequestSender>(with client: T). The function spells out its needs clearly, without dumping extra requirements on the caller.

Watch out for the urge to group protocols too early. Wait until you see a real pattern of types needing the same combo before creating a composite protocol. Early grouping usually bakes in assumptions that fall apart across different use cases.

Swift playground showing protocol composition syntax
Using protocol composition in a Swift playground

Principle 3: Use Associated Types with Care

Associated types are a powerful tool, but they can backfire and over-constrain when you lash them to concrete constraints. A protocol Repository with associatedtype Entity: Codable & Identifiable forces every entity to conform to both, even if the repository only needs an identifier for fetching. Strip unnecessary constraints and let the conformer choose.

When you use a protocol with associated types as a type, you must reach for the any keyword or generics. That adds complexity. If the abstraction doesn’t really gain from the flexibility, ask whether a concrete generic type or a simpler protocol would work better. Overusing associated types can muddy the code without a clear payoff.

Another trap: requiring associated types to match across conformances. Suppose a DataSource protocol demands associatedtype Element, and then a ViewController constrains its data source to have Element == SomeSpecificModel. That forces all data sources to carry that model, even when they could serve multiple views. Keep associated type constraints local to the conformer whenever you can.

Principle 4: Use Protocol Extensions for Defaults

Protocol extensions let you add behaviour without inflating the requirement set. They’re a perfect fit for convenience methods that build on the core requirements. For example, a Validator protocol might require a single validate(_:) method. An extension can add a validateAll(_:) method that walks a collection, calling the required method each time. Conformers get batch validation for free without being forced to implement it themselves.

Be careful about overriding default implementations. Swift resolves methods dynamically for protocol requirements but statically for extension-only methods—that can lead to surprising behaviour. If a method’s logic depends on the conformer, make it a requirement. If it’s purely additive, an extension is safe ground.

Defaults also lighten the initial load on conformers. A new type can adopt the protocol with a few lines of code and refine later. That incremental approach fits Swift’s philosophy of progressive disclosure.

Principle 5: Design for Testing and Mocking

Protocols often act as seams for dependency injection. An over-constrained protocol turns mock writing into a chore because you have to stub out irrelevant requirements. Yuki Tanaka checks every protocol by writing a mock: if the mock needs stubs for methods the test never calls, the protocol’s too broad.

Think about a LocationService protocol. A minimal version might only need a method to request the current location. A bloated version could tack on authorization status, accuracy settings, and background modes. The minimal version lets a test inject a fake that always returns a fixed location, keeping the test zeroed in on the business logic instead of location infrastructure.

Use protocol inheritance to create specialised versions when you need them. FullLocationService: LocationService can add the extra capabilities without forcing them on simple consumers. That keeps the base protocol lean while allowing richer implementations.

Practical Example: Refactoring an Over-Constrained Protocol

Picture an app that shows articles from different sources. An initial design might look like:

protocol ArticleService {
    func fetchArticles() async throws -> [Article]
    func fetchArticle(id: String) async throws -> Article
    func postArticle(_ article: Article) async throws
    func deleteArticle(id: String) async throws
    var baseURL: URL { get }
    var apiKey: String { get }
}

This protocol over-constrains in a few ways. A read-only client, like a widget, has to implement posting and deleting. The baseURL and apiKey properties lock in a specific networking approach. A refactored version splits concerns:

protocol ArticleReader {
    func fetchArticles() async throws -> [Article]
    func fetchArticle(id: String) async throws -> Article
}

protocol ArticleWriter {
    func postArticle(_ article: Article) async throws
    func deleteArticle(id: String) async throws
}

protocol ArticleServiceConfig {
    var baseURL: URL { get }
    var apiKey: String { get }
}

Now a type can conform only to ArticleReader if it never writes. The configuration lives in a separate protocol, so a service can grab its config from anywhere, not necessarily by conforming itself. The design is looser and easier to test.

FAQ

How do I decide if a requirement should be in the protocol or an extension?

Put a requirement in the protocol body when conforming types need to supply custom behaviour that varies in a meaningful way. Use an extension when the behaviour can be built from existing requirements and a default implementation covers most cases. If you catch yourself overriding the extension in nearly every conformer, move the method into the protocol.

Can a protocol with no requirements be useful?

Absolutely. An empty protocol—sometimes called a marker protocol—can group types for compile-time checks or enable specific extensions. For example, protocol SecureStorage {} might have an extension that adds encryption methods, while types that don’t need encryption simply don’t conform. Use this sparingly, since it adds a layer of indirection.

What is the risk of using too many small protocols?

The big risk is a tangled web of dependencies where a type must conform to a pile of protocols just to be useful. If a typical consumer always needs five protocols together, consider whether a single protocol bundling those requirements is more practical. Balance granularity with ergonomics by watching how the protocols get used in real code.