A macro-based dependency injection framework for Swift. Declare your dependencies as plain methods, and Resolve generates a thread-safe, concurrent resolver at compile time.
Compile-time generation · Per-resolver or process lifetime · Modular composition · Sendable by default · No runtime dependencies
import Resolve
@Resolvable
struct AppContainer {
@Register
func logger() -> Logger {
Logger(subsystem: "com.app", category: "main")
}
@Register
func database(_ resolver: Resolver) async throws -> Database {
try await Database(logger: resolver.logger)
}
@Perform
func analytics(_ resolver: Resolver) async {
await Analytics.configure(logger: resolver.logger)
}
}
let resolver = AppContainer.Resolver(.init())
let resolved = try await resolver.resolve()
resolved.database // ready to use| Swift | Platforms |
|---|---|
| 6.3+ | macOS 14.0+, iOS 17.0+, watchOS 10.0+ |
dependencies: [
.package(url: "https://github.com/letko-dmitry/Resolve.git", from: "1.1.1")
].target(
name: "MyApp",
dependencies: [
.product(name: "Resolve", package: "Resolve")
]
)The @Resolvable macro generates two nested types from your container:
Resolver-- a facade that exposes each@Registeras an async property and each@Performas an async method. Callingresolve()builds the entire dependency graph.Resolved-- an immutable,Sendableaggregate holding all resolved dependencies. This is what you pass around your app.
All non-transient registrations start concurrently via async let. All @Perform steps run in parallel inside a task group. Dependencies that reference siblings through Resolver naturally await each other, forming an implicit DAG -- and a cycle among them is rejected at compile time rather than deadlocking at runtime.
Every dependency is built at most once per Resolver. Concurrent callers share a single in-flight Task, so a value referenced by five siblings is still constructed once.
Attach to a struct or a class. Any other declaration -- an extension, an enum, an actor, a protocol -- is rejected with a diagnostic.
Only functions declared directly in the body of the annotated type are inspected. A @Register placed in an extension or inside a nested type expands to nothing, and the macro warns at the attribute so you find out at build time. A member wrapped in #if is skipped silently -- put the conditional inside the factory body instead.
Resolved and Resolver repeat the access level of the container, so a public container produces types other modules can name. A public container must also be explicitly Sendable: public types get no implicit conformance, and Resolver stores one.
The parameter sort controls the order in which registrations are emitted:
@Resolvable(sort: false) // declaration order; the default is alphabetical
struct Services { … }Sorting is by the registration name -- the name: override when present, otherwise the function name. It only decides the order of the properties on Resolved, and with it the order of that type's memberwise initialiser. Nothing about what is built, or when, changes.
Marks a method as a dependency factory. The return value is cached and exposed on Resolved.
Methods may be synchronous, async, throws, or async throws. They may take zero parameters or a single Resolver parameter to pull sibling dependencies.
A factory must be an instance method with a concrete return type. static, class, mutating, generic and opaque-returning (-> some P) methods are rejected: the return type has to work as a stored property on Resolved, and the factory has to be callable on an instance.
@Resolvable
struct Services {
// Synchronous, no dependencies.
@Register
func featureFlags() -> FeatureFlags {
FeatureFlags()
}
// Throwing, used by siblings.
@Register
func exerciseBundle() throws -> ExerciseBundle {
try ExerciseBundleReader().read()
}
// Depends on a sibling via Resolver. Every generated getter is `async`, and
// this one reads a throwing sibling, so the factory must be `async throws`.
@Register
func exerciseDataSource(_ resolver: Resolver) async throws -> ExerciseDataSource {
try await ExerciseDataSource(bundle: resolver.exerciseBundle)
}
// Async, no throws.
@Register
func watchController() async -> WatchController {
let controller = WatchController()
await controller.activate()
return controller
}
// Async + throwing.
@Register
func database() async throws -> Database {
try await Database.open(path: "app.db")
}
// Async + throwing, depends on siblings via Resolver.
@Register
func exerciseLibrary(_ resolver: Resolver) async throws -> ExerciseLibrary {
try await ExerciseLibrary(
database: resolver.database,
source: resolver.exerciseDataSource
)
}
// Process-lifetime singleton -- created once, survives Resolver recreation.
@Register(options: .once)
func remoteConfiguration() async -> RemoteConfiguration {
await RemoteConfiguration.fetch()
}
}Reading a sibling always needs
await. Every generated getter isasync, whatever the factory behind it looks like. A factory that touchesresolver.somethingElseis thereforeasynctoo, andasync throwsif the sibling throws.
name: decouples the exposed name from the factory's name. It renames the property on both Resolved and Resolver, and it becomes the cache key:
@Register(name: "pipeline")
func makePipeline(_ resolver: Resolver) async -> Pipeline {
await Pipeline(database: resolver.database)
}
resolved.pipeline // not `resolved.makePipeline`The value must be a string literal and a legal Swift identifier -- it is spliced into a property declaration. @Register(name: someConstant) is ignored with a warning; @Register(name: "foo bar") is an error.
Same as @Register, but the value is not included in Resolved. Use for intermediate wiring -- child resolvers, builders, or helpers needed only during graph construction. Still cached and callable on Resolver.
@Resolvable
struct WiringAssembly {
let essential: Essential
// Synchronous transient -- cheap to build, used by siblings.
@RegisterTransient
func thirdPartyResolver() -> ThirdPartyModule.Resolver {
ThirdPartyModule.Resolver(.init(identificator: essential.identificator))
}
// Async transient -- child resolver wired from siblings.
@RegisterTransient
func coreResolver(_ resolver: Resolver) async -> CoreModule.Resolver {
await CoreModule.Resolver(
.init(
identificator: essential.identificator,
thirdParty: resolver.thirdPartyResolver
)
)
}
// Throwing transient -- intermediate value that may fail.
@RegisterTransient
func exerciseBundle() throws -> ExerciseBundle {
try ExerciseBundleReader().read()
}
// Public dependency resolved from a transient child resolver.
@Register
func core(_ resolver: Resolver) async throws -> CoreModule.Resolved {
try await resolver.coreResolver.resolve()
}
}Marks a side-effect-only step with no return value. Runs during resolve() in parallel with other performables. Same shape rules as @Register, minus the return type, and without a name: override.
@Resolvable
struct ThirdParty {
// Process-lifetime one-shot -- configure an SDK exactly once.
@Perform(options: .once)
func firebase() async {
let options = FirebaseOptions.defaultOptions()!
options.apiKey = Configuration.Firebase.apiKey
FirebaseApp.configure(options: options)
}
// Depends on a sibling perform via Resolver.
@Perform
func firebaseAuth(_ resolver: Resolver) async throws {
await resolver.firebase()
try Auth.auth().useUserAccessGroup(Configuration.Firebase.accessGroup)
}
// Synchronous, throwing.
@Perform
func configure() throws {
try ConfigurationManager.apply()
}
// Sibling registration consumed by the @Perform below.
@Register
func database() async throws -> Database {
try await Database.open(path: "app.db")
}
// Per-Resolver lifetime -- depends on a sibling registration via Resolver.
@Perform
func importExercises(_ resolver: Resolver) async throws {
try await ExercisesImportJob.run(database: resolver.database)
}
}Every @Register, @RegisterTransient, and @Perform accepts an options parameter:
| Option | Lifetime | Use case |
|---|---|---|
.default |
Single Resolver instance |
Stateful services, controllers, use cases |
.once |
Entire process | SDKs that must be configured exactly once (FirebaseApp.configure) |
@Register(options: .once)
func featureFlags() -> FeatureFlags {
FeatureFlags()
}Pass a constant. The value is re-read on every access, so deriving it from a runtime flag sends the same registration to the per-Resolver cache on one read and the process-wide one on the next -- leaving you with two live values under one name.
A registration must never call a sibling as a plain method -- neither sibling() nor self.sibling(). That bypasses the cache, builds the dependency a second time and defeats options: .once, so it is a compile error with a fix-it:
@Register
func database() async throws -> Database { … }
@Register
func repository() async throws -> Repository {
try await Repository(database: database()) // error, with a fix-it
}
@Register
func repository(_ resolver: Resolver) async throws -> Repository {
try await Repository(database: resolver.database) // correct
}Calling a same-named local function, or the same method on a different instance, is left alone.
Mistakes are reported on your declaration, not inside generated code. In short, a container will not build if:
@Resolvablesits on anything but aclassor astruct- a
@Registerhas no return type, or returns an opaquesome P - a
@Performhas a return type - a factory is
static,class,mutating, or generic - a factory takes more than one parameter, or one that is not a
Resolver - two registrations end up exposing the same name
- a registration is called
resolve,Resolved,Resolver,_registraror_resolvable name:is not a legal Swift identifier- a registration calls a sibling directly instead of going through
Resolver - registrations form a cycle
You also get a warning — and the thing you wrote is ignored — when an attribute sits somewhere @Resolvable never looks, when one function carries two registration attributes, or when name: / sort: is given something other than a literal.
Annotate the factory, not the generated member -- @MainActor and @concurrent stay on your method and are never copied onto the property or method the macro emits. @MainActor is the only one the macro reads, and only to decide whether the generated call needs await. A factory isolated to any other global actor will not compile; mark it async instead.
Reach for @concurrent when the factory body does heavy synchronous work -- decryption, a large decode, parser work -- that must not run on a caller sitting on MainActor:
@Register
@concurrent
func trainingPlanProvider(_ resolver: Resolver) async throws -> TrainingPlanProvider {
try await TrainingPlanProvider(box: SecureBox.openJson(), storage: resolver.storage)
}For plain .init(...) construction leave it off: it costs an executor hop and buys nothing.
await alone does not hop off the actor. Under approachable concurrency a nonisolated async function inherits its caller's isolation and runs its whole body there. So a graph first touched from MainActor launch code builds on MainActor unless you say otherwise -- which is why an app-wide holder is usually written LazyAsyncThrowable { @concurrent in … }.
Resolved, Resolver and Registrar are Sendable; your container must be too.
Resolve ships four Sendable lazy wrappers for deferred computation outside the macro system.
Synchronous, non-throwing. Evaluates under an OSAllocatedUnfairLock on first call.
let storage = Lazy {
ExpensiveStorage(fileName: "data")
}
let s = storage() // computed once, memoizedSynchronous, throwing. Errors are not cached -- failures retry on next call.
let config = LazyThrowable {
try JSONDecoder().decode(Config.self, from: data)
}
let c = try config()Both synchronous wrappers run the factory under a non-recursive lock. A factory that reaches back into its own wrapper aborts the process rather than hanging.
Async, non-throwing. Multiple concurrent awaiters share a single Task.
let settings = LazyAsync {
await SettingsContainer.Resolver(.init()).resolve()
}
let resolved = await settings.valueAsync, throwing. Errors are cached -- first failure is permanent. Use this one whenever the graph you are wrapping can throw.
let session = LazyAsyncThrowable {
try await Session.bootstrap()
}
let s = try await session.valueLazy and LazyThrowable support callAsFunction -- use lazy() or try lazy(). LazyAsync and LazyAsyncThrowable additionally expose a .value property, so await lazy() and await lazy.value are equivalent (prefix with try when the factory throws).
Resolve is designed for modular apps. Each module defines its own @Resolvable container. Parent modules wire child resolvers as transient registrations:
@Resolvable
struct CoreAssembly {
let identificator: Identificator
@Register
func database() async throws -> Database {
try await Database.open()
}
@Register(options: .once)
func featureFlags() -> FeatureFlags {
FeatureFlags()
}
}
@Resolvable
struct DomainAssembly {
let core: CoreAssembly.Resolver
@Register
func exerciseLibrary() async throws -> ExerciseLibrary {
try await ExerciseLibrary(database: core.database)
}
}
@Resolvable
struct AppAssembly {
let essential: Essential
@RegisterTransient
func coreResolver() -> CoreAssembly.Resolver {
CoreAssembly.Resolver(.init(identificator: essential.identificator))
}
@RegisterTransient
func domainResolver(_ resolver: Resolver) async -> DomainAssembly.Resolver {
await DomainAssembly.Resolver(.init(core: resolver.coreResolver))
}
@Register
func core(_ resolver: Resolver) async throws -> CoreAssembly.Resolved {
try await resolver.coreResolver.resolve()
}
@Register
func domain(_ resolver: Resolver) async throws -> DomainAssembly.Resolved {
try await resolver.domainResolver.resolve()
}
}
// Bootstrap
let resolved = try await AppAssembly.Resolver(.init(essential: essential)).resolve()
resolved.core.database
resolved.domain.exerciseLibraryA common pattern is to hold the resolved graph in a lazy wrapper for the entire app lifecycle. AppAssembly.resolve() throws, so the throwing wrapper is the one to use:
enum Assembly {
static let resolved = LazyAsyncThrowable {
try await AppAssembly.Resolver(.init(essential: .live)).resolve()
}
}
// At launch:
let app = try await Assembly.resolved.value
// Later, from any task:
let database = try await Assembly.resolved.value.core.database- Cancelling
resolve()does not stop it. Factories run in unstructured tasks that do not inherit cancellation, and the caller will not return early. Put a timeout inside the factory if you need one. - A failure is remembered. If a factory throws, every later read of that dependency rethrows the same error without retrying — for the life of the
Resolver, or of the process underoptions: .once. Handle retryable work inside the factory rather than expecting a secondresolve()to fix it. - Only direct cycles are caught. The compiler stops you when factories reference each other through
resolver.sibling. A cycle built by passingResolversomewhere and calling it indirectly compiles, and deadlocks. #ifhides a registration. A@Registerinside a conditional-compilation block is not picked up, and disappears fromResolvedandResolverwithout a diagnostic. Put the#ifinside the factory body instead.
A runnable example lives in Sources/Playground/main.swift. Run it with:
swift run PlaygroundResolve is available under the MIT license. See LICENSE for details.