Framework Migration Guide

swift-ddd-kit  0.x → 1.0

Ambient Context & Pluggable Metadata — 給「依賴 swift-ddd-kit 的下游專案」的升級指南

target: swift-ddd-kit Swift 6 / Ubuntu / macOS 15+ ⚠ breaking release audience: human + AI agent
version transition
FROM0.x
TO1.0

1.0 把 event metadata 從固定的 external: [String:String]? 字串字典通道, 改成型別安全的 ambient context 機制。下游專案(如 P_A)需要做 命名替換泛型參數補上、以及把 save/delete 的 metadata 傳遞方式改寫。 Domain 層(AggregateRoot / DomainEvent / Usecase)不受影響

TL;DR — 一分鐘看懂

這個東西0.x1.0
儲存抽象 protocolEventStorageCoordinatorEventStore
Repository/Projector 的儲存型別參數associatedtype StorageCoordinatorassociatedtype Store
Repository/Projector 的儲存屬性var coordinatorvar store
append 的 metadata 通道external: [String:String]?metadata: Metadata?(型別化)
Kurrent 儲存實作泛型KurrentStorageCoordinator<StreamNaming>KurrentStorageCoordinator<StreamNaming, Metadata>
In-memory 儲存實作泛型InMemoryStorageCoordinatorInMemoryStorageCoordinator<Metadata>
save / delete metadata 參數save(aggregateRoot:, external:)save(aggregateRoot:) + ambient context
userId 便利方法save(…userId:) / delete(…userId:)已移除
CustomMetadata 結構{ className, external }{ operatorId }
事件型別判斷RecordedEvent.mappingClassNameRecordedEvent.eventType
DomainEvent protocol✓ 不變(仍有 associatedtype Metadata + var metadata
AggregateRoot / Usecase✓ 不變

🤖 AI Migration Manifest(機器可讀)

若你是 AI agent,正在把一個依賴 swift-ddd-kit 的專案從 0.x 升到 1.0 — 直接消化下方 JSON。每條規則含 kind(變更類型)、mechanical (能否純文字取代)、before/afteraction(對下游要做什麼)。 套用順序:先做 mechanical:true 的 rename,再處理 signature-change / removedscope 標示要掃描的程式碼位置。

{
  "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…)從應用層帶到儲存層。這個設計有三個問題:

  • Domain 抽象被汙染Repository 是 domain 概念,external dict 是基礎設施概念。每個 Usecase 都被迫顯式傳遞它,跟 business invariant 無關。
  • Schema 被鎖死成 dict[String:String]? 限制所有使用者只能用字串字典;tenantIdcorrelationIdrequestId 等需求沒有型別安全的擴充空間。
  • 跟既有的 DomainEvent.Metadata 機制重複 — 框架本來就有 per-event 的 metadata,但 write-side 的 external 跟它沒接起來。

1.0 用兩個機制取代它 —— Ambient Context(透過 Swift TaskLocal 從 Usecase 入口往下傳) 與 Pluggable Metadata(應用層自訂 EventMetadata 結構,框架不規範欄位)。

新舊資料流對照

同一件事(把帶 metadata 的事件寫進儲存)在兩個版本的流向:

flowchart LR subgraph OLD["0.x — external dict 通道"] direction TB A1[Usecase] -->|"external: [String:String]?"| A2[Repository.save] A2 -->|"external dict"| A3[EventStorageCoordinator.append] A3 --> A4[(KurrentDB)] end subgraph NEW["1.0 — ambient context"] direction TB B1[Usecase] -->|"EventMetadataContext.withValue(meta)"| B2["@TaskLocal 槽"] B1 --> B3[Repository.save] B2 -.讀取.-> B3 B3 -->|"typed Metadata?"| B4[EventStore.append] B4 --> B5[(KurrentDB)] end

關鍵差異:1.0 的 Repository.save 不再有 metadata 參數 —— 它從 ambient TaskLocal 自動讀取。Domain 層的呼叫鏈完全不碰 metadata。

Write / Read 完整時序

sequenceDiagram participant UC as Usecase participant CTX as EventMetadataContext<M> participant REPO as EventSourcingRepository participant STORE as EventStore (Kurrent/InMemory) participant DB as KurrentDB participant MAP as EventTypeMapper Note over UC,DB: 寫入路徑 UC->>CTX: withValue(metadata) { ... } UC->>REPO: save(aggregateRoot:) REPO->>CTX: 讀 current(typed M?) CTX-->>REPO: metadata REPO->>STORE: append(events:, metadata: M?) STORE->>DB: 寫入 events + customMetadata bytes Note over DB,UC: 讀取路徑 REPO->>STORE: fetchEvents(byId:) STORE->>DB: 讀 RecordedEvent DB-->>STORE: events + customMetadata STORE->>MAP: mapping(eventData:) MAP->>MAP: decode payload + 填 event.metadata MAP-->>REPO: [any DomainEvent](metadata 已填好) REPO-->>UC: 重建後的 AggregateRoot

變更總表

rename 純命名 signature 簽章變更 removed 移除 new 新增 unchanged 不變
ID類型變更自動化
R1renameprotocol EventStorageCoordinatorEventStoreauto
R2renameassociatedtype StorageCoordinatorStoreauto
R3renamevar coordinatorvar store(含 init label)auto
S1signatureKurrentStorageCoordinator<S><S, Metadata>手動
S2signatureInMemoryStorageCoordinator<Metadata>手動
S3signature自訂 EventStore:加 associatedtype Metadata + 改 append手動
S4signaturesave(aggregateRoot:external:) → 去掉 external手動
S5signaturedelete(byId:external:) → 去掉 external手動
D1removedsave(aggregateRoot:userId:) 便利方法移除手動
D2removeddelete(byId:userId:) 便利方法移除手動
D3removedRecordedEvent.mappingClassName 移除 → 用 eventTypeauto
D4removedRecordedEvent.userId 移除手動
D5removedCustomMetadata 改成 { operatorId }手動
N1newEventMetadata marker protocol手動
N2newEventMetadataContext<M> ambient carrier手動
U1-3unchangedDomainEvent / AggregateRoot / Usecase / fetchEvents

逐項變更說明

R1 / R2 / R3  rename  命名重構

儲存抽象的詞彙從「coordinator」改成「store」。只有 protocol 改名 —— 具體類別 InMemoryStorageCoordinator / KurrentStorageCoordinator 的名字保留不變(它們只是多了泛型參數,見 S1/S2)。

0.x

final class OrderRepository: EventSourcingRepository {
    typealias AggregateRootType = Order
    typealias StorageCoordinator =
        KurrentStorageCoordinator<OrderStream>

    let coordinator: StorageCoordinator
    init(coordinator: StorageCoordinator) {
        self.coordinator = coordinator
    }
}

1.0

final class OrderRepository: EventSourcingRepository {
    typealias AggregateRootType = Order
    typealias Store =
        KurrentStorageCoordinator<OrderStream, CustomMetadata>

    let store: Store
    init(store: Store) {
        self.store = store
    }
}
掃描範圍 每一個 EventSourcingRepositoryEventSourcingProjector 的 conformer。 Projector 同樣有 StorageCoordinatorStorecoordinatorstore

S1 / S2  signature  儲存實作補上 Metadata 泛型參數

1.0 的 EventStore protocol 多了 associatedtype Metadata: EventMetadata。 兩個內建實作因此各多一個泛型參數。

// KurrentStorageCoordinator
- KurrentStorageCoordinator<OrderStream>
+ KurrentStorageCoordinator<OrderStream, CustomMetadata>

// InMemoryStorageCoordinator
- InMemoryStorageCoordinator()
+ InMemoryStorageCoordinator<CustomMetadata>()

第二個型別參數就是這個 store 綁定的 metadata schema。用內建的 CustomMetadata, 或自己定義(見 N1)。每個出現 store 型別的地方都要補:typealias、屬性宣告、建構子呼叫。

S3  signature  自訂 EventStore 實作

只有當下游專案自己寫了 EventStore 實作(非內建兩種)才需要。

0.x

struct MyStore: EventStorageCoordinator {
  func append(
    events: [any DomainEvent],
    byId id: String, version: UInt64?,
    external: [String:String]?
  ) async throws -> UInt64? { … }
}

1.0

struct MyStore: EventStore {
  typealias Metadata = CustomMetadata
  func append(
    events: [any DomainEvent],
    byId id: String, version: UInt64?,
    metadata: Metadata?
  ) async throws -> UInt64? { … }
}

fetchEventspurge 簽章不變。

S4 / S5 / D1 / D2  signatureremoved  save / delete 的 metadata 傳遞

四種舊寫法,全部收斂到「在 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)
+ }

