Install安裝
Add the package to your Package.swift. DDDKit is the umbrella product; it re-exports the core protocols, the event sourcing layer, the KurrentDB adapter, the in-memory event bus and read model persistence.
在 Package.swift 加入套件。DDDKit 是 umbrella product,會一併重新導出核心協定、event sourcing 層、KurrentDB 轉接層、記憶體內 event bus 與 read model 持久化。
dependencies: [
.package(url: "https://github.com/gradyzhuo/swift-ddd-kit.git", from: "1.3.0")
],
targets: [
.target(
name: "MyContext",
dependencies: [
.product(name: "DDDKit", package: "swift-ddd-kit"),
]
)
]
🧪 Trying 1.4? The template variables framework ships as a pre-release. Pin from: "1.4.0-beta.9" to pick it up; the API may still move before 1.4.0 is final.
🧪 想試 1.4? Template variables 框架目前以 pre-release 發佈。將版本設為 from: "1.4.0-beta.9" 即可取得;在 1.4.0 正式版之前 API 仍可能調整。
Pre-release · 1.4.0-beta.9預覽版 · 1.4.0-beta.9
Template variables frameworkTemplate variables 框架
1.4 adds a small framework for filling templates from typed values. A variables.yaml
declares placeholders with typed inputs; VariablesGeneratorPlugin generates a
Sendable protocol with one async throws -> String method per variable;
PlaceholderSubstitution fills %Placeholder% tokens in a single pass.
1.4 新增一個以型別化數值填入模板的小框架。variables.yaml 宣告帶有型別化 input 的 placeholder;
VariablesGeneratorPlugin 為每個變數生成一個 async throws -> String 方法,
組成一個 Sendable protocol;PlaceholderSubstitution 以單一遍歷填入 %Placeholder%。
Earlier 1.4.0 pre-releases belong to a retired line.
較早的 1.4.0 預覽版屬於已停用的版本線。
Full 1.4.0-beta.9 release notes →1.4.0-beta.9 完整 release notes →
Template Variables in the README →README 的 Template Variables 章節 →
Latest stable · 1.3.0最新穩定版 · 1.3.0
Cross-context events over Apache Pulsar跨 Bounded Context 的事件:Apache Pulsar
Domain events stay inside their context. What crosses the boundary is a Published Language
event, and 1.3 adds both halves of that trip over Apache Pulsar, with neither side depending
on a Pulsar client library.
Domain event 留在自己的 context 裡;跨越邊界的是 Published Language 事件。1.3 補上這段旅程在 Apache Pulsar 上的兩端,
而且兩端都不依賴 Pulsar 的 client library。
PublishedLanguage
The zero-dependency wire envelope, PublishedLanguageEvent, shared by producers and consumers.
零依賴的 wire envelope PublishedLanguageEvent,由發送端與接收端共用。
ContextForwarder
Produce side. Subscribes to the context's own KurrentDB, translates events through ForwardingRules and posts them to Pulsar's REST produce endpoint. Bearer-token auth and a self-healing ForwarderGroup.
發送端。訂閱自身 context 的 KurrentDB,透過 ForwardingRule 轉譯後 POST 到 Pulsar 的 REST produce 端點。支援 Bearer token 驗證與會自我修復的 ForwarderGroup。
ContextReceiver
Consume side, portable. Drives a PulsarMessageSource into your handler with permit-based flow control, settlement rules and a DeadLetterMonitor. Fully testable offline.
接收端,可攜。透過 permit 流量控制把 PulsarMessageSource 的訊息送進你的 handler,內含結算規則與 DeadLetterMonitor。可完全離線測試。
ContextReceiverWebSocket
The concrete transport over Pulsar's WebSocket consumer protocol. Linux-only for now: the upstream WebSocket dependency does not build on the macOS 26 SDK.
走 Pulsar WebSocket consumer 協定的實際 transport。目前僅支援 Linux:上游的 WebSocket 依賴在 macOS 26 SDK 上無法編譯。
Things only a live broker teaches. REST produce takes a raw JSON string while the WebSocket consumer returns it base64-encoded. Delivery is at-least-once, so derive downstream ids from the upstream eventId to stay idempotent. Set maxRedeliverCount and deadLetterTopic, or a message you gave up on will redeliver forever.
只有真實 broker 才會教你的事。 REST produce 收原始 JSON 字串,WebSocket consumer 卻回傳 base64 編碼。投遞是 at-least-once,請從上游 eventId 推導下游 id 以保持冪等。務必設定 maxRedeliverCount 與 deadLetterTopic,否則放棄的訊息會無限重送。
Full 1.3.0 release notes →1.3.0 完整 release notes →
1.2.01.2.0
Test without a server: an in-memory KurrentDB double不用伺服器也能測:記憶體內的 KurrentDB 替身
swift-kurrentdb's RecordedEvent and Streams cannot be constructed
from outside that package, so a fake could never conform to the client protocol. 1.2 adds a
narrow EventStoreClient seam covering exactly what the coordinator and the
projection runners call, and ships two conformances: the live one, and one that needs no
server at all.
swift-kurrentdb 的 RecordedEvent 與 Streams 無法在套件外建構,因此假物件永遠無法 conform 原本的 client 協定。
1.2 新增一個窄的 EventStoreClient seam,只涵蓋 coordinator 與 projection runner 實際呼叫的操作,並提供兩個實作:一個接真實伺服器,一個完全不需要伺服器。
InMemoryEventStoreClient
In the new KurrentSupportInMemory product. Real optimistic-concurrency semantics, $ce- category routing, replay-on-subscribe for "seed then subscribe" tests, and an opt-in deliveryDelay to test eventual consistency on purpose.
位於新的 KurrentSupportInMemory product。真實的 optimistic concurrency 語意、$ce- category 路由、訂閱時重播(方便「先塞資料再訂閱」的測試),以及可選的 deliveryDelay 用來刻意測試最終一致性。
LiveEventStoreClient
The production conformance wrapping a real KurrentDBClient. The client: KurrentDBClient convenience initializers stay, so existing call sites do not change.
包著真實 KurrentDBClient 的生產環境實作。client: KurrentDBClient 便利初始化器保留,既有呼叫端不需修改。
KurrentDBPool (TestUtility)
KurrentDBClient.withPooledIntegrationTestClient borrows a client from a pool when KURRENTDB_POOL_URLS is set and falls back to the fixed cluster otherwise. Opt-in only.
KurrentDBClient.withPooledIntegrationTestClient 在設定 KURRENTDB_POOL_URLS 時從連線池借用 client,否則退回固定叢集。純選用。
Breaking: mappers receive any RecordedEventLike破壞性變更:mapper 改收 any RecordedEventLike
1.11.1
func mapping(eventData: RecordedEvent) throws
-> (any DomainEvent)? {
switch eventData.eventType { ... }
}
let stream = record.streamIdentifier.name
1.21.2
func mapping(eventData: any RecordedEventLike) throws
-> (any DomainEvent)? {
switch eventData.eventType { ... } // unchanged body
}
let stream = record.streamName
The same applies to KurrentProjection's register(extractInput:) closures. Code that only switches on eventType and calls decode(to:) is source-compatible after updating the parameter type. The dddkit scaffolding CLI moved out to swift-pangu-cli in this release.
KurrentProjection 的 register(extractInput:) closure 也一樣。只 switch eventType、呼叫 decode(to:) 的程式碼,改參數型別後即可相容。此版也把 dddkit 腳手架 CLI 拆到 swift-pangu-cli。
Full 1.2.0 release notes →1.2.0 完整 release notes →
1.1.0 · 1.1.11.1.0 · 1.1.1
A scaffolding CLI, and a safer first save專案腳手架 CLI,與更安全的首次寫入
1.1.0 added the dddkit command-line tool for scaffolding a runnable starter
project (aggregate, use cases, KurrentDB wiring) plus a release pipeline that publishes it
as a GitHub Release, a signed apt repository and a Homebrew tap. 1.1.1 fixed a correctness
hole in the write path.
1.1.0 新增 dddkit 命令列工具,可產生可直接執行的起始專案(aggregate、use case、KurrentDB 接線),
並附上把它發佈為 GitHub Release、簽章 apt repository 與 Homebrew tap 的 release pipeline。1.1.1 修補了寫入路徑上的一個正確性漏洞。
First save expects .noStream首次寫入預期 .noStream 1.1.1
A never-persisted aggregate has no version. KurrentStorageCoordinator.append used to send expectedRevision: .any in that case, which skips the concurrency check entirely. It now sends .noStream, so a duplicate or concurrent create collides with the existing stream instead of silently succeeding.
從未持久化的 aggregate 沒有版本號。過去 KurrentStorageCoordinator.append 在這種情況送出 expectedRevision: .any,等於完全跳過並行檢查。現在改送 .noStream,重複或並行的建立會撞到既有 stream,而不是靜默成功。
Where the CLI lives nowCLI 現在的位置
Since 1.2 the scaffolding tool is maintained separately as swift-pangu-cli (pangu project create), with no dependency on this package's library targets.
自 1.2 起,腳手架工具獨立維護為 swift-pangu-cli(pangu project create),不再依賴本套件的 library target。
Full 1.1.1 release notes →1.1.1 完整 release notes →
Full 1.1.0 release notes →1.1.0 完整 release notes →
Breaking · 1.0.0 · 1.0.1破壞性變更 · 1.0.0 · 1.0.1
Ambient context and pluggable metadataAmbient context 與可替換的 metadata
0.x carried audit metadata to the store as an external: [String: String]?
dictionary threaded through every save. 1.0 replaces that with a typed ambient
context: define your own EventMetadata schema, set it once at the use case entry,
and structured concurrency carries it to EventStore.append. Aggregates, events
and use cases never see it.
0.x 用 external: [String: String]? 字典把稽核 metadata 一路傳進每個 save。
1.0 改成型別化的 ambient context:自行定義 EventMetadata schema,在 use case 入口設定一次,
由 structured concurrency 帶到 EventStore.append。Aggregate、event 與 use case 完全不需要碰它。
0.x0.x
try await repository.save(
aggregateRoot: order,
external: ["operatorId": input.operatorId]
)
1.01.0
struct AuditMetadata: EventMetadata {
let operatorId: String
let tenantId: String
}
let meta = AuditMetadata(operatorId: ..., tenantId: ...)
try await EventMetadataContext<AuditMetadata>.withValue(meta) {
try await repository.save(aggregateRoot: order)
}
Renames at a glance重新命名一覽
1.0.1 fixed a double-apply bug in find(byId:hiddingDeleted: false) when reconstructing a soft-deleted aggregate.
1.0.1 修正 find(byId:hiddingDeleted: false) 重建已軟刪除 aggregate 時的重複 apply 錯誤。
0.x → 1.0 Migration Guide0.x → 1.0 遷移指南
Step-by-step changes, before/after data flow, and a machine-readable manifest for AI agents. Written in Traditional Chinese.
逐項變更、新舊資料流對照,以及給 AI agent 的機器可讀遷移清單。
Full 1.0.1 release notes →1.0.1 完整 release notes →
Full 1.0.0 release notes →1.0.0 完整 release notes →
Why swift-ddd-kit為什麼選擇 swift-ddd-kit
The Swift server ecosystem has matured, but the building blocks of a production DDD architecture, aggregate roots, event-sourced repositories, CQRS projectors and event migration, have been missing. swift-ddd-kit fills that gap.
Swift 後端生態已經成熟,但 production 等級 DDD 架構的建構元件,aggregate root、event-sourced repository、CQRS projector、事件 migration,一直缺席。swift-ddd-kit 補上這一塊。
Events are the source of truth事件就是事實的來源
State is never stored directly. Every change is an immutable DomainEvent, and state is rebuilt by replaying them through when(happened:).
狀態從不直接儲存。每次變更都是一個不可變的 DomainEvent,狀態透過 when(happened:) 重播事件重建。
Aggregates guard invariantsAggregate 守住不變條件
Business rules live in AggregateRoot. apply(event:) mutates state, runs ensureInvariant() and queues uncommitted events. No anemic models mutated from outside.
商業規則放在 AggregateRoot。apply(event:) 變更狀態、執行 ensureInvariant() 並排入未提交事件。沒有從外部被改動的貧血模型。
Write and read stay apart讀寫分離
The write side appends events; EventSourcingProjector folds them into ReadModels on the read side. A persistent-subscription runner keeps projections live.
寫入端只附加事件;讀取端由 EventSourcingProjector 把事件折疊成 ReadModel。Persistent subscription runner 讓投影持續更新。
Swift 6 strict concurrencySwift 6 strict concurrency
Native async/await throughout. Ambient metadata rides on TaskLocal, so audit and tenant data never leak into domain types.
全面使用原生 async/await。Ambient metadata 搭在 TaskLocal 上,稽核與租戶資料永遠不會滲入 domain 型別。
Less boilerplate, checked at build time更少樣板,在建置期檢查
SPM build-tool plugins turn event.yaml, projection-model.yaml and variables.yaml into Swift. Mistakes surface as build failures, not runtime surprises.
SPM build-tool plugin 把 event.yaml、projection-model.yaml 與 variables.yaml 轉成 Swift。錯誤在建置時就浮現,而不是執行期才發現。
Testable without infrastructure不需基礎設施也能測
InMemoryEventStoreClient stands in for KurrentDB, ContextReceiver is fully offline-testable, and TestUtility covers the integration runs that do need a server.
InMemoryEventStoreClient 代替 KurrentDB,ContextReceiver 可完全離線測試,TestUtility 則涵蓋真的需要伺服器的整合測試。
Quick Start快速上手
1. Events1. 事件
import DDDKit
struct OrderCreated: DomainEvent {
var id: UUID = .init()
var occurred: Date = .now
var aggregateRootId: String
let customerId: String
}
struct OrderCancelled: DeletedEvent {
var id: UUID = .init()
var occurred: Date = .now
let aggregateRootId: String
}
2. Aggregate root2. Aggregate root
final class Order: AggregateRoot {
typealias DeletedEventType = OrderCancelled
let id: String
private(set) var customerId: String = ""
var metadata: AggregateRootMetadata = .init()
init(id: String, customerId: String) throws {
self.id = id
try apply(event: OrderCreated(aggregateRootId: id, customerId: customerId))
}
required init?(events: [any DomainEvent]) throws {
guard let first = events.first as? OrderCreated else { return nil }
self.id = first.aggregateRootId
try apply(events: events)
}
func when(happened event: some DomainEvent) throws {
switch event {
case let e as OrderCreated: customerId = e.customerId
default: break
}
}
}
3. Mapper and repository3. Mapper 與 repository
struct OrderEventMapper: EventTypeMapper {
func mapping(eventData: any RecordedEventLike) throws -> (any DomainEvent)? {
switch eventData.eventType {
case "OrderCreated": return try eventData.decode(to: OrderCreated.self)
case "OrderCancelled": return try eventData.decode(to: OrderCancelled.self)
default: return nil
}
}
}
final class OrderRepository: EventSourcingRepository {
typealias AggregateRootType = Order
typealias Store = KurrentStorageCoordinator<Order, CustomMetadata>
let store: Store
init(client: KurrentDBClient) {
store = .init(client: client, eventMapper: OrderEventMapper())
}
}
4. Save and find4. 儲存與讀取
let client = KurrentDBClient(settings: .localhost())
let repository = OrderRepository(client: client)
let order = try Order(id: "order-001", customerId: "customer-42")
try await repository.save(aggregateRoot: order)
let found = try await repository.find(byId: "order-001") // replayed from the stream
try await repository.delete(byId: "order-001") // soft delete: appends OrderCancelled
try await repository.purge(byId: "order-001") // hard delete: removes the stream
5. Metadata without touching the domain5. 不碰 domain 的 metadata
struct AuditMetadata: EventMetadata {
let operatorId: String
let tenantId: String
}
// Bind the schema on the store …
typealias Store = KurrentStorageCoordinator<Order, AuditMetadata>
// … and set it once at the use case entry. save() picks it up automatically.
try await EventMetadataContext<AuditMetadata>.withValue(meta) {
try await repository.save(aggregateRoot: order)
}
A ready-to-run starter project with all of this wired up is one command away with swift-pangu-cli: pangu project create.
想要一個已經接好線、可直接執行的起始專案,用 swift-pangu-cli 一行搞定:pangu project create。
Code generation程式碼生成
Three SPM build-tool plugins read YAML next to your sources and emit Swift at build time; one command plugin emits KurrentDB projection definitions on demand. The same logic is reachable by hand through swift run generate.
三個 SPM build-tool plugin 讀取原始碼旁的 YAML,在建置時輸出 Swift;另有一個 command plugin 可隨時輸出 KurrentDB projection 定義。同樣的邏輯也可以透過 swift run generate 手動執行。
DomainEventGeneratorPlugin
event.yaml → created / domain / deleted event structs and an EventTypeMapper for the aggregate.
event.yaml → created / domain / deleted 事件 struct,以及該 aggregate 的 EventTypeMapper。
OrderCreated:
kind: createdEvent
aggregateRootId:
alias: orderId
properties:
customerId: String
totalAmount: Double
OrderCancelled:
kind: deletedEvent
ModelGeneratorPlugin
projection-model.yaml → ReadModel types and the projector scaffolding for the query side.
projection-model.yaml → 查詢端的 ReadModel 型別與 projector 骨架。
OrderSummary:
model: readModel
events:
- OrderCreated
- OrderAmountUpdated
GenerateKurrentDBProjectionsPlugin
A command plugin: swift package generate-kurrentdb-projections emits the KurrentDB projection definitions your read side subscribes to, from the same projection-model.yaml.
Command plugin:swift package generate-kurrentdb-projections 從同一份 projection-model.yaml 輸出讀取端要訂閱的 KurrentDB projection 定義。
VariablesGeneratorPlugin
variables.yaml → a Sendable variables protocol with one async throws -> String method per variable, plus the __value(of:inputs:) dispatch seam. Pre-release in 1.4.
variables.yaml → 一個 Sendable 變數 protocol,每個變數一個 async throws -> String 方法,另附 __value(of:inputs:) 分派入口。1.4 預覽版。
CustomerName:
placeholder: CustomerName
inputs:
- customerId: String
swift run generate event Sources/MyContext/event.yaml \
--configuration Sources/MyContext/event-generator-config.yaml -o Generated/Events.swift
swift run generate projector Sources/MyContext/projection-model.yaml \
--events Sources/MyContext/event.yaml -o Generated/Projectors.swift
swift run generate variables Sources/MyContext/variables.yaml -o Generated/Variables.swift