The natural way to share observable state in Combine is CurrentValueSubject. It publishes new values, holds the last one, and lets observers read it via .value. The trouble is that the same reference also lets observers call .send(_:). Anyone with a copy of the subject can mutate the state.
In a small app this is fine. In an app with auth state, language preference, network reachability, and a dozen other domain values, it isn’t. State should have one writer. Reactive consumers should observe, not mutate.
The fix is a read-only wrapper. A Publisher that exposes currentValue for synchronous reads and conforms to Publisher for streams, but does not expose .send. The owner keeps a CurrentValueSubject private and hands out the wrapper instead.
public class DomainProperty<Value>: Publisher {
public typealias Output = Value
public typealias Failure = Never
private let publisher: AnyPublisher<Value, Never>
public var currentValue: Value { /* synchronously reads from publisher */ }
public func receive<S>(subscriber: S) where S: Subscriber,
S.Failure == Never, S.Input == Value {
publisher.receive(subscriber: subscriber)
}
}
extension Publisher where Failure == Never {
func domainProperty() -> DomainProperty<Output> { DomainProperty(self) }
}
The owner uses it like this:
private let _state = CurrentValueSubject<AuthState, Never>(.unauthenticated)
public var state: DomainProperty<AuthState> { _state.domainProperty() }
_state.send(newValue) is still possible inside the owner. Outside, the only thing visible is a publisher that observers can read from. The single-writer invariant is enforced by the type system, not by convention or code review.
The pattern is borrowed from the UK Health Security Agency’s COVID-19 iOS app, where it does the same job. It’s the kind of small abstraction that quietly raises the floor of every domain object using it.