若舊呼叫的 externalnil 或根本沒用到 metadata,直接刪掉參數即可: repo.save(aggregateRoot: order) / repo.delete(byId: id)

推薦做法 在 Usecase 的 execute 入口設一次 ambient,函式主體內所有巢狀的 repository.save 都會透過 structured concurrency 自動繼承,不需逐一傳遞。

D3 / D4  removed  RecordedEvent extension 移除

RecordedEvent.swift 整檔刪除 —— 它的兩個 extension 都依賴已被移除的欄位。

0.x1.0 替代
eventData.mappingClassName eventData.eventType —— KurrentDB 原生欄位,寫入時由 DomainEvent.eventType 填入,case label 仍對得上 Swift 型別名。
recordedEvent.userId 無直接替代。Mapper 解碼後 event.metadata?.operatorId,或自行 decode recordedEvent.customMetadata 成你的 metadata 型別。

手寫的 EventTypeMapperswitch eventData.mappingClassName,改成 switch eventData.eventType 即可(純文字取代)。用 codegen plugin 產生的 mapper 會自動更新。

D5  removed  CustomMetadata 簡化

0.x 的 CustomMetadata 同時揹著基礎設施欄位(className)和無型別字典 (external)—— 正是這次重構要消滅的「schema 鎖死成 dict」形狀。1.0 把它縮成 一個有意義的單欄位結構。

