- name
- elf-dotnet
- description
- Elf Express ASP.NET Core 10 後端規範(minimal API + SqlSugar + PostgreSQL)。
當任務涉及建立或修改 server/ 下的 .NET 方案、專案結構(.slnx、.csproj、Directory.*.props)、Program.cs、
minimal API endpoint、服務層、依賴注入生命週期(Singleton/Scoped/Transient)、appsettings 與環境變數、
連線字串、例外處理、ILogger 記錄、async/await 與 CancellationToken、C# 命名慣例時觸發。
- metadata
- {"version":"1.0.0","owner":"Elf Express"}
# Elf Express .NET 後端規範(elf-dotnet)
依據:參考專案 `TypingTrainer/server`(ASP.NET Core 10 minimal API + SqlSugar),
轉換為團隊標準(PostgreSQL 18、`server/src/` + `server/tests/` 版面、Central Package Management、ProblemDetails 錯誤格式)。
相關 skill:
- 版本號 → **elf-stack**(本 skill 不寫版本號)
- 資料存取 → **elf-sqlsugar**;資料庫 → **elf-postgresql**;Entity 設計 → **elf-domain-modeling**
- URL / 回應格式 / 錯誤格式 → **elf-api-design**、**elf-api-contract**
- 測試 → **elf-unit**、**elf-integration**;CI → **elf-cicd-backend**
## 1. 何時使用
- 建立新的後端方案或新專案。
- 新增 endpoint、服務、設定項目。
- 修改 `Program.cs`、DI 註冊、設定檔、例外處理、記錄方式。
- 審查 C# 後端程式碼。
## 2. 固定規則
### 2.1 方案結構
1. **MUST** 後端全部放在 repo 的 `server/` 下,方案檔為 `server/<App>.slnx`(XML 格式 `.slnx`,不用舊 `.sln`)。
2. **MUST** 產品程式碼放 `server/src/<Project>/`;單元測試放 `server/tests/<Project>.Tests/`(一個主專案對應一個測試專案,見 elf-unit);
整合測試(需要 Docker,含 WebApplicationFactory / contract 測試)另放 `server/tests/<App>.Api.IntegrationTests/`(見 elf-integration)。
3. **MUST** Web 專案命名 `<App>.Api`,使用 `Microsoft.NET.Sdk.Web`。
4. **MUST** 預設**單一** `<App>.Api` 專案,以資料夾分層(見 2.2)。**MUST NOT** 自行拆出 `<App>.Domain` / `<App>.Infrastructure` / `<App>.Application`,除非使用者要求(拆分準則「待確認」)。
5. 可跨產品重用的模組放 `server/framework/<Vendor>.Framework.<Module>/`,介面放同名 `.Abstractions` 專案、每個 provider 一個專案(參考專案 `XiHan.Framework.Translation*` 模式)。只有在使用者明確說「做成可重用模組」時才建立。
6. **MUST** 共用 MSBuild 屬性放 `server/Directory.Build.props`,套件版本放 `server/Directory.Packages.props`(見 elf-stack);`.csproj` 不寫 `TargetFramework`、`Nullable`、`ImplicitUsings`、套件 `Version`。
### 2.2 `<App>.Api` 內部資料夾
| 資料夾 | 內容 | 命名 |
|--------|------|------|
| `Api/` | minimal API endpoint 群組(static class + extension method) | `<Feature>Endpoints.cs`,方法 `Map<Feature>()` |
| `Services/` | 業務邏輯;endpoint 只呼叫服務 | `<Feature>Service.cs`(介面 `I<Feature>Service`) |
| `Data/` | SqlSugar 註冊、CodeFirst 實體清單、種子資料、目前使用者 / 租戶 | `SqlSugarSetup.cs`、`<Name>Seeds.cs` |
| `Models/` | SqlSugar 實體(見 elf-domain-modeling) | 一個聚合一個檔案,如 `Order.cs` |
| `Options/` | 強型別設定類別 | `<Section>Options.cs` |
| `Infrastructure/` | 跨 feature 的 HTTP 基礎:`Problems`、`ApiValidation`、`GlobalExceptionHandler`、`AppException`、`PagedResult<T>` / `Paging` | `Problems` 見 references §11;其餘見 elf-api-design references |
| `Auth/` | 認證相關常數與處理:`ElfClaimTypes`(claim 名稱唯一定義) | `Auth/ElfClaimTypes.cs`,見 elf-sqlsugar references §1 |
| `Program.cs` | 組裝:DI 註冊、middleware、`MapGroup("/api")` | — |
7. **MUST** 分層方向:`Api/`(endpoint)→ `Services/` → `ISqlSugarClient`。endpoint 內**只**做參數繫結、輸入驗證、呼叫服務、把結果轉成 HTTP 結果。
- 唯一例外:純參照資料(非租戶、非軟刪除、無業務規則)可用 `MapCrud<T>` plumbing(見 references §10),它直接用 `ISqlSugarClient`。
8. **MUST** 使用 minimal API;**MUST NOT** 新增 MVC Controller(參考專案全部為 minimal API)。
9. **MUST** 所有路由掛在 `app.MapGroup("/api")` 底下;每個 feature `MapGroup(prefix).WithTags("<Feature>")`,每個 endpoint `.WithName("<Verb><Resource>")`。
10. **MUST** 提供 `GET /api/health` 回 `{ status = "ok", utc = DateTime.UtcNow }`(docker healthcheck 依賴它)。
11. OpenAPI:`builder.Services.AddOpenApi()`;`app.MapOpenApi()` **只在 Development** 對外(參考專案作法)。
### 2.3 依賴注入生命週期
12. **MUST** `ISqlSugarClient` 註冊為 **Singleton**,實作為 `SqlSugarScope`(執行緒安全)。**MUST NOT** 把 `SqlSugarClient` 註冊為 Singleton。
13. **MUST** 依下表選生命週期:
| 條件 | 生命週期 |
|------|----------|
| 無狀態,或狀態只來自設定 / 環境(如參考專案 `ModelStorage`) | `AddSingleton` |
| 被 Singleton 依賴、又需要讀當前請求 → 透過 `IHttpContextAccessor` 讀取(如 `ICurrentTenant`) | `AddSingleton` |
| 業務服務(`<Feature>Service`)預設 | `AddScoped` |
| `DelegatingHandler`(`AddHttpClient(...).AddHttpMessageHandler<T>()` 要求每次新實例) | `AddTransient` |
`AddTransient` **只**用於上表最後一列,以及 ASP.NET Core 文件明確要求 Transient 的擴充點;其他情況 **MUST NOT** 使用(無狀態用 Singleton,需要請求範圍用 Scoped)。
14. **MUST NOT** 讓 Singleton 依賴 Scoped(captive dependency)。Development 預設會驗證 scope,啟動失敗時修生命週期,不要關驗證。
15. **MUST** 每個模組提供 `Add<Module>(this IServiceCollection, ...)` extension,放在該模組內(如 `Data/SqlSugarSetup.cs` 的 `AddSqlSugar`);`Program.cs` 只呼叫 extension。
### 2.4 設定與連線字串
16. **MUST** 連線字串名稱固定為 `ConnectionStrings:Default`,以 `config.GetConnectionString("Default")` 讀取。
17. **MUST** 非本機環境以環境變數覆寫:`ConnectionStrings__Default`、`<Section>__<Key>`(雙底線)。
18. **MUST NOT** 在 `appsettings*.json` commit 任何正式密碼、API key、正式連線字串。`appsettings.json` 只放**空字串占位**(參考專案 `Translation:Google:ApiKey` 寫成 `"Translation": { "Google": { "ApiKey": "" } }`)與本機 docker 開發用的非機密預設值。
19. **MUST** 連線字串缺失時啟動即失敗(`?? throw new InvalidOperationException(...)`)。**MUST NOT** 在程式碼寫 fallback 連線字串(參考專案 SQLite 的 `?? "DataSource=app.db"` 不可沿用)。
20. **MUST** 以 Options pattern 讀取區段:`AddOptions<XOptions>().Bind(config.GetSection(XOptions.Section)).ValidateOnStart()`;服務注入 `IOptions<XOptions>`。**MUST NOT** 在服務裡散落 `config["A:B"]`。
21. CORS 允許來源從 `Cors:Origins`(字串陣列)讀取(參考專案模式);**MUST NOT** `AllowAnyOrigin()`。
### 2.5 例外處理與回應
22. **MUST** 服務的預期失敗只有兩種回傳方式:
- **查詢單筆**:回 `T?`,`null` 由 endpoint 轉 404。
- **命令(新增 / 修改 / 狀態轉換)有預期失敗時**:回**結果 enum**(`PlaceResult.Placed / NotFound / InvalidState`,成員明寫數值),endpoint 以 `switch` 對應每個成員。沒有預期失敗的命令直接回結果值(如新 Id)。
**MUST NOT** 用 `bool` 表示失敗原因、**MUST NOT** 用 throw 當流程控制;服務層 **MUST NOT** 丟 `AppException` 或建立任何 HTTP 結果。
23. **MUST** 未預期的例外交給全域處理:`AddProblemDetails(...)` + `AddExceptionHandler<GlobalExceptionHandler>()` + `app.UseExceptionHandler()` + `app.UseStatusCodePages()`(elf-api-design §2.3);**MUST NOT** 在 endpoint 內 `catch (Exception)` 吞例外,或把 `ex.Message` / stack trace 回給用戶端。
24. **MUST** 錯誤回應一律為 RFC 9457 ProblemDetails 並帶 `code`(`<resource>.<reason>`,格式由 **elf-api-design** 擁有)。endpoint 以 `Problems.NotFound / Conflict / BadRequest / Forbidden(code, detail)`(references §11,內部為 `TypedResults.Problem`)轉換服務結果;驗證失敗用 `ApiValidation.Problem(...)`。
`AppException` 只允許在 endpoint 層(endpoint filter、共用 endpoint helper)丟出。**MUST NOT** 回 `new { message = ... }` 等自訂錯誤形狀。
25. **MUST** 程式錯誤(不該發生的參數)用 `ArgumentNullException.ThrowIfNull(x)`、`ArgumentException.ThrowIfNullOrWhiteSpace(x)`、`ArgumentOutOfRangeException.ThrowIfNegativeOrZero(x)`。
### 2.6 記錄(Logging)
26. **MUST** 注入 `ILogger<T>`;使用 message template 具名占位:`logger.LogWarning("Provider {Provider} failed for {Target}", name, target)`。
27. **MUST NOT** 用 `$"..."` 組 log 訊息;**MUST NOT** 記錄密碼、token、連線字串、完整個資。
28. **MUST NOT** 用 `Console.WriteLine`;SQL log 只在 Development 經 `db.Aop.OnLogExecuting` 寫到 `ILogger`(Debug)。
29. 等級:`Information` 業務事件、`Warning` 可恢復的外部失敗、`Error` 需人處理的失敗。`appsettings.json` 的 `Microsoft.AspNetCore` 設 `Warning`(參考專案設定)。
### 2.7 async
30. **MUST** 所有 I/O(DB、HTTP、檔案)使用 async API;方法名以 `Async` 結尾,回傳 `Task` / `Task<T>`。
31. **MUST** endpoint 宣告 `CancellationToken ct` 參數並一路傳到服務與 SqlSugar / HttpClient(參數名統一 `ct`)。
32. **MUST NOT** 使用 `.Result`、`.Wait()`、`GetAwaiter().GetResult()`、`async void`。
33. `ConfigureAwait(false)`:`server/framework/*` 函式庫 **MUST** 使用(參考專案 XiHan 模組如此);`<App>.Api` **不需要**(ASP.NET Core 無 SynchronizationContext)。
34. 獨立的外部呼叫可用 `Task.WhenAll` 平行;**MUST NOT** 對同一個 SqlSugar 交易平行下指令。
### 2.8 命名與風格
35. **MUST** file-scoped namespace(`namespace Acme.Api.Services;`),namespace = 專案名 + 資料夾路徑。
36. **MUST** 型別 / 方法 / 屬性 PascalCase;區域變數 / 參數 camelCase;private 欄位 `_camelCase`;常數 PascalCase。縮寫當單字寫(`HttpClient`、`OrderId`)。
37. **MUST** DTO 使用 `record`,命名 `<Verb><Resource>Request` / `<Resource>Dto`。只給單一 endpoint 群組用的 DTO 可巢狀在 `<Feature>Endpoints` 內(參考專案 `PromptEndpoints.PromptDto`);跨檔共用的放 `Api/Contracts/`。
38. **MUST NOT** 把 SqlSugar 實體直接當 request body 接收(mass assignment);回應也轉 DTO(`MapCrud<T>` 除外)。
39. 服務用 primary constructor 注入(`public sealed class OrderService(ISqlSugarClient db) : IOrderService`);不打算被繼承的類別加 `sealed`。
40. 註解寫「為什麼」,不寫「做什麼」。
## 3. 標準範本
完整可複製檔案見 [`references/project-templates.md`](references/project-templates.md):
`global.json`、`Directory.Build.props`、`<App>.slnx` 與建立指令、`<App>.Api.csproj`、`<App>.Api.Tests.csproj`(`coverlet.msbuild`)、
`Program.cs`、`appsettings*.json`、`MapCrud<T>`、`Problems`。
API Dockerfile 只有一份,由 **elf-cicd-docker** 維護(`templates/api.Dockerfile`),本 skill 不另提供。
(本節與 references 的 C# 範本已於 2026-09 以 net10.0 + SqlSugarCore 5.1.4.221 實際建置通過。)
### 3.1 目錄骨架
```
<repo>/
├── global.json
└── server/
├── Acme.slnx
├── Directory.Build.props
├── Directory.Packages.props
├── src/
│ └── Acme.Api/
│ ├── Acme.Api.csproj
│ ├── Program.cs
│ ├── appsettings.json
│ ├── appsettings.Development.json
│ ├── Api/OrderEndpoints.cs
│ ├── Services/OrderService.cs
│ ├── Data/SqlSugarSetup.cs
│ ├── Models/Order.cs
│ ├── Options/CorsOptions.cs
│ ├── Auth/ElfClaimTypes.cs
│ └── Infrastructure/Problems.cs
└── tests/
├── Acme.Api.Tests/
│ ├── Acme.Api.Tests.csproj
│ └── Services/OrderServiceTests.cs
└── Acme.Api.IntegrationTests/ # needs Docker (Testcontainers), see elf-integration
└── Acme.Api.IntegrationTests.csproj
```
### 3.2 Endpoint 群組
endpoint 只做:繫結 → 驗證輸入 → 呼叫服務 → 把 `T?` / 結果 enum 轉成 HTTP 結果(`TypedResults` + `Problems`)。
```csharp
using Acme.Api.Infrastructure;
using Acme.Api.Models;
using Acme.Api.Services;
using Microsoft.AspNetCore.Http.HttpResults;
namespace Acme.Api.Api;
public static class OrderEndpoints
{
public record CreateOrderRequest(string Name, int Quantity);
public record OrderDto(int Id, string Name, int Quantity, DateTime CreatedAt);
public record CreatedIdDto(int Id);
/// <summary>Route under the /api group; used again to build Location headers.</summary>
private const string Prefix = "/orders";
public static RouteGroupBuilder MapOrders(this IEndpointRouteBuilder api)
{
var group = api.MapGroup(Prefix).WithTags("Orders");
group.MapGet("/{id:int}", GetAsync).WithName("GetOrder");
group.MapPost("/", CreateAsync).WithName("CreateOrder");
group.MapPost("/{id:int}/place", PlaceAsync).WithName("PlaceOrder");
return group;
}
// Lookup: the service returns T?; null becomes 404 ProblemDetails.
private static async Task<Results<Ok<OrderDto>, ProblemHttpResult>> GetAsync(
IOrderService orders, int id, CancellationToken ct)
{
var dto = await orders.GetAsync(id, ct);
return dto is null
? Problems.NotFound("order.not_found", $"Order {id} was not found.")
: TypedResults.Ok(dto);
}
private static async Task<Results<Created<CreatedIdDto>, ValidationProblem>> CreateAsync(
IOrderService orders, CreateOrderRequest body, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(body.Name) || body.Quantity <= 0)
{
return ApiValidation.Problem(
("name", "Name is required."),
("quantity", "Quantity must be positive."));
}
var id = await orders.CreateAsync(body, ct);
return TypedResults.Created($"/api{Prefix}/{id}", new CreatedIdDto(id));
}
// Command: the service returns a result enum; the endpoint maps every member.
private static async Task<Results<NoContent, ProblemHttpResult>> PlaceAsync(
IOrderService orders, int id, CancellationToken ct) =>
await orders.PlaceAsync(id, ct) switch
{
PlaceResult.Placed => TypedResults.NoContent(),
PlaceResult.NotFound => Problems.NotFound("order.not_found", $"Order {id} was not found."),
PlaceResult.InvalidState => Problems.Conflict("order.invalid_state", $"Order {id} is not a draft."),
var other => throw new ArgumentOutOfRangeException(nameof(other), other, null),
};
}
```
### 3.3 服務
```csharp
using Acme.Api.Data;
using Acme.Api.Models;
using SqlSugar;
using static Acme.Api.Api.OrderEndpoints;
namespace Acme.Api.Services;
public interface IOrderService
{
Task<OrderDto?> GetAsync(int id, CancellationToken ct);
Task<int> CreateAsync(CreateOrderRequest request, CancellationToken ct);
Task<PlaceResult> PlaceAsync(int id, CancellationToken ct);
}
public sealed class OrderService(ISqlSugarClient db, ILogger<OrderService> logger) : IOrderService
{
public async Task<OrderDto?> GetAsync(int id, CancellationToken ct)
{
var order = await db.Queryable<Order>().Where(o => o.Id == id).FirstAsync(ct);
return order is null
? null
: new OrderDto(order.Id, order.Name, order.Quantity, UtcTime.Normalize(order.CreatedAt));
}
public async Task<int> CreateAsync(CreateOrderRequest request, CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(request);
Ver en GitHub