A lightweight dependency injection framework built for Swift 6 concurrency — with explicit Sendable and MainActor APIs, zero external dependencies, and TaskLocal-based environment isolation.
- Concurrency-First Design — Swift concurrency is a first-class citizen. Sendable and MainActor constraints are part of the API, enforced by the compiler at every call site — not hidden behind
@unchecked Sendable. - Native MainActor Support — Dedicated
registerMain()/@MainService/@MainProviderfor MainActor-isolated types. Aligned with Swift 6.2 Approachable Concurrency. - Zero Dependencies — Built entirely on Swift standard library primitives (
Synchronization.Mutex,@TaskLocal). - TaskLocal Environment Isolation — Per-task environment switching for parallel-safe testing. No global state mutation needed.
- Flexible Scopes — Singleton, transient, graph, and custom named scopes for fine-grained lifecycle control.
- Familiar Patterns — register/resolve API inspired by Swinject. Property wrapper injection with modular Assembly support.
import Service
// Sendable services — safe across threads
ServiceEnv.current.register(DatabaseProtocol.self) {
DatabaseService(connectionString: "sqlite://app.db")
}
// MainActor services — for UI components, no @unchecked Sendable needed
ServiceEnv.current.registerMain(UserViewModel.self) {
UserViewModel()
}struct UserRepository {
@Service var database: DatabaseProtocol
func fetchUser(id: String) -> User? {
return database.findUser(id: id)
}
}
@MainActor
struct UserView: View {
@MainService var viewModel: UserViewModel
var body: some View {
Text(viewModel.userName)
}
}let repository = UserRepository()
let user = repository.fetchUser(id: "123")
// database is automatically injected, no manual passing needed!await ServiceEnv.$current.withValue(.test) {
ServiceEnv.current.register(DatabaseProtocol.self) {
MockDatabase()
}
let repository = UserRepository()
// All resolutions use test environment
}Control how service instances are created and cached:
// Singleton (default) — same instance reused globally
env.register(DatabaseService.self) { DatabaseService() }
// Transient — new instance every time
env.register(RequestHandler.self, scope: .transient) { RequestHandler() }
// Graph — shared within the same resolution chain
env.register(UnitOfWork.self, scope: .graph) { UnitOfWork() }
// Custom — named scope, can be selectively cleared
env.register(SessionService.self, scope: .custom("user-session")) { SessionService() }
env.resetScope(.custom("user-session")) // Clear only this scopeService provides four property wrappers in a 2x2 matrix:
| Sendable | MainActor | |
|---|---|---|
| Lazy + cached | @Service |
@MainService |
| Scope-driven | @Provider |
@MainProvider |
@Service/@MainService: Resolves once on first access, caches the result internally.@Provider/@MainProvider: Resolves on every access, caching behavior follows the registered scope.
@Provider var handler: RequestHandler // transient → new instance each access
@Service var database: DatabaseProtocol // singleton → resolved once, cachedAll four support optional types — returns nil instead of crashing when the service is not registered:
@Service var analytics: AnalyticsService?
@Provider var tracker: TrackingService?Add to your Package.swift:
dependencies: [
.package(url: "https://github.com/nslogmeng/swift-service", .upToNextMajor(from: "1.0.0"))
],
targets: [
.target(
name: "MyProject",
dependencies: [
.product(name: "Service", package: "swift-service"),
]
)
]For comprehensive guides, tutorials, and API reference, see the Service Documentation.
// Traditional way: manually pass every dependency
class UserService {
init(database: DatabaseProtocol, logger: LoggerProtocol) { ... }
}
let service = UserService(database: db, logger: logger)
// Service way: automatic injection
class UserService {
@Service var database: DatabaseProtocol
@Service var logger: LoggerProtocol
}
let service = UserService() // Dependencies automatically injected!Service uses the familiar register/resolve patterns from traditional DI containers. The key difference: concurrency constraints are part of the API, not hidden behind @unchecked Sendable. When you register with register(), the service must be Sendable. When you register with registerMain(), it lives on the main actor. The compiler enforces this at every call site — catching threading mistakes at build time, not runtime.
Service was inspired by the excellent work of Swinject and swift-dependencies.
This project is licensed under the MIT License. See the LICENSE file for details.
