Skip to content
ExponaBLOG
SWIFT, SWIFTUI & IOS APPS · DAY 007 / 100

Swift protocols: Swift & iOS best practices explained

Define behavior, not inheritance.

Expona Admin10 min read

By day seven of your Swift journey, you've written structs, classes, and enums—but you're probably noticing yourself repeating similar requirements across different types. This is where protocols shine: they let you define behavior contracts without forcing inheritance hierarchies. In this article, we'll explore Swift protocols: Swift & iOS best practices explained, showing you how to build flexible, composable systems that senior teams rely on in production.

What are protocols and why they matter

A protocol is a blueprint for functionality. It declares what properties and methods a type must have, but it doesn't provide the implementation—that's up to whoever adopts the protocol. Think of it like an interface contract: "If you conform to this protocol, you must implement these specific behaviors."

This is fundamentally different from inheritance. With a class hierarchy, you're locked into a tree structure where a child inherits everything from its parent. With protocols, you're saying "here's what I need from you," and any type—struct, class, enum, or even an actor—can conform if it provides the goods.

Why does this matter in production? Because inheritance tends to create rigid, tightly coupled code. We've all seen deep hierarchies like Animal → Mammal → Dog → LabradorRetriever that break the moment you need a dog that can fly. Protocols let you describe capabilities independently, so types can mix and match behaviors without fighting a hierarchy.

Protocols—Declare required properties and methods

Let's start with the basics. A protocol declares what conforming types must provide, without saying how they should do it.

swift
protocol PaymentProcessor {
    var isReady: Bool { get }
    var transactionFeePercentage: Double { get set }
    
    func processPayment(amount: Decimal, for user: User) async throws -> TransactionReceipt
    func refund(receipt: TransactionReceipt) async throws
}

Notice a few things here:

  • Properties can be read-only (get) or read-write (get set).
  • Methods don't have implementations—just signatures.
  • You can mark methods as async throws to enforce error handling and asynchrony at the protocol level.

Now, a concrete type can conform. Let's say we're building an e-commerce platform and need a Stripe processor:

swift
struct StripeProcessor: PaymentProcessor {
    var isReady: Bool = true
    var transactionFeePercentage: Double = 2.9
    private let apiKey: String
    
    init(apiKey: String) {
        self.apiKey = apiKey
    }
    
    func processPayment(amount: Decimal, for user: User) async throws -> TransactionReceipt {
        let stripePaymentIntent = try await stripe.createPaymentIntent(
            amount: amount,
            customer: user.id,
            apiKey: apiKey
        )
        return TransactionReceipt(
            transactionId: stripePaymentIntent.id,
            amount: amount,
            processor: "stripe",
            timestamp: Date()
        )
    }
    
    func refund(receipt: TransactionReceipt) async throws {
        try await stripe.refundPaymentIntent(id: receipt.transactionId, apiKey: apiKey)
    }
}

A struct can conform, and you provide implementations for every required property and method. If you forget one, the compiler will error. This is one of Swift's superpowers: protocol conformance is checked at compile time.

Conformance—Structs, classes and enums can conform

Any type can adopt a protocol. Let's illustrate with a real scenario: imagine we need multiple payment processors for different regions or payment methods.

swift
class PayPalProcessor: PaymentProcessor {
    var isReady: Bool {
        return tokenManager.hasValidToken()
    }
    
    var transactionFeePercentage: Double = 3.5
    private let tokenManager: PayPalTokenManager
    
    init(tokenManager: PayPalTokenManager) {
        self.tokenManager = tokenManager
    }
    
    func processPayment(amount: Decimal, for user: User) async throws -> TransactionReceipt {
        let transaction = try await paypalAPI.executePayment(
            amount: amount,
            payerEmail: user.email,
            token: tokenManager.currentToken()
        )
        return TransactionReceipt(
            transactionId: transaction.id,
            amount: amount,
            processor: "paypal",
            timestamp: Date()
        )
    }
    
    func refund(receipt: TransactionReceipt) async throws {
        try await paypalAPI.refundTransaction(id: receipt.transactionId)
    }
}

We used a class here instead of a struct. The protocol doesn't care. You can also use enums if your conformance needs are simple:

swift
enum MockPaymentProcessor: PaymentProcessor {
    case success
    case failure
    
    var isReady: Bool { true }
    var transactionFeePercentage: Double { 0.0 }
    
    func processPayment(amount: Decimal, for user: User) async throws -> TransactionReceipt {
        switch self {
        case .success:
            return TransactionReceipt(transactionId: UUID().uuidString, amount: amount, processor: "mock", timestamp: Date())
        case .failure:
            throw PaymentError.processingFailed
        }
    }
    
    func refund(receipt: TransactionReceipt) async throws {
        // Mock refund logic
    }
}

Now here's the magic: your checkout code doesn't need to know which processor it's using. It just needs a PaymentProcessor:

swift
class CheckoutController {
    private let processor: PaymentProcessor
    
    init(processor: PaymentProcessor) {
        self.processor = processor
    }
    
    func completeCheckout(for user: User, amount: Decimal) async throws -> TransactionReceipt {
        guard processor.isReady else {
            throw PaymentError.processorNotReady
        }
        return try await processor.processPayment(amount: amount, for: user)
    }
}

This is dependency injection in action. You wire in a different processor at initialization—Stripe in production, mock in tests—and the checkout logic stays untouched. That's the composability protocols give us.

Protocol extensions—Provide default implementations

Sometimes you don't want every conforming type to reimplement the same logic. That's where protocol extensions come in. They let you provide default implementations that adopters can override if needed.