0.x

public struct CustomMetadata: Codable, Sendable {
  public let className: String
  public var external: [String:String]?
  public init(className: String,
              external: [String:String]?)
}
// + operatorId computed extension

1.0

public struct CustomMetadata: EventMetadata {
  public let operatorId: String

  public init(operatorId: String) {
    self.operatorId = operatorId
  }
}
若你之前靠 external 塞任意 key/value CustomMetadata 不再支援。定義你自己的 EventMetadata 結構(N1), 把那些欄位變成型別化的 property —— 這正是這次升級的目的。

N1 / N2  new  EventMetadata 與 EventMetadataContext

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)
        }
    }
}

U1 / U2 / U3  unchanged  不受影響的部分

  • 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 的專案):

flowchart TD START([P_A 依賴 swift-ddd-kit]) --> Q1{有用
EventSourcingRepository
或 Projector?} Q1 -->|否| SAFE[幾乎不受影響
只需 bump 版本後重建] Q1 -->|是| R[必做 R1/R2/R3
命名重構] R --> S[必做 S1/S2
補 Metadata 泛型參數] S --> Q2{有呼叫
save/delete 帶
external 或 userId?} Q2 -->|是| FIX[必做 S4/S5/D1/D2
改用 EventMetadataContext] Q2 -->|否| Q3 FIX --> Q3{有手寫
EventTypeMapper?} Q3 -->|是| MAP[必做 D3
mappingClassName → eventType] Q3 -->|否| Q4 MAP --> Q4{有用 CustomMetadata
的 className/external
或 RecordedEvent.userId?} Q4 -->|是| META[必做 D4/D5
改 schema / 改欄位存取] Q4 -->|否| Q5 META --> Q5{有自訂
EventStore 實作?} Q5 -->|是| CUSTOM[必做 S3] Q5 -->|否| DONE CUSTOM --> DONE([重建 + 跑測試]) SAFE --> DONE

Migration 步驟清單

建議照順序執行 —— 先做純命名(編譯器會把後面的問題指出來),再處理簽章。

  1. Bump 依賴Package.swiftswift-ddd-kit 需求改成 from: "1.0.0"
  2. R1 命名 — 全專案搜尋 protocol 引用 EventStorageCoordinatorEventStore(: EventStorageCoordinatorany EventStorageCoordinator、泛型約束)。
  3. R2 + R3 命名 — 每個 Repository / Projector conformer:typealias StorageCoordinatorStore;var coordinatorvar store;self.coordinator / .coordinator.store;init(coordinator:)init(store:)
  4. 選定 metadata schema — 沿用 CustomMetadata(現在是 { operatorId }),或定義自己的 EventMetadata 結構(N1)。
  5. S1 + S2 泛型 — 所有 KurrentStorageCoordinator<S> 補成 <S, M>;所有 InMemoryStorageCoordinator 補成 <M>(typealias、屬性、建構子)。
  6. S3(若適用) — 自訂的 EventStore 實作:加 associatedtype Metadata,appendexternal:metadata:
  7. S4 + S5 + D1 + D2 呼叫端 — 每個 save / delete 呼叫:拿掉 external: / userId: 參數;需要 metadata 的用 EventMetadataContext<M>.withValue { … } 包起來,最好設在 Usecase 入口。
  8. D3 mapper — 手寫 mapper 的 switch eventData.mappingClassNameswitch eventData.eventType
  9. D4 + D5 metadata 存取 — 修正 CustomMetadata(className:external:) 建構與 .className/.external 存取;處理 RecordedEvent.userId 的讀取。
  10. 重新生成 codegen — 若 generated mapper / event 有 commit 進 repo,重新生成(或重建讓 SwiftPM plugin 自動產生)。
  11. 編譯 + 測試swift build 解掉剩餘錯誤,跑測試套件確認行為。

陷阱與注意事項

Task.detached 不會繼承 ambient context EventMetadataContext 走 Swift TaskLocal 語意:結構化並行 (async letTaskGroup)會繼承;Task.detached { } 不會。 若在 metadata 邊界內 spawn detached task,要手動 capture 後在裡面重新 withValue
一次 save 內的所有 event 共用同一份 metadata 這是刻意設計。若你需要「同次 save、不同 event 不同 metadata」,通常代表 aggregate 邊界劃錯。
Store.Metadata 與 event.Metadata 靠 convention 對齊 框架不在編譯期強制兩者相同。執行期不一致時,read path 的 mapper decode 失敗會讓 event.metadata = nil(不會 crash)。codegen 產出的 event 預設 typealias Metadata = CustomMetadata —— 若 store 綁的是別的型別,記得讓 event 一致。
事件型別判斷改用 KurrentDB 原生 eventType 1.0 移除了把型別名塞進 metadata payload 的做法。read 路徑改用 eventData.eventType (寫入時由 DomainEvent.eventType 填入,預設等於 Swift 型別名)。metadata payload 不再揹型別判別資訊。
為什麼 EventMetadataContext 內部是個 dict,而不是直接 @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