xtro/swiftusecase
A lightweight, strongly typed use‑case layer for Swift. Cleanly separate **what the app does** from **how it is shown**. Ships with a macro that generates the boilerplate for you, plus clean concurrency‑first executables.
Why SwiftUseCase
Application features are easiest to reason about when business actions are modeled explicitly. A use case is a single, named operation with a typed input and a typed output. This library gives you that modeling surface with minimal ceremony, so your code reads like intent, not plumbing.
What problems it solves
- Massive ViewModels / Massive Interactors: view and presentation layers accumulate fetching, validation, mapping, feature flags and side effects. Use cases pull that back into thin, composable units.
- Hidden coupling: implicit dependencies burrow into closures and singletons. A use case exposes its dependency boundary via
Parameterand the injectedexecuteclosure. - Inconsistent async/throwing handling: different teams pick different conventions. Here every shape has a canonical protocol and executable type, so the call sites are uniform.
- Hard‑to‑test logic: with logic smeared across layers, tests become UI‑driven and brittle. A use case is a tiny function you can call directly, stub, or wrap in
AnyUseCase.
Why not just services or helpers?
Services are long‑lived bags of methods. They encourage temporal coupling and often leak transport details into call sites. A use case is short‑lived, single‑purpose, and named after the intent. You compose use cases to form flows, not the other way around. The result is clearer boundaries, easier replacement, and stronger invariants.
Design guarantees you get
- Typed contract:
ParameterandResultare explicit. Multiple inputs are auto‑wrapped into a generatedParameterstruct, zero inputs becomeVoid. - Uniform execution:
UseCase,ThrowingUseCase,AsyncUseCase,AsyncThrowingUseCaseshare the same ergonomics, includingcallAsFunction. - Cancellation‑safety: async variants play well with
Taskcancellation; bridging patterns are shown in the docs. - Macro ergonomics when you want them: annotate a function with
@Usecase(inside a type), get a concrete type plus a ready‑made static instance. No reflection, no magic at runtime.
When to introduce a use case
- The operation represents a domain action: FetchUser, UpdateProfile, ValidatePurchase.
- The logic needs isolated tests or to be reused across app and widgets/extensions.
- You want to decouple transport (URLSession, CoreData, CloudKit) from the intent.
- A team agreement is to keep ViewModels dumb: compose use cases there, don’t implement them there.
Testing and evolution
- Inject
executeto stub, spy, or time‑travel. You can also erase toAnyUseCasefor higher‑order composition. - Start without the macro to establish the contract; add
@Usecaselater to reduce boilerplate. Or do the reverse. The protocols stay stable either way.
Performance and safety
- Use cases compile down to plain functions and closures. No dynamic dispatch is required beyond what you opt into.
- Protocols are small and composable; Swift’s inlining does the rest. You pay only for what you write.
In short: if you want clean, explicit, concurrency‑friendly business logic with first‑class testability, there isn’t a better path. This is the simple, boring foundation you’ll be glad you picked six months from now.
Requirements
- Swift 5.10+
- iOS 13+ / watchOS 6+ / tvOS 13+ / macOS 10.15+
Installation (Swift Package Manager)
.package(url: "https://github.com/xtro/SwiftUseCase.git", from: "1.0.0")Targets:
.target(
name: "App",
dependencies: [
.product(name: "SwiftUseCase", package: "SwiftUseCase"),
.product(name: "SwiftUseCaseMacro", package: "SwiftUseCase") // if you use the macro
]
)Quick Start with `@Usecase`
Define your use case inside a namespace type. An enum is a good fit.
import SwiftUseCase
enum AppUsecases {}
extension AppUsecases {
@Usecase
static func fetchUser(id: Int, session: URLSession) async throws -> User {
let url = URL(string: "https://api.example.com/users/\(id)")!
let (data, _) = try await session.data(from: url)
return try JSONDecoder().decode(User.self, from: data)
}
}The macro generates next to it:
struct FetchUserUsecase: AsyncThrowingUseCasewithParameterandResultpublic static let fetchUser = FetchUserUsecase()onAppUsecases
Using the generated API
Prefer the static instance for clarity:
let user = try await AppUsecases.fetchUser(.init(id: 1, session: .shared))Or access the concrete type directly:
let uc = AppUsecases.FetchUserUsecase()
let user = try await uc(.init(id: 1, session: .shared))Multiple parameters are automatically wrapped into a Parameter struct. No parameters result in Parameter == Void and you can call uc() with no arguments.
Without the macro
You can write the same thing explicitly:
struct ValidateEmail: ThrowingUseCase {
typealias Parameter = String
typealias Result = Void
var execute: ThrowingExecutable<Parameter, Result> { { email in
guard email.contains("@") else { throw ValidationError.invalid }
} }
}
try ValidateEmail()("me@example.com")Cancellation‑aware bridging example
A more realistic modern scenario: uploading data with progress and retry policy. The macro handles the boilerplate, you focus on intent.
extension AppUsecases {
@Usecase
static func uploadImage(_ image: UIImage, to url: URL, session: URLSession) async throws -> URLResponse {
let data = image.jpegData(compressionQuality: 0.8)!
for attempt in 1...3 {
do {
var request = URLRequest(url: url)
request.httpMethod = "POST"
let (responseData, response) = try await session.upload(for: request, from: data)
guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
throw URLError(.badServerResponse)
}
print("✅ Upload success after attempt #\(attempt)")
return http
} catch {
print("⚠️ Upload attempt #\(attempt) failed: \(error)")
if attempt == 3 { throw error }
try await Task.sleep(for: .seconds(Double(attempt))) // exponential-ish backoff
}
}
throw URLError(.cannotConnectToHost)
}
}This example showcases async/throwing flow control, retry logic, and cancellation safety without clutter. The macro turns it into a fully‑typed, testable AsyncThrowingUseCase with a static uploadImage instance ready to call.
Testing
Inject a custom execute for stubs and spies.
let stub = AppUsecases.FetchUserUsecase { _ in .init(id: 0, name: "Stub") }
let user = try await stub(.init(id: 42, session: .shared))You can also erase types:
let any: AnyUseCase<AppUsecases.FetchUserUsecase.Parameter, AppUsecases.FetchUserUsecase.Result>
= .init(AppUsecases.FetchUserUsecase())Combine publishers and callback helpers live in AnyUseCase+Combine and *+Callbacks.
API Surface
The library exposes four main protocol families, each covering one execution flavor so your business logic can express exactly what it needs—no more, no less.
UseCase/Executable<Parameter, Result>— for synchronous, pure functions. Ideal for lightweight computations or formatting.ThrowingUseCase/ThrowingExecutable<Parameter, Result>— same as above but can throw. Use when validation or failure paths are part of the contract.AsyncUseCase/AsyncExecutable<Parameter, Result>— asynchronous but non‑throwing. Fits I/O or concurrent jobs that always succeed logically.AsyncThrowingUseCase/AsyncThrowingExecutable<Parameter, Result>— the full async + error model; your network or database boundaries usually live here.AnyUseCase— type erasure wrapper that hides generics so you can store heterogeneous use cases in arrays, dependency containers, or pass them around dynamically.
Each protocol shares the same shape and ergonomics: a Parameter, a Result, and an execute closure. The uniformity means once you know one, you know them all.
Documentation
DocC catalog included at Documentation/SwiftUseCase.docc. In Xcode use Product → Build Documentation.
License
MIT. See LICENSE.
Package Metadata
Repository: xtro/swiftusecase
Default branch: main
README: README.md