Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .swift-version
Original file line number Diff line number Diff line change
@@ -1 +1 @@
5.3
5.9
98 changes: 98 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
# AGENTS.md

`DependencyInjection` is a zero-dependency Swift package providing a type-keyed dependency injection
container for Apple platforms. This file covers using it in app code. [README.md](README.md) has
installation and every API in full; [CONTRIBUTING.md](CONTRIBUTING.md) covers changing the package.

**Prefer `AsyncContainer` in new code.** It is an `actor`, so it is data-race safe under the Swift 6
language mode, with compiler-enforced `Sendable` bounds. `Container` is `@unchecked Sendable` over
unguarded dictionaries — that silences the concurrency checker rather than proving anything. Use
`Container` only for `@Injected` / `@LazyInjected` or `autoregister`, which the async side lacks, and
confine it to one isolation domain. Examples below are `Container` unless marked otherwise.

## Composition root

One `ModuleRegistration` per feature module, all called once at launch before anything resolves.

```swift
enum NetworkingModule: ModuleRegistration {
static func registerDependencies(in container: Container) {
container.register(type: APIClient.self, dependency: URLSessionAPIClient())
container.autoregister(in: .shared, initializer: TokenStore.init)
}
}

enum AppDependencies {
static func configure(in container: Container = .shared) {
NetworkingModule.registerDependencies(in: container)
}
}
```

Call `configure()` from `App.init()` or `application(_:didFinishLaunchingWithOptions:)`. Registering
into `Container.shared` is what lets the property wrappers work without `from:`. New registrations go
in a module's `registerDependencies(in:)`, not scattered through the app.

## Register and resolve

```swift
container.register(type: APIClient.self, in: .shared) { resolver in
URLSessionAPIClient(tokenStore: resolver.resolve(type: TokenStore.self))
}
container.register(type: AppConfiguration.self, dependency: .production) // always shared
container.autoregister(in: .shared, initializer: ProfileRepository.init) // parameters auto-resolved
let repository: ProfileRepository = container.resolve()
let checked = try container.tryResolve(type: ProfileRepository.self)
```

`.shared` caches the first instance forever; `.new` runs the factory on every resolve. **Always pass
`in:`** — `register { ... }` without a scope binds the variadic-argument overload, which hard-codes
`.new`. `resolve` is `try! tryResolve`, so an unregistered type **crashes the process**; use
`tryResolve` wherever the composition root does not guarantee the registration.

`@Injected` resolves at owner init, `@LazyInjected` at first access. Both default to
`Container.shared` and take `from:` to target another container.

## AsyncContainer

Same model with `await` throughout and `Sendable` required on every dependency and argument. The
`await` sits on the inner `resolve`, not on the factory closure.

```swift
enum NetworkingModule: AsyncModuleRegistration {
static func registerDependencies(in container: AsyncContainer) async {
await container.register(in: .shared) { resolver in
URLSessionAPIClient(tokenStore: await resolver.resolve(type: TokenStore.self))
}
}
}
let client: URLSessionAPIClient = await AsyncContainer.shared.resolve()
```

There is no `autoregister` and no property wrappers on this side.

## Runtime arguments

`container.register { resolver, userID in ... }`, resolved with `container.resolve(arguments: "id")`.

- 1–3 arguments, always `.new` — there is no scope parameter.
- A fourth throws `ResolutionError.tooManyArguments` **at resolve time, not registration**, so an
over-wide registration compiles and fails at runtime.
- Matching uses **compile-time** types: registering `any APIClient` and resolving a concrete
`URLSessionAPIClient` throws `ResolutionError.unmatchingArgumentType`.

## Testing app code

Register into a local `Container()` per test rather than `Container.shared`, re-running the module's
`registerDependencies(in:)` and then overriding what the test needs. A test that does touch
`Container.shared` — the bare property wrappers do — must `clean()` before it returns, or the
registration leaks into every other test.

## Traps

- `Container` is **not thread-safe**; its storage is unguarded despite `@unchecked Sendable`.
- Re-registering a type discards its cached `.shared` instance. Anything holding the old one keeps it,
so two objects can both believe they are the singleton. Register once at startup.
- `@LazyInjected` is a `final class`; struct copies share the wrapper and the resolved instance.
- `AsyncContainer` dedupes in-flight `.shared` resolution — N concurrent resolves run the factory once,
and a throw clears the entry so the next resolve retries.
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,22 @@ __Sections__
- `Removed` for deprecated features removed in this release.
- `Fixed` for any bug fixes.

## [Unreleased]

### Added

- **Agent-facing documentation** — `AGENTS.md` documents the package from the outside, for agents and developers building an app with it: the `ModuleRegistration` composition root, scope rules, resolution, runtime arguments, testing app code, and the runtime traps. Installation and requirements stay in `README.md`, which it links to. `CONTRIBUTING.md` carries the contributor workflow, build and test commands, public API compatibility, and the container invariants that are easy to break; `Sources/AGENTS.md` and `Tests/AGENTS.md` carry the rules scoped to those trees.
- **Guidance to prefer `AsyncContainer`** — `AGENTS.md` and `README.md` now recommend `AsyncContainer` for new code, especially under the Swift 6 language mode: it is an actor with compiler-enforced `Sendable` bounds, whereas `Container` is `@unchecked Sendable` over dictionaries it mutates without a lock, so the concurrency checker cannot catch a race. The trade-off — no `autoregister` and no property wrappers on the async side — is documented alongside it.
- **Documented `ModuleRegistration`** — `ModuleRegistration` and `AsyncModuleRegistration` are public API but were documented nowhere outside their own DocC comments. They are now documented as the composition-root pattern in `AGENTS.md`.

### Fixed

- **README examples now compile** — the resolution examples used the `argument:` and `argument1:`/`argument2:` labels removed in 2.0.0 and now use the parameter-pack `arguments:` parameter, and the argument-matching example annotated a closure parameter without the parentheses Swift requires.
- **Documented registration scope** — the README claimed `shared` was the default scope. Omitting `in:` selects the overload for registrations with arguments, which always registers with the `new` scope; the examples and the surrounding text now say so and pass the scope explicitly.
- **Stale requirements** — the README advertised Swift 5.3 and Xcode 11; the package requires Swift 5.9 and Xcode 15 because the container API is built on parameter packs. `.swift-version` was pinned to 5.3 for the same reason and now matches the manifest.
- **Installation snippet excluded every 2.x release** — the README pinned `.upToNextMajor(from: "1.0.0")`, which resolves to `>= 1.0.0, < 2.0.0` and therefore never reaches the current version. It now pins `2.0.0`.
- **Broken source link** — the `Factory` link in the README pointed at a path that moved when the sync and async protocols were split.

## [2.0.1]

### Added
Expand Down
143 changes: 143 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
# Contributing

Development guide for `DependencyInjection`, and the contributor entry point for this repository.
[AGENTS.md](AGENTS.md) documents the package from the outside, for agents and developers building an
app with it; [README.md](README.md) covers the same ground in prose. Rules scoped to the code itself
live in [Sources/AGENTS.md](Sources/AGENTS.md) and [Tests/AGENTS.md](Tests/AGENTS.md) — read whichever
covers the files you are about to edit.

## Every change

- `swift test` is the authoritative check. Run it before reporting a change as working.
- A pull request changing more than 50 lines must also update `CHANGELOG.md`, or CI fails the build.
- A public API change updates `AGENTS.md` and `README.md` in the same pull request.

## Toolchain

The package has no dependencies and builds with SwiftPM alone. It declares
`swift-tools-version:5.9` and uses parameter packs, so Swift 5.9 / Xcode 15 is the floor.

`.mise.toml` pins the Ruby toolchain CI needs for Danger:

```sh
mise install # Ruby 3.3 + danger 8.2
```

SwiftLint and SwiftFormat are configured (`.swiftlint.yml`, `.swiftformat`) but are **not installed
or run by CI** — they are local-only conveniences.

## Commands

```sh
swift build # compile the library
swift test # authoritative check — run this before claiming success
swift test --enable-code-coverage # exactly what CI runs
danger --fail-on-errors=true # CI only; needs GITHUB_TOKEN and an open pull request
swiftformat --lint . # report formatting drift without rewriting files
swiftlint --quiet # report lint findings
```

Two things that look like they should work but do not:

- **`xcodebuild test` has no test target to run.** `DependencyInjection.xcodeproj` exposes a single
framework target and no tests. SwiftPM is the only path that runs the suite.
- **`swift package generate-documentation` is unavailable.** swift-docc-plugin is deliberately not a
dependency. Build DocC from Xcode instead: Product → Build Documentation.

Do not run bare `swiftformat .`. It rewrites every file it touches, including ones unrelated to your
change.

## CI

`.github/workflows/integrations.yml` runs on every pull request, on a self-hosted macOS runner. It
installs Mise, runs Danger, then runs `swift test --enable-code-coverage`. That is the whole gate —
no linting, no formatting check, no coverage threshold.

`Dangerfile` fails the build when:

- a PR changes more than 50 lines without touching `CHANGELOG.md` (override by putting `#trivial` in
the PR title or body, or `[WIP]` in the title), or
- the PR description is shorter than 5 characters.

## Public API compatibility

Everything under `Sources/` ships as public API. The package is past 1.0 — see `CHANGELOG.md` for the
current version — so changing an existing signature, argument label, or default value is a breaking
change: it needs a deliberate decision and a `CHANGELOG.md` entry under a new version heading. Adding
new overloads is fine; silently changing what an existing call resolves to is not.

## Container invariants

These are the behaviours that are easy to break, or to rely on incorrectly. Each is verified against
the current implementation.

**`Container` is not thread-safe.** It is declared `@unchecked Sendable`, but its `registrations` and
`sharedInstances` dictionaries are mutated without any lock. Concurrent `register`/`resolve` calls
from different threads are a data race and will corrupt the dictionaries or crash. Either register
everything from a single thread during startup, or use `AsyncContainer`, whose actor isolation makes
this safe.

**`resolve` traps; `tryResolve` throws.** `resolve` is `try! tryResolve` (see
`Sources/Protocols/Resolution/Sync/DependencyResolving.swift`). Resolving an unregistered type kills
the process. `@Injected` calls `resolve` during property-wrapper initialisation, so a missing
registration crashes when the enclosing object is created; `@LazyInjected` defers the same crash to
the first access of the property. Use `tryResolve` anywhere the registration is not guaranteed.

**Omitting `in:` registers `.new`, not `.shared`.** `register { ... }` with no scope binds the
variadic-argument overload with an empty parameter pack, which hard-codes `scope: .new`
(`Sources/Container/Sync/Container.swift`). Only `register(in: .shared)` and the autoclosure
`register(dependency:)` produce a cached instance. Always pass the scope explicitly.

**Re-registering a type discards its cached shared instance.** `register` sets
`sharedInstances[identifier] = nil`, because the new factory probably returns something different.
Anything already holding the previous instance keeps it, so two objects that both believe they are
the singleton can be alive at once. Register once at startup rather than re-registering later.

**The three-argument limit is enforced at resolve time, not at registration.** Registering a factory
with four arguments compiles and succeeds silently; only `tryResolve` checks
`RegistrationIdentifierConstant.maximumArgumentCount` and throws `ResolutionError.tooManyArguments`.
A registration that exceeds the limit is therefore dead code that fails at runtime.

**Argument matching uses compile-time types.** The registration key is built from the static types at
the call site, so registering with `any SomeProtocol` and resolving with a concrete conforming value
throws `ResolutionError.unmatchingArgumentType` even though the value conforms.

**`AsyncContainer` deduplicates in-flight shared resolution.** It keeps a `sharedTasks` map, so N
concurrent resolves of the same `.shared` type run the factory once and all await the same task. If
the factory throws, the task entry is cleared so the next resolve retries rather than caching the
failure.

## Testing

Tests use Swift Testing (`@Suite` / `@Test` / `#expect`) and live under `Tests/`. Scoped conventions
for writing them are in [Tests/AGENTS.md](Tests/AGENTS.md).

Expectations for a change:

- Sync and async behaviour is tested in parallel suites. A behaviour change on one side normally
needs a matching case on the other.
- The package aims for full coverage of the public surface; new public behaviour should arrive with
tests.
- `swift test` must pass before a change is reported as working.

## Documentation

Each document owns a distinct slice; keep changes in the one that owns the fact.

- **`README.md`** is the consumer source of truth: installation, registration, resolution, scopes,
arguments, property wrappers. Every code block in it must compile against the current API — the
examples went stale once already when parameter packs replaced the numbered `argument1:`/
`argument2:` labels in 2.0.0.
- **`AGENTS.md`** is that same consumer surface condensed for coding agents: choosing a container, the
`ModuleRegistration` composition root, scope rules, runtime arguments, testing app code, and the
traps. It and `README.md` change together — a public API change that lands in one but not the other
is a bug. Its code blocks must compile too.
- **DocC comments** on the declarations own per-symbol behaviour. Sync and async declarations carry
deliberately duplicated comments; update both sides together.
- **`CHANGELOG.md`** follows [Keep a Changelog](http://keepachangelog.com/) and uses the section
vocabulary listed at the top of the file. CI requires it for anything over 50 lines.

## Style

Formatting and lint rules are defined by `.swiftformat` and `.swiftlint.yml` — defer to those files
rather than to prose. Match the surrounding code where a rule does not apply.
7 changes: 5 additions & 2 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import PackageDescription
let package = Package(
name: "DependencyInjection",
platforms: [
.macOS(.v10_15), .iOS(.v13), .tvOS(.v13), .watchOS(.v6),
.macOS(.v12), .iOS(.v15), .tvOS(.v15), .watchOS(.v8),
],
products: [
// Products define the executables and libraries a package produces, and make them visible to other packages.
Expand All @@ -22,6 +22,8 @@ let package = Package(
name: "DependencyInjection",
dependencies: [],
path: "Sources",
// Agent instructions live alongside the code; they are documentation, not a resource.
exclude: ["AGENTS.md"],
swiftSettings: [
.define("APPLICATION_EXTENSION_API_ONLY"),
.enableUpcomingFeature("StrictConcurrency"),
Expand All @@ -30,7 +32,8 @@ let package = Package(
.testTarget(
name: "DependencyInjectionTests",
dependencies: ["DependencyInjection"],
path: "Tests"
path: "Tests",
exclude: ["AGENTS.md"]
),
]
)
Loading
Loading