Designing Protocols for @Published Properties: Best Practices and Testing in Swift

abdul ahad
4 min read5 days ago
Photo by Tim Mossholder on Unsplash

Swift developers often face challenges when designing protocols for classes that use @Published properties, especially in MVVM architectures with Combine. We will explore how to abstract @Published properties effectively, why they are often best avoided in protocols, and how to structure your services and view models for clean, testable code.

The Problem: Exposing @Published in Protocols

@Published is a property wrapper in Swift that broadcasts changes to subscribers. It’s commonly used in classes to notify other parts of the app (like views) about state changes. For example:

class CoinDataService {
@Published var allCoins: [CoinModel] = []
var coinSubscription: AnyCancellable?
private let networkManager: NetworkManagerProtocol

init(networkManager: NetworkManagerProtocol) {
self.networkManager = networkManager
getCoins()
}

func getCoins() {
guard let url = URL(string: "...") else { return }
coinSubscription = networkManager.fetchData(from: url, type: [CoinModel].self)
.sink { completion in
if case let .failure(error) = completion {
print(error.localizedDescription)
}
} receiveValue: { [weak self] coins in
self?.allCoins = coins
self?.coinSubscription?.cancel()
}
}
}

The challenge was designing a protocol for CoinDataService that included the @Published property (allCoins). A proposed solution was:

protocol CoinDataServiceProtocol {
var allCoinsPublisher: AnyPublisher<[CoinModel], Never> { get }
func getCoins()
}

extension CoinDataService: CoinDataServiceProtocol {
var allCoinsPublisher: AnyPublisher<[CoinModel], Never> {
$allCoins.eraseToAnyPublisher()
}
}

While functional, this approach introduces unnecessary complexity by exposing an extra property (allCoinsPublisher).

Why Exposing @Published is Problematic

1. Implementation Details Should Be Hidden

@Published is an implementation detail. Exposing it via protocols violates the principle of encapsulation and ties the protocol to a specific implementation.

Better Approach: Expose behavior instead of state. Protocols should focus on defining methods, not how state is managed internally.

protocol CoinDataServiceProtocol {
func fetchCoins(completion: @escaping ([CoinModel]) -> Void)
}

2. Coupling to Combine

By exposing @Published (or its publisher) in the protocol, you’re coupling the abstraction to Combine. This makes testing and future refactoring harder, especially if you need to switch to a different reactive framework.

3. Stateless Services Are Easier to Test

Published properties are better suited for view models where state needs to drive the UI. Services should remain stateless, focusing solely on fetching and providing data.

Recommended Approach: Move State Management to the ViewModel

A clean solution is to keep services stateless and let the view model handle state management with @Published.

Refactored Design

Service Protocol and Implementation

protocol CoinDataServiceProtocol {
func fetchCoins(completion: @escaping ([CoinModel]) -> Void)
}

class CoinDataService: CoinDataServiceProtocol {
private let networkManager: NetworkManagerProtocol
init(networkManager: NetworkManagerProtocol) {
self.networkManager = networkManager
}
func fetchCoins(completion: @escaping ([CoinModel]) -> Void) {
guard let url = URL(string: "...") else { return }
networkManager.fetchData(from: url, type: [CoinModel].self)
.sink(receiveCompletion: { _ in }, receiveValue: { coins in
completion(coins)
})
.store(in: &subscriptions)
}
}

ViewModel

The view model subscribes to the service and uses @Published to broadcast changes.

class CoinViewModel: ObservableObject {
@Published var allCoins: [CoinModel] = []
private let dataService: CoinDataServiceProtocol

init(dataService: CoinDataServiceProtocol) {
self.dataService = dataService
}
func fetchCoins() {
dataService.fetchCoins { [weak self] coins in
self?.allCoins = coins
}
}
}

Testing the ViewModel

Testing becomes simpler with this approach. You can mock the service to simulate different scenarios.

Mock Service

class MockCoinDataService: CoinDataServiceProtocol {
var coins: [CoinModel] = []

func fetchCoins(completion: @escaping ([CoinModel]) -> Void) {
completion(coins)
}
}

ViewModel Test

func test_viewModelUpdatesAllCoins() {
let mockService = MockCoinDataService()
mockService.coins = [CoinModel(name: "Bitcoin")]

let viewModel = CoinViewModel(dataService: mockService)
viewModel.fetchCoins()
XCTAssertEqual(viewModel.allCoins.count, 1)
XCTAssertEqual(viewModel.allCoins.first?.name, "Bitcoin")
}

When to Use @Published in Services

In some cases, a service might need @Published, such as for real-time data (e.g., WebSockets). When this is necessary:

  1. Keep @Published private.
  2. Expose the publisher through a protocol only when absolutely needed.

Example: Real-Time Data Service

protocol RealTimeDataServiceProtocol {
var coinsPublisher: AnyPublisher<[CoinModel], Never> { get }
}

class RealTimeDataService: RealTimeDataServiceProtocol {
@Published private var coins: [CoinModel] = []
var coinsPublisher: AnyPublisher<[CoinModel], Never> {
$coins.eraseToAnyPublisher()
}
func startListening() {
Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
self.coins.append(CoinModel(name: "New Coin"))
}
}
}

Testing Real-Time Data

func test_realTimeUpdates() {
let service = RealTimeDataService()
let expectation = XCTestExpectation(description: "Receive real-time updates")

let cancellable = service.coinsPublisher
.sink { coins in
if coins.count > 0 {
expectation.fulfill()
}
}
service.startListening()
wait(for: [expectation], timeout: 2.0)
cancellable.cancel()
}

Summary

  1. Avoid Exposing @Published: Treat it as an implementation detail and avoid including it in protocols.
  2. Stateless Services: Keep services focused on fetching and providing data, leaving state management to view models.
  3. Real-Time Data: When @Published is necessary, keep it private and expose a publisher only if required.
  4. Testing: Use mock services for testing view models, and Combine utilities for testing publishers.

By following these guidelines, you can create clean, testable architectures that minimize coupling and maximize flexibility in your Swift projects.

--

--

abdul ahad
abdul ahad

Written by abdul ahad

A software developer dreaming to reach the top and also passionate about sports and language learning

No responses yet