Swift 6 · iOS 17+ · macOS 14+ · MIT

Plugins that clean up
after themselves.

cordis-ios is a native Swift port of the cordis plugin framework. Plugins declare the services they need, load when those services appear, and revert every effect when they stop. No teardown code, no dangling observers.

.package(url: "https://github.com/eovidiu/cordis-ios.git", from: "0.1.0")
Greeter.swift
import Cordis

enum ClockKey: ServiceKey {
  typealias Value = ClockService
  static let name = "clock"
}

@CordisActor @Service(ClockKey.self)
final class ClockService {
  func now() -> Date { Date() }
}

@CordisActor @Plugin
final class Greeter {
  @Inject(ClockKey.self) var clock: ClockService   // runs only while "clock" is provided

  func apply(_ scope: EffectScope) async throws {
    print("hello at \(try clock.now())")
    try scope.collect { print("bye") }              // undone automatically on unload
  }
}

let root = Context()
let greeter = try root.plugin(Greeter.self)        // pending: no clock yet
let clock = try await root.plugin(ClockService.self).await()
try await greeter.await()                          // active
try await clock.dispose()                          // greeter unloads ("bye"), back to pending

Why cordis-ios

Composition you can switch off.

Feature flags, modules and extensions usually leak: an observer that outlives its screen, a service nobody unregistered. cordis makes every side effect revertible and every dependency reactive.

Revertible effects

Listeners, provided services, child plugins and UI contributions are effects. Unloading runs their disposers newest first, so teardown always matches setup.

Reactive dependency injection

A plugin runs only while every service it injects is available. Withdraw a service and its consumers drop to pending; provide it again and they reload.

Isolated realms

Run two providers of the same service side by side. A context resolves each service name in its own realm, so a subtree never sees the other's instance.

Persisted loader

Describe the plugin tree as JSON. The Loader diffs it against what is running by id: changed configs reload, removed entries are disposed, the rest keep running.

Swift macros

@Plugin, @Service and @Inject generate the init, config and inject list. A plugin is a class with one apply method.

Strict concurrency

Swift 6 language mode. One global actor, CordisActor, owns contexts, fibers and listeners, so lifecycle transitions never race.

How it works

Three pieces. One lifecycle.

1

Provide a service

A service is a plugin that provides itself under a typed key. It is available to consumers once its fiber is active, and withdrawn the moment it unloads.

Services and injection →
enum CounterKey: ServiceKey {
  typealias Value = CounterService
  static let name = "counter"
}

@CordisActor @Service(CounterKey.self)
final class CounterService {
  private(set) var value = 0
  func add(_ delta: Int) { value += delta }
}
2

Write a plugin that depends on it

Declare what you need with @Inject. Everything apply installs, from listeners to UI, is collected and reverted when the plugin stops for any reason.

Effects →
@CordisActor @Plugin
final class CounterBadge {
  @Inject(CounterKey.self) var counter: CounterService

  func apply(_ scope: EffectScope) async throws {
    let badge = Badge(value: try counter.value)
    toolbar.add(badge)
    try scope.collect { toolbar.remove(badge) }
  }
}
3

Run the tree from JSON

Register plugins in a catalog and hand the Loader a store. Toggle, reconfigure or remove entries at runtime; it reconciles by id and saves the tree.

Loader →
var catalog = PluginCatalog()
catalog.register(CounterService.self, as: "counter")
catalog.register(CounterBadge.self, as: "badge")

let loader = Loader(ctx: root, catalog: catalog,
                    store: FileEntryStore(url: entriesURL))
try await loader.start()
try await loader.setDisabled(id: "counter", true)
// badge drops to pending; its toolbar badge is gone

Every transition is emitted as internal/status. Full state machine →

Cordis Showcase

See it run on an iPhone.

A SwiftUI app built entirely from plugins. A ten-step tour withdraws services, rejects configs, recovers failed plugins and isolates realms while you watch every fiber change state.

Plugins tab: each entry with its live state and an enable switch
Dashboard: six cards, each contributed by a different plugin
Timeline: the internal events caused by disabling a service

Free on the App Store for iPhone (iOS 17+). Or run it on a simulator with Examples/CordisShowcase/run.sh sim.

Built on research

“A Programming Paradigm for Spatiotemporal Composability”

cordis is created by Shigma and the cordiverse contributors. cordis-ios is an independent Swift port that follows cordis 4.0 and ports its test suite; it is not affiliated with or endorsed by the cordis authors.

About the author

Ovidiu Eftimie

I write about software engineering and working with coding agents, and publish the research behind it.