Designing Protocols for @Published
Properties: Best Practices and Testing in Swift
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:
- Keep
@Published
private. - 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
- Avoid Exposing
@Published
: Treat it as an implementation detail and avoid including it in protocols. - Stateless Services: Keep services focused on fetching and providing data, leaving state management to view models.
- Real-Time Data: When
@Published
is necessary, keep it private and expose a publisher only if required. - 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.