swift
extension PaymentProcessor {
    func processPaymentWithFee(amount: Decimal, for user: User) async throws -> TransactionReceipt {
        let fee = amount * Decimal(transactionFeePercentage) / 100
        let total = amount + fee
        return try await processPayment(amount: total, for: user)
    }
    
    func isValidTransaction(receipt: TransactionReceipt) -> Bool {
        let hoursSinceTransaction = Calendar.current.dateComponents(
            [.hour],
            from: receipt.timestamp,
            to: Date()
        ).hour ?? 0
        return hoursSinceTransaction < 24
    }
}

Now every conforming type gets these methods for free. If Stripe and PayPal both need to check transaction validity the same way, you've eliminated duplication. And if one processor needs different logic, it can override:

swift
extension StripeProcessor {
    override func isValidTransaction(receipt: TransactionReceipt) -> Bool {
        // Stripe keeps transactions valid for 7 days instead of 24 hours
        let daysSinceTransaction = Calendar.current.dateComponents(
            [.day],
            from: receipt.timestamp,
            to: Date()
        ).day ?? 0
        return daysSinceTransaction < 7
    }
}

Protocol extensions are powerful, but use them carefully. They can hide behavior. A junior developer might not realize that processPaymentWithFee comes from the protocol extension, not the concrete type. Document and test them well.

Protocol-oriented design—Compose small protocols instead of deep class hierarchies

The real power emerges when you design systems around protocols from the start. Instead of building one monolithic PaymentProcessor protocol, break it into focused, single-responsibility protocols that compose together.

swift
protocol PaymentAuthorizer {
    func authorize(amount: Decimal, for user: User) async throws -> String
}

protocol PaymentCapture {
    func capture(authorizationToken: String, amount: Decimal) async throws -> String
}

protocol Refundable {
    func refund(transactionId: String, amount: Decimal) async throws
}

protocol TransactionLogger {
    func logTransaction(_ receipt: TransactionReceipt) async throws
}

Now you can compose these into richer types:

swift
struct StripePaymentService: PaymentAuthorizer, PaymentCapture, Refundable, TransactionLogger {
    private let apiKey: String
    private let database: TransactionDatabase
    
    func authorize(amount: Decimal, for user: User) async throws -> String {
        let intent = try await stripe.createPaymentIntent(amount: amount, apiKey: apiKey)
        return intent.clientSecret
    }
    
    func capture(authorizationToken: String, amount: Decimal) async throws -> String {
        let confirmation = try await stripe.confirmPaymentIntent(clientSecret: authorizationToken, apiKey: apiKey)
        return confirmation.id
    }
    
    func refund(transactionId: String, amount: Decimal) async throws {
        try await stripe.refund(paymentIntentId: transactionId, apiKey: apiKey)
    }
    
    func logTransaction(_ receipt: TransactionReceipt) async throws {
        try await database.insert(receipt)
    }
}

And in your checkout flow, you request exactly what you need:

swift
class AuthorizeThenCaptureCheckout {
    private let authorizer: PaymentAuthorizer
    private let capturer: PaymentCapture
    private let logger: TransactionLogger
    
    init(authorizer: PaymentAuthorizer, capturer: PaymentCapture, logger: TransactionLogger) {
        self.authorizer = authorizer
        self.capturer = capturer
        self.logger = logger
    }
    
    func processOrder(for user: User, amount: Decimal) async throws -> String {
        let authToken = try await authorizer.authorize(amount: amount, for: user)
        let transactionId = try await capturer.capture(authorizationToken: authToken, amount: amount)
        let receipt = TransactionReceipt(transactionId: transactionId, amount: amount, processor: "stripe", timestamp: Date())
        try await logger.logTransaction(receipt)
        return transactionId
    }
}

This is protocol-oriented design. You're not forced into a deep hierarchy; you're composing behaviors that make sense for your use case. A different payment flow might use PaymentAuthorizer and Refundable but not PaymentCapture. The system adapts to you, not the other way around.

Common mistakes

Over-designing with protocols. Not every type needs to conform to a protocol. If you have one struct that does one thing and won't change, a protocol is overhead. Protocols are for sharing contracts across multiple types or enabling dependency injection.

Making protocols too broad. A protocol with 15 required methods is a smell that you're trying to do too much. Keep them focused. Ask: "Could a realistic type conform to this without doing things it shouldn't?"

Ignoring associated types. Sometimes you need a protocol to work with generic types. associatedtype lets you do that, but it adds complexity. Use it when you need type-level flexibility, not by default.

swift
protocol Repository {
    associatedtype Item
    func fetch(id: String) async throws -> Item
    func save(_ item: Item) async throws
}

struct UserRepository: Repository {
    typealias Item = User
    
    func fetch(id: String) async throws -> User {
        // Implementation
    }
    
    func save(_ item: User) async throws {
        // Implementation
    }
}

Forgetting that protocols are compiler-checked. The compiler will catch missing implementations, but it won't catch semantic bugs. If a conforming type implements a method incorrectly (wrong side effects, wrong return values), you won't know until runtime. Write tests that verify protocol-conforming types behave correctly.

Key takeaways

  • Protocols define behavior contracts without inheritance, making code more flexible and composable.
  • Any type—struct, class, enum, actor—can conform to a protocol, checked at compile time.
  • Protocol extensions provide default implementations, reducing duplication without forcing inheritance.
  • Protocol-oriented design means building systems from many small, focused protocols rather than deep class hierarchies.
  • Use protocols for dependency injection and testing; avoid them for one-off types or over-broad interfaces.
  • Protocols declare what, not how; implementations provide the how.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles