PureXML/docs/rules/dependency-injection.md
Mihaela Mihaljevic 708390242c chore: scaffold PureXML pure-Swift XML package
Root Swift package mirroring PureYAML's structure, rules, and verification
gates. Includes the XML node model (Node, Element, Attribute, QualifiedName),
a working emitter (Serializer) with escaping and pretty/compact options, and
structural validation. The parser surface is stable; parse() raises
notImplemented until the scanner lands, pinned by a test.

- dependency-free, namespaced under PureXML
- portability and style gates carried over (style, namespacing, forbidden
  patterns, swiftformat, swiftlint, linux, wasm, windows CI)
- 11 tests passing
2026-06-07 23:20:20 +02:00

7.7 KiB

Dependency Injection Rules

Canonical dependency-injection principles for PureXML and the wider PureXML codebase: no singletons, constructor injection, protocol seams.

These are standing rules for every change and every PR. They are grounded in Design Patterns (Gamma, Helm, Johnson, Vlissides, 1994) and Mark Seemann's Dependency Injection in .NET (2011).

1. No Singletons. Ever.

  • No static let shared = X(). Not on a final class. Not when the class is immutable. Not when Design Patterns (1994, p. 127) explicitly sanctions the pattern.
  • No static accessors that reach into a process-wide config holder. That is a Service Locator (Seemann 2011, ch. 5) and it counts.
  • No "but this case is sanctioned" soft framing. When tempted to document a Singleton as "legitimate per p. 127," stop. Do the injection.
  • Apple's os.Logger per-category statics are allowed; they are Apple's idiom, not ours.
  • Internal-detail caches (private static let cache = Cache() inside a static enum loader) are not the kind of Singleton being banned. They are memoization, not Service Locator, and are not reachable from consumers as a dependency.

Why: every dependency must appear in the call graph at the type's init site, not be conjured from process-wide state at runtime. That makes coupling visible, testable, and removable.

2. Every external collaborator goes through init. No exceptions.

  • Paths, factories, strategies, loggers, indexes, anything not owned by the type itself goes through the constructor.
  • Not through method parameters at the callsite. Not through static var fallbacks. Not through environment lookup.
  • Pure free functions that compute a value from arguments are fine (no state).
  • Stateful types with collaborators take them via init.

Example for PureXML:

public struct TileRenderer {
    private let loader: TileLoading
    private let logger: Logging

    public init(loader: TileLoading, logger: Logging) {
        self.loader = loader
        self.logger = logger
    }
}

3. Cross-module coupling via protocols, not concrete imports.

  • Code that crosses a module boundary depends on a named protocol, not on a concrete type imported from another module.
  • The composition root (the executable target / app entry point) is the only place that holds concretes from multiple modules and wires them together.

If and when PureXML grows into multiple SPM packages:

  • A feature/producer package may not import another feature/producer package.
  • Cross-package seams are named protocols, either declared in a small foundation-only *Models package or inlined per package.
  • The composition root remains the only place that imports concretes from several packages.

4. No closure typealiases at cross-module seams.

Forbidden at cross-module seams:

public typealias TileLookup = @Sendable (String) async throws -> TileType?

Required:

public protocol TileLookupStrategy: Sendable {
    func lookup(uri: String) async throws -> TileType?
}

Reasons: a named protocol is discoverable (you can find all conformers), it forces a real type at the conformer site, and it matches the Factory Method / Strategy phrasing from Design Patterns.

  • This does not ban closures as method parameters or property values. onProgress: (@Sendable (Progress) -> Void)? is fine.
  • It does ban closure-typed named cross-module contracts.

5. Every module lifts out cleanly.

If and when PureXML is split into multiple packages, each package should lift out of the repo without modification:

  • If a package needs a dependency to function, that dependency is named in its public API.
  • Hidden dependencies (ambient singletons, reach-into-shared-state, undeclared transitive imports) are violations even if they currently build.
  • A mechanical lift-out check copies the package plus its declared transitive dependencies to a temp directory, generates a minimal Package.swift, and runs swift build. Green means it lifts out.

6. Composition root: binary name stays clean.

If PureXML ships multiple binaries, the executable target name stays normal (no Impl suffix). The binary's external identity (filename on disk, which output) stays clean.

Use an *Impl namespace for the source files inside that target where each module is concretely glued together:

// Sources/CLI/CLIImpl.swift
public enum CLIImpl {}                       // namespace anchor

// Sources/CLI/CLIImpl.LiveTileLoaderFactory.swift
extension CLIImpl {
    struct LiveTileLoaderFactory: TileLoaderFactory { ... }
}

The *Impl namespace says "this file is where we wire concretes." Composition roots can import anything; they wire the universe. Library/producer code cannot import beyond its explicit allowed list.

7. Keep the import contract explicit.

If PureXML grows into multiple packages, keep a checked-in docs/package-import-contract.md: one row per target listing target name, allowed imports, and current state. Anything outside the allowed column is a violation and must be fixed in the same change. Back it with a CI script that greps ^import per target and fails on disallowed imports.

8. Foundation-only is the end state for producer packages.

The end goal for a multi-package split: producer packages import only external primitives (Foundation, system frameworks, vetted third-party SSG dependencies). Zero internal package imports. Each producer declares its own protocols inline. The composition root grows to bridge between per-package protocols.

Until then, a producer package may import:

  • Foundation primitives and system frameworks
  • Its own *Models companion and other foundation-only *Models protocol-seam targets
  • A shared value-type layer, if one exists

Sequence the work: settle the singleton and static-state removal first, then pursue foundation-only producers as a separate effort. Do not fold the two together.

9. No relitigating a settled principle. Just do it.

  • When the strict-DI / strict-portability path is chosen, stop offering alternatives and execute. Surface only ordering / sequencing questions, not principle-relitigation.
  • Do not ask permission to fix obvious blockers (build failures, fatal crashes, CI breakers). Fix them. Reserve "want me to?" for genuinely debatable scope/timing calls.

10. Verify before claiming done.

  • Use the project's canonical build command (swift build).
  • Run the full test suite and cite the count: 142 / 142 pass. "Tests pass" without a number is not evidence.
  • If incremental build is acting strange (stale module errors, link errors against old signatures), clean the build and rebuild.

11. Keep the roadmap current.

Update the project roadmap (document or tracker issue) on every phase, release, or scope change. "Closed" means shipped, not "PR merged."

12. Documentation mirrors the user-facing surface in the same change.

When a CLI command, option, or other user-facing surface changes, update the documentation in the same change. Also update the CHANGELOG. Hand-curated, not auto-generated.


Reference

  • Gamma, Helm, Johnson, Vlissides, Design Patterns (1994). Factory Method p. 107; Strategy p. 315; Singleton p. 127 (we do not use the third one).
  • Mark Seemann, Dependency Injection in .NET (2011). Service Locator anti-pattern ch. 5; Composition Root ch. 4.

Stop rule (paste at the top of any session that touches imports)

Before adding import X to a producer module:

  1. Is X external (Foundation, system framework, a vetted third-party dependency)? Allowed.
  2. Is the target an executable target / composition root? Allowed.
  3. Otherwise: STOP. Surface the situation. The target state is foundation-only.