Where Errors Belong: Use Case Outputs, Not Error Passed Around
A ViewModel doing error as? WrongPasswordError is not a ViewModel doing its job. It is a ViewModel cleaning up after a boundary that gave up on meaning.
This article is about where errors belong in an application, and the small but important habit that keeps Error from leaking across layers.
The villain is the downcast. The reason the downcast exists is that someone treated the use case like a function that returns, and stuffed every kind of failure into a single generic Error.
If we fix the boundary shape, the downcast disappears.
#The Pain
The use case is doing the right thing: triggered from a feature factory or view intent, calling back through an output protocol. The output protocol is where it falls apart.
protocol LoginUseCaseOutput {
func onLoginStarted()
func onLoginSucceeded()
func onLoginFailed(_ error: Error)
}
@MainActor
final class LoginViewModel: ObservableObject, LoginUseCaseOutput {
@Published private(set) var state: State = .idle
func onLoginStarted() {
state = .loading
}
func onLoginSucceeded() {
state = .loggedIn
}
func onLoginFailed(_ error: Error) {
if error is WrongPasswordError {
state = .wrongPassword(email: "")
} else if error is AccountLockedError {
state = .accountLocked
} else if (error as NSError).domain == NSURLErrorDomain {
state = .offline
} else {
state = .failed("Something went wrong.")
}
}
}
Or its Kotlin twin:
interface LoginUseCaseOutput {
fun onLoginStarted()
fun onLoginSucceeded()
fun onLoginFailed(error: Throwable)
}
class LoginViewModel : ViewModel(), LoginUseCaseOutput {
val state = MutableStateFlow<LoginState>(LoginState.Idle)
override fun onLoginStarted() { state.value = LoginState.Loading }
override fun onLoginSucceeded() { state.value = LoginState.LoggedIn }
override fun onLoginFailed(error: Throwable) {
state.value = when (error) {
is WrongPasswordError -> LoginState.WrongPassword(email = "")
is AccountLockedError -> LoginState.AccountLocked
is IOException -> LoginState.Offline
else -> LoginState.Failed("Something went wrong.")
}
}
}
This is not unusual code. It works. It passes tests. The output protocol is even structured the way I have recommended in earlier posts: a use case calling back through a named protocol, a ViewModel implementing it, no direct dependency from view to use case.
The smell is the (_ error: Error) parameter. The ViewModel is rendering state and interpreting an opaque error to decide what happened. Worse: it has lost context the use case had at the moment of failure. The wrongPassword case wants the email back. The output handed an Error and expected the ViewModel to reconstruct meaning from it.
That interpretation is not the ViewModel's job. The ViewModel should never have to guess what the use case meant.
Why Generic Error Happens
It is not laziness. Inside the use case, repositories throw or return Result, and Error is the natural type for everything to converge on. When it comes time to design the output, the path of least resistance is to mirror what the use case already has: an Error parameter on a single didFail callback.
That decision feels like translation. It is not. It is forwarding.
The cost of "lose meaning" does not show up until a screen needs to distinguish a wrong password from a locked account from a network blip. By the time the screen needs that, the output has already been written with _ error: Error. Now the ViewModel has to recover meaning that the boundary discarded. The downcast is the symptom.
The fix is not "throw a better error type." The fix is to ask a different question: what shape should the use case present its failures in, on the output?
#The Reframe: Use Cases Call Outputs, They Do Not Return
In Use Cases in iOS Apps: Helpful Boundary or Extra Layer? I described use cases as application operations that coordinate dependencies and report results through an output protocol.
A use case does not hand a value back to its caller. It calls back on an output, in the output's vocabulary, with the meanings the screen cares about.
That includes failure. Failure is part of the output's vocabulary, not a return value, not a throw, and never a raw Error handed across the boundary.
Honest note: in earlier posts I showed outputs like didFail(_ error: Error). That style is convenient and I am not going to pretend it never reads fine. But it has the same hidden cost as throwing Error from a return type. The screen still has to interpret. This article is the version of that idea I would write today.
#Two Shapes For Failure On The Output
There are two reasonable ways to model failures on an output.
(b) Method per failure. Each meaningful failure gets its own callback.
protocol LoginUseCaseOutput {
func onLoginStarted()
func onLoginSucceeded()
func onWrongPassword(forEmail email: String)
func onAccountLocked(forEmail email: String)
func onOffline()
func onUnknownLoginError()
}
interface LoginUseCaseOutput {
fun onLoginStarted()
fun onLoginSucceeded()
fun onWrongPassword(email: String)
fun onAccountLocked(email: String)
fun onOffline()
fun onUnknownLoginError()
}
(a) One method, typed enum payload. A single failure callback carries a sealed type describing what happened.
enum LoginFailure {
case wrongPassword(email: String)
case accountLocked(email: String)
case offline
case unknown
}
protocol LoginUseCaseOutput {
func onLoginStarted()
func onLoginSucceeded()
func onLoginFailed(_ failure: LoginFailure)
}
sealed interface LoginFailure {
data class WrongPassword(val email: String) : LoginFailure
data class AccountLocked(val email: String) : LoginFailure
data object Offline : LoginFailure
data object Unknown : LoginFailure
}
interface LoginUseCaseOutput {
fun onLoginStarted()
fun onLoginSucceeded()
fun onLoginFailed(failure: LoginFailure)
}
Both styles communicate the same set of meanings. The difference is how the callback site reads.
I default to (b). The output reads as a vocabulary the screen can answer directly, every failure has a name, and the ViewModel becomes a list of small focused methods.
I reach for (a) when failures all map to the same UI treatment, or when the failure set is genuinely open-ended and the discipline of "one method per case" stops paying for itself. A sync operation with a dozen possible infrastructural failures probably wants a sealed type rather than a dozen methods.
Both are valid. Pick by what the screen actually needs to do with the failure.
#Inside The Use Case: Throws And Results Are Fine
The output shape is a boundary rule. It does not dictate how the use case implements itself.
Inside, repositories and services can throw or return Result. Swift 6 typed throws are fine. Kotlin sealed results are fine. The point is what leaves the use case through its output, not what travels inside it.
struct LoginUseCase {
let authenticationRepository: AuthenticationRepository
let sessionStore: SessionStore
let output: LoginUseCaseOutput
func execute(email: String, password: String) async {
output.onLoginStarted()
do {
let session = try await authenticationRepository.login(
email: email,
password: password
)
try await sessionStore.save(session)
output.onLoginSucceeded()
} catch let error as AuthenticationRepositoryError {
switch error {
case .wrongPassword:
output.onWrongPassword(forEmail: email)
case .accountLocked:
output.onAccountLocked(forEmail: email)
case .offline:
output.onOffline()
case .unknown:
output.onUnknownLoginError()
}
} catch {
output.onUnknownLoginError()
}
}
}
class LoginUseCase(
private val authenticationRepository: AuthenticationRepository,
private val sessionStore: SessionStore,
private val output: LoginUseCaseOutput,
) {
suspend fun execute(email: String, password: String) {
output.onLoginStarted()
try {
val session = authenticationRepository.login(email, password)
sessionStore.save(session)
output.onLoginSucceeded()
} catch (e: AuthenticationRepositoryError.WrongPassword) {
output.onWrongPassword(email)
} catch (e: AuthenticationRepositoryError.AccountLocked) {
output.onAccountLocked(email)
} catch (e: AuthenticationRepositoryError.Offline) {
output.onOffline()
} catch (e: Throwable) {
output.onUnknownLoginError()
}
}
}
The repository tells the use case what went wrong using a typed vocabulary the repository owns. The use case translates that into the output's vocabulary. The repository's error type and the use case's output are not the same thing, and they should not be. They serve different audiences. See The Repository Pattern in Swift: A Useful Abstraction for how that translation responsibility sits on the repository side.
#The Encapsulation Rule
Underlying errors travel within a layer as opaque payloads for logging. They never cross a boundary outward.
A repository may carry an underlying error for diagnostics:
enum AuthenticationRepositoryError: Error {
case wrongPassword
case accountLocked
case offline
case unknown(underlying: Error)
}
The repository can log .unknown(underlying:) with the original error attached. That detail is useful for crash reporting and for understanding what happened in production.
What it cannot do is leak that underlying outside the repository. The use case sees AuthenticationRepositoryError.unknown(_), decides what it means in domain terms, and calls output.onUnknownLoginError(). The Error instance stays in the layer that knows what to do with it.
This rule is what makes the practice usable: you do not lose crash diagnostics, because logging happens at the layer that has the type information. You just stop letting that information escape upward, where it would only invite ViewModels to start interpreting it.
#The ViewModel Side: Methods Are Easier To Test
The two output shapes lead to different ViewModel code.
Method-per-failure ViewModel. Each callback is a small, focused method.
@MainActor
final class LoginViewModel: ObservableObject, LoginUseCaseOutput {
@Published private(set) var state: State = .idle
func onLoginStarted() {
state = .loading
}
func onLoginSucceeded() {
state = .loggedIn
}
func onWrongPassword(forEmail email: String) {
state = .wrongPassword(email: email)
}
func onAccountLocked(forEmail email: String) {
state = .accountLocked
}
func onOffline() {
state = .offline
}
func onUnknownLoginError() {
state = .failed("Could not log in.")
}
}
Enum-payload ViewModel. One callback, switch on the case.
@MainActor
final class LoginViewModel: ObservableObject, LoginUseCaseOutput {
@Published private(set) var state: State = .idle
func onLoginStarted() { state = .loading }
func onLoginSucceeded() { state = .loggedIn }
func onLoginFailed(_ failure: LoginFailure) {
switch failure {
case .wrongPassword(let email): state = .wrongPassword(email: email)
case .accountLocked(let email): state = .accountLocked
case .offline: state = .offline
case .unknown: state = .failed("Could not log in.")
}
}
}
Both are correct. The method-per-failure version is easier to test in small focused units:
func test_onWrongPassword_setsWrongPasswordState() {
let sut = LoginViewModel()
sut.onWrongPassword(forEmail: "a@b.com")
XCTAssertEqual(sut.state, .wrongPassword(email: "a@b.com"))
}
The enum version requires the test to build the case first. It is not a big difference, but it adds up across many use cases and many screens. Smaller methods make smaller tests.
See Testing Architecture, Not Implementation Details for how this style of test gives you confidence in the boundary rather than the internals.
#What You Should Remember
Error is not a boundary type.
Let use cases call outputs in the output's own vocabulary. Prefer one method per meaningful failure when the screen reacts differently to each. Use a typed enum payload when failures share UI treatment or the set is genuinely open. Keep underlying errors inside the layer that produced them. Log there, do not leak upward. Stop downcasting in ViewModels. The boundary is wrong, not the cast.