Framework Migration Guide
Ambient Context & Pluggable Metadata — 給「依賴 swift-ddd-kit 的下游專案」的升級指南
1.0 把 event metadata 從固定的 external: [String:String]? 字串字典通道,
改成型別安全的 ambient context 機制。下游專案(如 P_A)需要做
命名替換、泛型參數補上、以及把 save/delete 的 metadata 傳遞方式改寫。
Domain 層(AggregateRoot / DomainEvent / Usecase)不受影響。
| 這個東西 | 0.x | 1.0 |
|---|---|---|
| 儲存抽象 protocol | EventStorageCoordinator | EventStore |
| Repository/Projector 的儲存型別參數 | associatedtype StorageCoordinator | associatedtype Store |
| Repository/Projector 的儲存屬性 | var coordinator | var store |
| append 的 metadata 通道 | external: [String:String]? | metadata: Metadata?(型別化) |
| Kurrent 儲存實作泛型 | KurrentStorageCoordinator<StreamNaming> | KurrentStorageCoordinator<StreamNaming, Metadata> |
| In-memory 儲存實作泛型 | InMemoryStorageCoordinator | InMemoryStorageCoordinator<Metadata> |
| save / delete metadata 參數 | save(aggregateRoot:, external:) | save(aggregateRoot:) + ambient context |
| userId 便利方法 | save(…userId:) / delete(…userId:) | 已移除 |
CustomMetadata 結構 | { className, external } | { operatorId } |
| 事件型別判斷 | RecordedEvent.mappingClassName | RecordedEvent.eventType |
DomainEvent protocol | ✓ 不變(仍有 associatedtype Metadata + var metadata) | |
AggregateRoot / Usecase | ✓ 不變 | |
若你是 AI agent,正在把一個依賴 swift-ddd-kit 的專案從 0.x 升到 1.0 —
直接消化下方 JSON。每條規則含 kind(變更類型)、mechanical
(能否純文字取代)、before/after、action(對下游要做什麼)。
套用順序:先做 mechanical:true 的 rename,再處理 signature-change /
removed。scope 標示要掃描的程式碼位置。
{
"schema": "swift-ddd-kit-migration/v1",
"package": "swift-ddd-kit",
"from": "0.x",
"to": "1.0",
"domainLayerImpact": "none",
"applyOrder": ["rename", "signature-change", "removed", "new"],
"rules": [
{
"id": "R1",
"kind": "rename",
"mechanical": true,
"scope": "any type reference to the storage protocol",
"before": "EventStorageCoordinator",
"after": "EventStore",
"note": "Only the PROTOCOL was renamed. Concrete classes InMemoryStorageCoordinator and KurrentStorageCoordinator keep their names (see R5/R6).",
"action": "Replace every reference to the protocol name `EventStorageCoordinator` with `EventStore` — in `: EventStorageCoordinator` conformances, `any EventStorageCoordinator` existentials, and generic constraints."
},
{
"id": "R2",
"kind": "rename",
"mechanical": true,
"scope": "types conforming to EventSourcingRepository or EventSourcingProjector",
"before": "typealias StorageCoordinator = SomeStore",
"after": "typealias Store = SomeStore",
"action": "Rename the `StorageCoordinator` associated-type witness to `Store` in every Repository and Projector conformer."
},
{
"id": "R3",
"kind": "rename",
"mechanical": true,
"scope": "types conforming to EventSourcingRepository or EventSourcingProjector",
"before": "var coordinator: SomeStore / self.coordinator / x.coordinator",
"after": "var store: SomeStore / self.store / x.store",
"action": "Rename the `coordinator` stored property to `store`, and every read of `.coordinator` on a repository/projector to `.store`. Also rename the matching `init(coordinator:)` label to `init(store:)`."
},
{
"id": "S1",
"kind": "signature-change",
"mechanical": false,
"scope": "every use of KurrentStorageCoordinator as a type or constructor",
"before": "KurrentStorageCoordinator",
"after": "KurrentStorageCoordinator",
"action": "Add a second generic argument: the metadata schema type. Use `CustomMetadata` for the bundled default, or your own `EventMetadata`-conforming struct. Applies to `typealias Store = …`, property types, and `KurrentStorageCoordinator<…>(client:eventMapper:)` constructor calls."
},
{
"id": "S2",
"kind": "signature-change",
"mechanical": false,
"scope": "every use of InMemoryStorageCoordinator as a type or constructor",
"before": "InMemoryStorageCoordinator / InMemoryStorageCoordinator()",
"after": "InMemoryStorageCoordinator / InMemoryStorageCoordinator()",
"action": "Add the metadata schema as a generic argument everywhere InMemoryStorageCoordinator is named — typealias, property type, and constructor."
},
{
"id": "S3",
"kind": "signature-change",
"mechanical": false,
"scope": "custom hand-written types conforming to EventStore (the renamed protocol)",
"before": "func append(events:, byId:, version:, external: [String:String]?) async throws -> UInt64?",
"after": "associatedtype Metadata: EventMetadata\n func append(events:, byId:, version:, metadata: Metadata?) async throws -> UInt64?",
"action": "If the project defines its own EventStore implementation: add `associatedtype Metadata: EventMetadata` (or a `typealias`), and change `append`'s last parameter from `external: [String:String]?` to `metadata: Metadata?`. `fetchEvents` and `purge` signatures are unchanged."
},
{
"id": "S4",
"kind": "signature-change",
"mechanical": false,
"scope": "every call site of repository.save(aggregateRoot:external:)",
"before": "try await repository.save(aggregateRoot: x, external: [\"userId\": uid])",
"after": "try await EventMetadataContext.withValue(CustomMetadata(operatorId: uid)) {\n try await repository.save(aggregateRoot: x)\n}",
"action": "Remove the `external:` argument from save. If metadata was being passed, wrap the call in `EventMetadataContext.withValue(metadata) { … }`. If `external` was nil/unused, just delete the argument.",
"note": "M must equal the Store.Metadata of that repository's store."
},
{
"id": "S5",
"kind": "signature-change",
"mechanical": false,
"scope": "every call site of repository.delete(byId:external:)",
"before": "try await repository.delete(byId: id, external: …)",
"after": "try await repository.delete(byId: id) // optionally wrapped in EventMetadataContext.withValue",
"action": "Remove the `external:` argument from delete. delete internally calls save, so wrap in EventMetadataContext.withValue if a delete-time metadata payload is needed."
},
{
"id": "D1",
"kind": "removed",
"mechanical": false,
"scope": "call sites of the userId convenience overloads",
"before": "try await repository.save(aggregateRoot: x, userId: uid)",
"after": "try await EventMetadataContext.withValue(CustomMetadata(operatorId: uid)) {\n try await repository.save(aggregateRoot: x)\n}",
"action": "The `save(aggregateRoot:userId:)` and `delete(byId:userId:)` convenience overloads (KurrentSupport) are DELETED. Replace with an explicit EventMetadataContext.withValue scope."
},
{
"id": "D2",
"kind": "removed",
"mechanical": false,
"scope": "call sites of repository.delete(byId:userId:)",
"before": "try await repository.delete(byId: id, userId: uid)",
"after": "try await EventMetadataContext.withValue(CustomMetadata(operatorId: uid)) {\n try await repository.delete(byId: id)\n}",
"action": "Same as D1 for the delete overload."
},
{
"id": "D3",
"kind": "removed",
"mechanical": true,
"scope": "hand-written EventTypeMapper implementations",
"before": "switch eventData.mappingClassName { … }",
"after": "switch eventData.eventType { … }",
"action": "`RecordedEvent.mappingClassName` is removed. Replace with the KurrentDB-native `eventData.eventType` (populated from `DomainEvent.eventType` at write time, so case labels still match the Swift type name)."
},
{
"id": "D4",
"kind": "removed",
"mechanical": false,
"scope": "any read of RecordedEvent.userId",
"before": "let uid = recordedEvent.userId",
"after": "// decode metadata into your schema, or read event.metadata after mapping",
"action": "`RecordedEvent.userId` is removed. To recover the operator: after the mapper fills `event.metadata`, read `event.metadata?.operatorId` (when using CustomMetadata), or decode `recordedEvent.customMetadata` into your own EventMetadata type."
},
{
"id": "D5",
"kind": "removed",
"mechanical": false,
"scope": "construction and field access of CustomMetadata",
"before": "CustomMetadata(className: \"X\", external: [\"userId\": uid]) / meta.className / meta.external",
"after": "CustomMetadata(operatorId: uid) / meta.operatorId",
"action": "CustomMetadata is rewritten to a single-field struct `{ public let operatorId: String }`. The `className` and `external` fields and the old initializer are GONE. If you relied on `external` for arbitrary key/values, define your own `EventMetadata` struct instead (see N1)."
},
{
"id": "N1",
"kind": "new",
"mechanical": false,
"scope": "projects that need metadata fields beyond operatorId",
"after": "struct AuditMetadata: EventMetadata {\n let operatorId: String\n let tenantId: String\n let correlationId: String\n}",
"action": "`EventMetadata` is a new marker protocol (`Codable & Sendable`, no required fields). Define your own metadata schema by conforming to it. This becomes your `Store.Metadata` and the `M` in `EventMetadataContext`."
},
{
"id": "N2",
"kind": "new",
"mechanical": false,
"scope": "Usecase entry points that previously passed external/userId",
"after": "try await EventMetadataContext.withValue(metadata) { /* repository work */ }",
"action": "`EventMetadataContext` is the new ambient carrier. Set it ONCE at the Usecase entry; nested repository.save calls read it automatically via structured concurrency. API: static `withValue(_:operation:)` and static `current`."
},
{
"id": "U1",
"kind": "unchanged",
"scope": "DomainEvent protocol and all event structs",
"note": "DomainEvent still declares `associatedtype Metadata: Codable` and `var metadata: Metadata? { get set }`. Generated event structs still emit `typealias Metadata = CustomMetadata`. No change needed to event definitions — but note CustomMetadata's shape changed (see D5)."
},
{
"id": "U2",
"kind": "unchanged",
"scope": "AggregateRoot, Usecase, DomainEventBus, ReadModel, EventSourcingProjector.apply",
"note": "Domain layer is untouched. `apply(readModel:events:)` signature unchanged. No migration needed for aggregates, use cases, or projectors' projection logic."
},
{
"id": "U3",
"kind": "unchanged",
"scope": "EventStore.fetchEvents return shape",
"note": "fetchEvents still returns `(events: [any DomainEvent], latestRevision: UInt64)?`. No streaming change."
}
],
"downstreamProcedure": [
"Bump the swift-ddd-kit dependency requirement to 1.0 in Package.swift.",
"Apply R1: rename protocol references EventStorageCoordinator -> EventStore.",
"Apply R2 + R3: in every EventSourcingRepository / EventSourcingProjector conformer, rename `StorageCoordinator` typealias -> `Store`, `coordinator` property -> `store`, `init(coordinator:)` -> `init(store:)`.",
"Decide the metadata schema: keep `CustomMetadata` (now { operatorId }) or define your own EventMetadata struct (N1).",
"Apply S1 + S2: add the metadata generic argument to every KurrentStorageCoordinator / InMemoryStorageCoordinator usage.",
"Apply S3 if the project has a custom EventStore implementation.",
"Apply S4 + S5 + D1 + D2: rewrite every save/delete call site — drop external/userId args, wrap in EventMetadataContext.withValue where metadata is needed.",
"Apply D3: in hand-written mappers, switch eventData.mappingClassName -> eventData.eventType.",
"Apply D4 + D5: fix CustomMetadata construction/field access and any RecordedEvent.userId reads.",
"If generated mappers/events are committed to the repo, regenerate them (or rebuild so the SwiftPM plugin regenerates).",
"Build; resolve remaining compiler errors; run the test suite."
]
}
0.x 透過一個固定的 external: [String:String]? 字串字典,把 audit metadata
(operatorId、userId…)從應用層帶到儲存層。這個設計有三個問題:
Repository 是 domain 概念,external dict 是基礎設施概念。每個 Usecase 都被迫顯式傳遞它,跟 business invariant 無關。[String:String]? 限制所有使用者只能用字串字典;tenantId、correlationId、requestId 等需求沒有型別安全的擴充空間。DomainEvent.Metadata 機制重複 — 框架本來就有 per-event 的 metadata,但 write-side 的 external 跟它沒接起來。1.0 用兩個機制取代它 —— Ambient Context(透過 Swift TaskLocal 從 Usecase 入口往下傳)
與 Pluggable Metadata(應用層自訂 EventMetadata 結構,框架不規範欄位)。
同一件事(把帶 metadata 的事件寫進儲存)在兩個版本的流向:
關鍵差異:1.0 的 Repository.save 不再有 metadata 參數 —— 它從 ambient
TaskLocal 自動讀取。Domain 層的呼叫鏈完全不碰 metadata。
| ID | 類型 | 變更 | 自動化 |
|---|---|---|---|
| R1 | rename | protocol EventStorageCoordinator → EventStore | auto |
| R2 | rename | associatedtype StorageCoordinator → Store | auto |
| R3 | rename | var coordinator → var store(含 init label) | auto |
| S1 | signature | KurrentStorageCoordinator<S> → <S, Metadata> | 手動 |
| S2 | signature | InMemoryStorageCoordinator → <Metadata> | 手動 |
| S3 | signature | 自訂 EventStore:加 associatedtype Metadata + 改 append | 手動 |
| S4 | signature | save(aggregateRoot:external:) → 去掉 external | 手動 |
| S5 | signature | delete(byId:external:) → 去掉 external | 手動 |
| D1 | removed | save(aggregateRoot:userId:) 便利方法移除 | 手動 |
| D2 | removed | delete(byId:userId:) 便利方法移除 | 手動 |
| D3 | removed | RecordedEvent.mappingClassName 移除 → 用 eventType | auto |
| D4 | removed | RecordedEvent.userId 移除 | 手動 |
| D5 | removed | CustomMetadata 改成 { operatorId } | 手動 |
| N1 | new | EventMetadata marker protocol | 手動 |
| N2 | new | EventMetadataContext<M> ambient carrier | 手動 |
| U1-3 | unchanged | DomainEvent / AggregateRoot / Usecase / fetchEvents | — |
儲存抽象的詞彙從「coordinator」改成「store」。只有 protocol 改名 ——
具體類別 InMemoryStorageCoordinator / KurrentStorageCoordinator
的名字保留不變(它們只是多了泛型參數,見 S1/S2)。
final class OrderRepository: EventSourcingRepository { typealias AggregateRootType = Order typealias StorageCoordinator = KurrentStorageCoordinator<OrderStream> let coordinator: StorageCoordinator init(coordinator: StorageCoordinator) { self.coordinator = coordinator } }
final class OrderRepository: EventSourcingRepository { typealias AggregateRootType = Order typealias Store = KurrentStorageCoordinator<OrderStream, CustomMetadata> let store: Store init(store: Store) { self.store = store } }
EventSourcingRepository 或 EventSourcingProjector 的 conformer。
Projector 同樣有 StorageCoordinator → Store 與 coordinator → store。1.0 的 EventStore protocol 多了 associatedtype Metadata: EventMetadata。
兩個內建實作因此各多一個泛型參數。
// KurrentStorageCoordinator - KurrentStorageCoordinator<OrderStream> + KurrentStorageCoordinator<OrderStream, CustomMetadata> // InMemoryStorageCoordinator - InMemoryStorageCoordinator() + InMemoryStorageCoordinator<CustomMetadata>()
第二個型別參數就是這個 store 綁定的 metadata schema。用內建的 CustomMetadata,
或自己定義(見 N1)。每個出現 store 型別的地方都要補:typealias、屬性宣告、建構子呼叫。
只有當下游專案自己寫了 EventStore 實作(非內建兩種)才需要。
struct MyStore: EventStorageCoordinator { func append( events: [any DomainEvent], byId id: String, version: UInt64?, external: [String:String]? ) async throws -> UInt64? { … } }
struct MyStore: EventStore { typealias Metadata = CustomMetadata func append( events: [any DomainEvent], byId id: String, version: UInt64?, metadata: Metadata? ) async throws -> UInt64? { … } }
fetchEvents、purge 簽章不變。
四種舊寫法,全部收斂到「在 Usecase 入口用 EventMetadataContext.withValue 設一次」:
// 舊:external dict(S4) - try await repo.save(aggregateRoot: order, external: ["userId": uid]) // 舊:userId 便利方法(D1,已移除) - try await repo.save(aggregateRoot: order, userId: uid) // 新:1.0 統一寫法 + try await EventMetadataContext<CustomMetadata>.withValue( + CustomMetadata(operatorId: uid) + ) { + try await repo.save(aggregateRoot: order) + }
若舊呼叫的 external 是 nil 或根本沒用到 metadata,直接刪掉參數即可:
repo.save(aggregateRoot: order) / repo.delete(byId: id)。
execute 入口設一次 ambient,函式主體內所有巢狀的
repository.save 都會透過 structured concurrency 自動繼承,不需逐一傳遞。RecordedEvent.swift 整檔刪除 —— 它的兩個 extension 都依賴已被移除的欄位。
| 0.x | 1.0 替代 |
|---|---|
eventData.mappingClassName |
eventData.eventType —— KurrentDB 原生欄位,寫入時由 DomainEvent.eventType 填入,case label 仍對得上 Swift 型別名。 |
recordedEvent.userId |
無直接替代。Mapper 解碼後 event.metadata?.operatorId,或自行 decode recordedEvent.customMetadata 成你的 metadata 型別。 |
手寫的 EventTypeMapper 若 switch eventData.mappingClassName,改成 switch eventData.eventType 即可(純文字取代)。用 codegen plugin 產生的 mapper 會自動更新。
0.x 的 CustomMetadata 同時揹著基礎設施欄位(className)和無型別字典
(external)—— 正是這次重構要消滅的「schema 鎖死成 dict」形狀。1.0 把它縮成
一個有意義的單欄位結構。
public struct CustomMetadata: Codable, Sendable { public let className: String public var external: [String:String]? public init(className: String, external: [String:String]?) } // + operatorId computed extension
public struct CustomMetadata: EventMetadata { public let operatorId: String public init(operatorId: String) { self.operatorId = operatorId } }
external 塞任意 key/value
CustomMetadata 不再支援。定義你自己的 EventMetadata 結構(N1),
把那些欄位變成型別化的 property —— 這正是這次升級的目的。N1 — EventMetadata 是新的 marker protocol(Codable & Sendable,
無任何必要欄位)。下游專案自訂 metadata schema:
struct AuditMetadata: EventMetadata { let operatorId: String let tenantId: String let correlationId: String }
N2 — EventMetadataContext<M> 是 TaskLocal-backed 的 ambient carrier。
每個 metadata 型別 M 有獨立的儲存槽。API 只有兩個:
// 設定(在 Usecase 入口) try await EventMetadataContext<AuditMetadata>.withValue(meta) { // 此 scope 內(含巢狀 async)repository.save 自動讀到 meta } // 讀取(框架內部 / 進階用途) let meta = EventMetadataContext<AuditMetadata>.current // M?
完整的應用層樣板:
// 1. Repository 透過 Store.Metadata 綁定 schema final class OrderRepository: EventSourcingRepository { typealias AggregateRootType = Order typealias Store = KurrentStorageCoordinator<OrderStream, AuditMetadata> let store: Store init(store: Store) { self.store = store } } // 2. Usecase 在入口設 ambient,domain 邏輯完全不碰 metadata struct PlaceOrderUsecase { let repository: OrderRepository func execute(input: Input) async throws -> Output { let meta = AuditMetadata(operatorId: input.operatorId, tenantId: input.tenantId, correlationId: input.requestId) return try await EventMetadataContext<AuditMetadata>.withValue(meta) { let order = try Order(id: input.orderId, …) try await repository.save(aggregateRoot: order) return Output(orderId: order.id) } } }
DomainEvent protocol — 仍是 associatedtype Metadata: Codable + var metadata: Metadata?。事件定義不用改;但注意 CustomMetadata 的形狀變了(D5)。AggregateRoot / Usecase / DomainEventBus / ReadModel — 介面完全沒動。EventSourcingProjector.apply(readModel:events:) — 簽章不變。Projector 的投影邏輯不用改。EventStore.fetchEvents — 仍回傳 (events: [any DomainEvent], latestRevision: UInt64)? tuple,沒有改成 stream。把這張流程圖套到 P_A(或任何依賴 swift-ddd-kit 的專案):
建議照順序執行 —— 先做純命名(編譯器會把後面的問題指出來),再處理簽章。
Package.swift 把 swift-ddd-kit 需求改成 from: "1.0.0"。EventStorageCoordinator → EventStore(: EventStorageCoordinator、any EventStorageCoordinator、泛型約束)。typealias StorageCoordinator → Store;var coordinator → var store;self.coordinator / .coordinator → .store;init(coordinator:) → init(store:)。CustomMetadata(現在是 { operatorId }),或定義自己的 EventMetadata 結構(N1)。KurrentStorageCoordinator<S> 補成 <S, M>;所有 InMemoryStorageCoordinator 補成 <M>(typealias、屬性、建構子)。EventStore 實作:加 associatedtype Metadata,append 的 external: 改 metadata:。save / delete 呼叫:拿掉 external: / userId: 參數;需要 metadata 的用 EventMetadataContext<M>.withValue { … } 包起來,最好設在 Usecase 入口。switch eventData.mappingClassName → switch eventData.eventType。CustomMetadata(className:external:) 建構與 .className/.external 存取;處理 RecordedEvent.userId 的讀取。swift build 解掉剩餘錯誤,跑測試套件確認行為。EventMetadataContext 走 Swift TaskLocal 語意:結構化並行
(async let、TaskGroup)會繼承;Task.detached { } 不會。
若在 metadata 邊界內 spawn detached task,要手動 capture 後在裡面重新 withValue。
event.metadata = nil(不會 crash)。codegen 產出的 event 預設
typealias Metadata = CustomMetadata —— 若 store 綁的是別的型別,記得讓 event 一致。
eventData.eventType
(寫入時由 DomainEvent.eventType 填入,預設等於 Swift 型別名)。metadata payload
不再揹型別判別資訊。
@TaskLocal var current: M??Swift 不允許在泛型型別上宣告 @TaskLocal static stored property
(編譯錯誤:static stored properties not supported in generic types)。
所以實作上用一個非泛型的 TaskLocal 字典,以 ObjectIdentifier(M.self)
為 key,再用泛型的 EventMetadataContext<M> 包一層型別安全的讀寫 API。
對下游使用者透明 —— 你只會看到 withValue / current。