Skip to main content

elf-domain-modeling

Elf Express 領域建模規範(C# + SqlSugar 實體)。當任務涉及設計或修改 Entity、Value Object、聚合(Aggregate)、 主鍵型別、審計欄位(CreatedAt / CreatedBy / UpdatedAt / UpdatedBy)、多租戶邊界(ICompanyEntity / CompanyId)、 軟刪除(ISoftDelete / IsDeleted)、enum 與狀態轉換、工廠方法、領域事件、Repository / UnitOfWork 取捨時觸發。

Source facts

Repository
elf-express/skillshare-source
Last source activity
September 27, 2026 at 01:26
Detected SKILL.md language
Chinese
Stars
0
Forks
0

Install options

The review-first prompt is selected by default. You can switch to a direct command or download a local copy.

Review the source files

Read SKILL.md and any companion files shown by SkillsMP before deciding whether to install.

File Explorer
2 files

Showing SKILL.md

SKILL.md
Source instructions · Read-only preview
name
elf-domain-modeling
description
Elf Express 領域建模規範(C# + SqlSugar 實體)。當任務涉及設計或修改 Entity、Value Object、聚合(Aggregate)、 主鍵型別、審計欄位(CreatedAt / CreatedBy / UpdatedAt / UpdatedBy)、多租戶邊界(ICompanyEntity / CompanyId)、 軟刪除(ISoftDelete / IsDeleted)、enum 與狀態轉換、工廠方法、領域事件、Repository / UnitOfWork 取捨時觸發。
metadata
{"version":"1.0.0","owner":"Elf Express"}
# Elf Express 領域建模規範(elf-domain-modeling) 依據:參考專案 `TypingTrainer` 的 `server/<App>.Api/Models/Entities.cs`(SqlSugar CodeFirst 實體即資料表定義), 加上團隊標準的多租戶、審計、軟刪除介面。實體即 SqlSugar 持久化模型;**不另建一套 ORM 以外的 domain model**。 相關 skill:**elf-sqlsugar**(標註、AOP、過濾器實作)、**elf-postgresql**(欄位型別、索引)、 **elf-dotnet**(服務層、結果回傳慣例)、**elf-api-contract**(實體與 API DTO 的對應)。 ## 1. 何時使用 - 新增資料表 / 實體,或為實體加欄位、索引、關聯。 - 判斷一個概念該是實體、值物件、還是 JSON 欄位。 - 設計狀態轉換、業務不變條件(invariant)。 - 決定資料是否屬於某公司(租戶)、是否要軟刪除。 ## 2. 固定規則 ### 2.1 基底類別與介面(定義見 references §1) 1. **MUST** 所有實體實作 `IEntity`(透過繼承 `EntityBase`);snake_case 轉換只作用於 `IEntity`。 2. **MUST** 依資料性質選基底類別: | 資料性質 | 基底類別 | 取得的欄位 | |----------|----------|------------| | 公司的業務資料(訂單、客戶、出貨…)**預設** | `CompanyEntity` | Id、審計 4 欄、CompanyId、IsDeleted、DeletedAt | | 全公司共用的參照資料(國家、系統內建範本) | `AuditedEntity` | Id、審計 4 欄 | | 純技術性、只增不改的記錄(keystroke log 類、多對多連結表) | `EntityBase` | Id | 3. **MUST** 主鍵為 `int Id`、`IsPrimaryKey = true, IsIdentity = true`(參考專案作法;PostgreSQL 為 identity 欄位)。 4. **MUST** 外鍵屬性命名 `<Entity>Id`(`OrderId`、`CompanyId`),型別與對方主鍵一致(`int`)。 5. **MUST NOT** 使用 `required` 成員;所有非 null 屬性給預設值(`string.Empty`、`DateTime.UtcNow`、enum 預設成員)。 6. **MUST** 屬性為 `public get; set;`(SqlSugar 需要可寫屬性來物化資料)。不變條件由**工廠方法與行為方法**守護,服務層**MUST NOT** 直接改狀態欄位繞過行為方法。 ### 2.2 審計欄位 7. **MUST** 審計欄位名稱固定:`CreatedAt`、`CreatedBy`、`UpdatedAt`、`UpdatedBy`(`IAuditable`)。 8. **MUST** 審計欄位由 `SqlSugarSetup` 的 `DataExecuting` AOP 自動填入;**MUST NOT** 在服務中手動賦值(BulkCopy 例外,見 elf-sqlsugar)。 9. **MUST** 時間一律 UTC(`DateTime.UtcNow`),欄位 `timestamptz`。**MUST NOT** 使用 `DateTime.Now`。 10. `CreatedBy` / `UpdatedBy` 為 `int?` 使用者 Id;系統 / 背景工作寫入時為 `null`。 ### 2.3 多租戶邊界(`ICompanyEntity`) 11. **MUST** 任何屬於某公司的資料列實作 `ICompanyEntity`(含聚合內的**子實體**,例如 `OrderLine` 也要有 `CompanyId`), 讓直接查子表時仍被租戶過濾器保護。 12. **MUST** `CompanyId` 由 AOP 在新增時填入、標 `IsOnlyIgnoreUpdate = true`;**MUST NOT** 從 request body 接收 `CompanyId`。 13. **MUST** 租戶資料表的查詢索引以 `CompanyId` 為第一欄(例:`ix_orders_company_created (company_id, created_at desc)`)。 14. **MUST** 唯一鍵在租戶內唯一時,唯一索引包含 `CompanyId`(例:`ux_customers_company_code (company_id, code)`)。 15. **MUST NOT** 讓一個聚合跨越多個公司;跨公司的關聯只能指向非租戶的參照資料。 ### 2.4 軟刪除(`ISoftDelete`) 16. **MUST** 使用者可「刪除」的業務資料實作 `ISoftDelete`(`IsDeleted` + `DeletedAt`);全域過濾器自動隱藏已刪除列。 17. **MUST** 刪除以 elf-sqlsugar §3.5 的 `SoftDeleteAsync<T>` 執行;**MUST NOT** 對 `ISoftDelete` 實體用 `Deleteable`。 18. 軟刪除實體上的唯一索引會把已刪除的列也算進去;需要「刪除後可重建同代碼」時,唯一索引改為 PostgreSQL partial index(`WHERE is_deleted = false`),以手寫 SQL 建立(見 elf-postgresql)。 ### 2.5 聚合 19. **MUST** 一個聚合一個檔案,檔名 = 聚合根名稱(`Models/Order.cs` 內含 `Order`、`OrderLine`、`OrderStatus`)。 20. **MUST** 子實體只透過聚合根的服務修改;**MUST NOT** 為子實體另開可直接寫入的 endpoint。 21. **MUST** 聚合根到子實體用 `[Navigate(NavigateType.OneToMany, nameof(Child.ParentId))] public List<Child>? Children { get; set; }`(參考專案寫法),載入用 `.Includes(...)`。 22. **MUST** 修改聚合(根 + 子)的多個寫入包在同一個交易(elf-sqlsugar §3.3)。 23. **MUST** 聚合之間只以 Id 參照(`CustomerId`),**MUST NOT** 在一個聚合裡放另一個聚合根的導覽屬性並一起寫入。 ### 2.6 工廠方法與行為方法 24. **MUST** 每個聚合根與子實體提供 `public static T Create(...)`,在其中驗證必要參數(`ArgumentException.ThrowIfNullOrWhiteSpace`、`ArgumentOutOfRangeException.ThrowIfNegativeOrZero`)並設定初始狀態。 25. **MUST** 狀態轉換寫成實體上的方法;**預期中的拒絕**(狀態不允許)回傳 `bool`(`TryPlace()`),由服務轉成結果 enum → endpoint 以 ProblemDetails 回 409(elf-dotnet §2.5)。只有「程式錯誤」才 throw。 26. enum:封閉集合用 C# enum,**MUST** 每個成員明寫數值(`Draft = 0, Placed = 1`),只可新增、不可重排或改值。 這條同時適用於**存進資料庫的 enum** 與**服務回傳的結果 enum**(`PlaceResult`、`CreateXResult`)——後者雖不入庫, 但會出現在 log 與測試斷言中,明寫數值可避免重排後意義悄悄改變。 可由使用者或資料擴充的分類用 `string` 代碼(`Length = 32`),如參考專案 `Category.Kind`。 ### 2.7 值物件 27. **MUST** 值物件為不可變型別:`readonly record struct`(小、無身分)或 `sealed record`;建構子驗證,運算回傳新實例。 28. **MUST** 值物件持久化為擁有者實體上的**原始欄位**(`Money` → `TotalAmount` + `Currency`),並在實體上提供 `[SugarColumn(IsIgnore = true)]` 的組合屬性與 `SetX(ValueObject)` 方法。 29. **MUST** 預設以**欄位或子表**建模,不用 `jsonb`。只有同時符合下列三項時才用 `jsonb`(`IsJson = true, ColumnDataType = "jsonb"`): (a) 鍵 / 結構由外部來源或使用者決定,無法事先列成欄位; (b) 程式碼中**沒有**以其內容篩選、排序、join 或建索引的查詢; (c) 永遠整包讀、整包寫。 任何一項不符 → 拆成欄位或子表。之後若出現以其內容查詢的需求,先改為欄位;確實無法時才依 elf-postgresql 規則 18 建 GIN 索引。 ### 2.8 Repository、領域事件 30. **MUST** 服務直接注入 `ISqlSugarClient` 存取資料(參考專案作法);**MUST NOT** 新增泛型 `IRepository<T>` / `UnitOfWork` 包裝層。 31. 領域事件:**目前不採用**。副作用(寄信、通知、寫其他聚合)由服務在交易 **commit 之後**顯式呼叫。**MUST NOT** 自行引入 MediatR 或事件匯流排(見待確認)。 ## 3. 標準範本 完整可編譯範本見 [`references/entity-templates.md`](references/entity-templates.md): `EntityContracts.cs`(介面 + 三個基底類別)、`Order.cs`(聚合根 + 子實體 + enum + jsonb + 工廠 / 行為方法)、 `Money.cs`(值物件)、`Country`(非租戶參照資料)、聚合載入與狀態轉換服務。 (已於 net10.0 + SqlSugarCore 5.1.4.221 編譯通過。) 最小範例(租戶業務資料): ```csharp using SqlSugar; namespace Acme.Api.Models; [SugarTable("Customers")] [SugarIndex("ux_customers_company_code", nameof(CompanyId), OrderByType.Asc, nameof(Code), OrderByType.Asc, true)] public class Customer : CompanyEntity { [SugarColumn(Length = 32)] public string Code { get; set; } = string.Empty; [SugarColumn(Length = 128)] public string Name { get; set; } = string.Empty; [SugarColumn(Length = 256, IsNullable = true)] public string? Email { get; set; } public static Customer Create(string code, string name, string? email) { ArgumentException.ThrowIfNullOrWhiteSpace(code); ArgumentException.ThrowIfNullOrWhiteSpace(name); return new Customer { Code = code.Trim(), Name = name.Trim(), Email = email?.Trim() }; } } ``` 新增後務必:加入 `SqlSugarSetup.Entities`、寫測試(`Create` 的驗證、行為方法的狀態轉換)。 ## 4. 檢查清單 - [ ] 繼承正確的基底類別(業務資料預設 `CompanyEntity`) - [ ] `[SugarTable("PluralPascal")]`;已加入 `SqlSugarSetup.Entities` - [ ] 沒有 `required`;非 null 屬性都有預設值 - [ ] 字串都有 `Length` 或 `text`;nullable 欄位有 `?` + `IsNullable = true` - [ ] 時間欄位 UTC + `timestamptz` - [ ] 子實體也有 `CompanyId`(若聚合屬於租戶) - [ ] 索引以 `CompanyId` 開頭;租戶內唯一鍵包含 `CompanyId` - [ ] 有 `Create(...)` 工廠方法;狀態轉換在實體方法中,預期拒絕回 `bool` - [ ] enum 成員明寫數值(含服務回傳的結果 enum) - [ ] 值物件不可變,持久化為原始欄位 + `IsIgnore` 組合屬性 - [ ] 沒有新增泛型 Repository / UnitOfWork / MediatR - [ ] 實體沒有被直接當作 API request / response(改用 DTO,見 elf-api-contract) - [ ] 有單元測試(elf-unit)與租戶 / 軟刪除整合測試(elf-integration) ## 5. 常見錯誤 | 錯誤 | 正確 | |------|------| | `public required string Name { get; set; }` | `public string Name { get; set; } = string.Empty;` | | 屬性名 `Word` 放在 class `Word` 裡 | C# 不允許成員與型別同名;改 `Text`(參考專案實例) | | `OrderLine` 沒有 `CompanyId`,靠 join `Order` 判斷租戶 | 子實體也繼承 `CompanyEntity` | | request DTO 帶 `CompanyId` 並寫入 | `CompanyId` 由 AOP 從目前租戶填入 | | `CreatedAt = DateTime.Now` | 交給 AOP;需要時用 `DateTime.UtcNow` | | `order.Status = OrderStatus.Placed;` 寫在服務裡 | `order.TryPlace()`,規則在實體 | | 狀態不允許時 `throw new InvalidOperationException` | 回傳 `false` / 結果 enum,endpoint 回 409 | | enum 不寫值,後來在中間插入新成員 | 每個成員明寫數值,只在尾端新增 | | `ux_customers_code (code)` 在租戶資料上 | `ux_customers_company_code (company_id, code)` | | 把可查詢的欄位塞進 `jsonb` | 需要查詢 / 索引的拆成欄位 | | 為了「以後可能會加欄位」先用 `jsonb` | 預設欄位 / 子表;只在規則 29 三項都成立時用 `jsonb` | | 新增 `IRepository<T>`、`UnitOfWork` | 服務直接用 `ISqlSugarClient` | ## 6. 待確認 - 主鍵型別:目前 `int` identity(參考專案);大量資料表是否改 `long`,或採 `Guid`(PostgreSQL 18 內建 `uuidv7()`)。 - `CompanyId` 型別(目前 `int`)。租戶來源的 Claim 名稱已定為 `ElfClaimTypes.CompanyId = "company_id"`(見 elf-sqlsugar references §1);JWT 簽發方式仍取決於 elf-dotnet 的認證設計。 - 軟刪除是否需要 `DeletedBy` 欄位;已刪除資料的保留 / 清除期限。 - 領域事件機制(是否導入、採 in-process 或 outbox)。 - SqlSugar `IsOwnsOne`(5.1.4.221 已有此屬性)是否可取代「值物件拆原始欄位」作法——需實測後決定。 - SqlSugar 是否支援 `private set` 物化;若支援,是否改為私有 setter 強化封裝。 - 何時從單一 `<App>.Api` 拆出獨立 Domain 專案(見 elf-dotnet)。
View on GitHub