The BookStore API follows strict standards for time handling, JSON serialization, endpoint organization, and error handling to ensure consistency, interoperability, and maintainability.
Rule: Each feature area is a static class with a single extension method on RouteGroupBuilder. Handler methods are private static (or internal static) named methods below the registration method.
public static class BookEndpoints
{
public static RouteGroupBuilder MapBookEndpoints(this RouteGroupBuilder group)
{
_ = group.MapGet("/", SearchBooks)
.WithName("GetBooks")
.WithSummary("Get all books");
_ = group.MapGet("/{id:guid}", GetBook)
.WithName("GetBook")
.WithSummary("Get book by ID");
return group;
}
// Handler methods: named, static
static async Task<Ok<PagedListDto<BookDto>>> SearchBooks(...) { ... }
static async Task<IResult> GetBook(Guid id, ...) { ... }
}- Public API:
/api/{resource}— e.g.,/api/books,/api/authors - Admin API:
/api/admin/{resource}— e.g.,/api/admin/books,/api/admin/authors
Both tiers are wired up in EndpointMappingExtensions.MapApiEndpoints():
// Public endpoints
publicApi.MapGroup("/books")
.WithMetadata(new AllowAnonymousTenantAttribute())
.MapBookEndpoints()
.WithTags("Books");
// Admin endpoints (authorization applied inside MapAdminBookEndpoints)
adminApi.MapGroup("/books")
.MapAdminBookEndpoints()
.WithTags("Admin - Books");.WithName("...")— unique operation name, on every endpoint.WithSummary("...")— short description, on every endpoint.WithTags("...")— applied at the group level inEndpointMappingExtensions, not per endpoint.RequireAuthorization()— on individual endpoints or entire groups.RequireAuthorization("Admin")— applied to the admin group as a whole (viareturn group.RequireAuthorization("Admin")).WithMetadata(new AllowAnonymousTenantAttribute())— applied to public groups to allow access without requiring a resolved tenant (public read endpoints only).DisableAntiforgery().Accepts<IFormFile>("multipart/form-data")— for file upload endpoints
_ = group.MapPost("/{id:guid}/cover", UploadCover)
.WithName("UploadBookCover")
.WithSummary("Upload book cover image")
.DisableAntiforgery()
.Accepts<IFormFile>("multipart/form-data");Groups are associated with an ApiVersionSet built in MapApiEndpoints:
var apiVersionSet = app.NewApiVersionSet()
.HasApiVersion(new Asp.Versioning.ApiVersion(1))
.ReportApiVersions()
.Build();
var publicApi = app.MapGroup("/api")
.WithApiVersionSet(apiVersionSet);| Source | Attribute | Example |
|---|---|---|
| Route segment | implicit (or [FromRoute]) |
Guid id from /{id:guid} |
| Query string object | [AsParameters] |
[AsParameters] BookSearchRequest request |
| Request body (JSON) | [FromBody] |
[FromBody] CreateBookRequest request |
| DI services | [FromServices] |
[FromServices] IQuerySession session |
| Query string single param | [FromQuery] |
[FromQuery] string? tenantId |
| Raw HTTP context | (direct parameter) | HttpContext context |
| Cancellation | (direct parameter) | CancellationToken cancellationToken |
static async Task<Ok<PagedListDto<BookDto>>> SearchBooks(
[FromServices] IQuerySession session,
[FromServices] HybridCache cache,
[FromServices] ITenantContext tenantContext,
[AsParameters] BookSearchRequest request, // query string object
HttpContext context,
CancellationToken cancellationToken)
{
// ...
return TypedResults.Ok(pagedResult);
}static Task<IResult> CreateBook(
[FromBody] CreateBookRequest request, // JSON body
[FromServices] IMessageBus bus,
[FromServices] ITenantContext tenantContext,
CancellationToken cancellationToken)
{
var command = new CreateBook(...);
return bus.InvokeAsync<IResult>(command,
new DeliveryOptions { TenantId = tenantContext.TenantId },
cancellationToken);
}Rule: Write endpoints are thin dispatch layers that resolve IMessageBus and dispatch commands with bus.InvokeAsync(...). They do not contain business logic.
Rule: Tenant-aware command dispatch must always set DeliveryOptions.TenantId from ITenantContext:
return bus.InvokeAsync<IResult>(
command,
new DeliveryOptions { TenantId = tenantContext.TenantId },
cancellationToken);For write operations that require optimistic concurrency, extract the If-Match header directly from HttpContext:
static Task<IResult> UpdateBook(
Guid id,
[FromBody] UpdateBookRequest request,
[FromServices] IMessageBus bus,
[FromServices] ITenantContext tenantContext,
HttpContext context,
CancellationToken cancellationToken)
{
var etag = context.Request.Headers["If-Match"].FirstOrDefault();
var command = new UpdateBook(id, ...) { ETag = etag };
return bus.InvokeAsync<IResult>(command, ...);
}Admin resources follow a consistent soft-delete pattern:
DELETE /api/admin/{resource}/{id}for soft deletePOST /api/admin/{resource}/{id}/restorefor restore- Both operations propagate
If-MatchfromHttpContextinto commandETagbefore dispatching withIMessageBus
var etag = context.Request.Headers["If-Match"].FirstOrDefault();
var command = new RestoreBook(id) { ETag = etag };
return bus.InvokeAsync<IResult>(command,
new DeliveryOptions { TenantId = tenantContext.TenantId },
cancellationToken);| Scenario | Return Type |
|---|---|
| Read endpoint with one success response | Task<Ok<PagedListDto<T>>> or Task<Ok<T>> |
| Read endpoint that may return NotFound | Task<IResult> |
| Write endpoint (delegates to Wolverine) | Task<IResult> |
| Handler returning error via Result pattern | IResult |
Rule (endpoint handlers): Always use TypedResults.* (not Results.*) in endpoint handler methods — even when the declared return type is Task<IResult> — so TypedResults benefits apply where possible.
Rule (Wolverine handlers): Wolverine command handlers return Task<IResult> and use Results.* for success responses (e.g., Results.Created) and Result.Failure(...).ToProblemDetails() for all error responses.
Use the concrete typed form for single-return-type handlers — this gives automatic OpenAPI schema generation:
static async Task<Ok<PagedListDto<AuthorDto>>> GetAuthors(...)
{
// ...
return TypedResults.Ok(pagedResult);
}Use Task<IResult> when the handler may return NotFound or needs to attach an ETag header. Internally still use TypedResults.* for the actual return values. Use the WithETag() extension to attach the ETag response header:
static async Task<IResult> GetAuthor(Guid id, ...)
{
var author = await LoadAuthorAsync(id, ...);
if (author is null)
{
return TypedResults.NotFound();
}
return TypedResults.Ok(author).WithETag(author.ETag!); // WithETag wraps to IResult
}ETagHelper.PreconditionFailed() is used in Wolverine handlers when an optimistic concurrency version check fails:
var expectedVersion = ETagHelper.ParseETag(command.ETag);
if (expectedVersion.HasValue && aggregate.Version != expectedVersion.Value)
{
return ETagHelper.PreconditionFailed(); // 412 PreconditionFailed
}Write endpoints dispatch commands to Wolverine handlers, which return IResult directly:
// Endpoint: thin dispatch layer
static Task<IResult> CreateBook(
[FromBody] CreateBookRequest request,
[FromServices] IMessageBus bus,
[FromServices] ITenantContext tenantContext,
CancellationToken cancellationToken)
{
var command = new CreateBook(...);
return bus.InvokeAsync<IResult>(command,
new DeliveryOptions { TenantId = tenantContext.TenantId },
cancellationToken);
}
// Handler: owns the response
public static async Task<IResult> Handle(
CreateBook command,
IDocumentSession session, ...)
{
if (invalid)
{
return Result.Failure(Error.Validation(ErrorCodes.Books.LanguageInvalid, "Invalid language code"))
.ToProblemDetails();
}
// ... persist event ...
return Results.Created($"/api/admin/books/{command.Id}", new { id = command.Id });
}Rule: All timestamps MUST use UTC timezone.
✅ Correct:
var timestamp = DateTimeOffset.UtcNow;
var eventTime = DateTimeOffset.UtcNow;❌ Incorrect:
var timestamp = DateTime.Now; // Local timezone - NEVER use
var eventTime = DateTimeOffset.Now; // Local timezone - NEVER useRule: Use DateTimeOffset instead of DateTime for all timestamps.
✅ Correct:
public DateTimeOffset Timestamp { get; set; }
public DateTimeOffset LastModified { get; set; }❌ Incorrect:
public DateTime Timestamp { get; set; } // No timezone info - NEVER useAll date/time values are automatically serialized in ISO 8601 format:
{
"timestamp": "2025-12-26T17:16:09.123Z",
"lastModified": "2025-12-26T17:16:09Z",
"publicationDate": "2008-08-01"
}Format Details:
DateTimeOffset:YYYY-MM-DDTHH:mm:ss.fffZ(with milliseconds)DateOnly:YYYY-MM-DD- Timezone: Always
Z(UTC)
Rule: Use PartialDate for incomplete dates (e.g., publication year only).
public record BookDto(
string Title,
PartialDate? PublicationDate // ✅ Can be year, year-month, or full date
);Capabilities:
- Year only:
2008 - Year-Month:
2008-08 - Full Date:
2008-08-01
Client Usage: Always check for value before access:
if (book.PublicationDate.HasValue)
{
var year = book.PublicationDate.Value.Year;
var display = book.PublicationDate.Value.ToDisplayString();
}Rule: All JSON properties use camelCase.
{
"bookId": "018d5e4a-7b2c-7000-8000-123456789abc",
"title": "Clean Code",
"publicationDate": "2008-08-01",
"lastModified": "2025-12-26T17:16:09Z"
}Rule: Enums are serialized as strings, not integers.
✅ Correct (String):
{
"status": "Active",
"role": "Administrator",
"orderStatus": "Shipped"
}❌ Incorrect (Integer):
{
"status": 0,
"role": 1,
"orderStatus": 2
}Benefits:
- Readable:
"Active"is clearer than0 - Evolvable: Can reorder enum values without breaking API
- Self-documenting: No need to look up enum definitions
- Debuggable: Easier to understand logs and database queries
Configured in Program.cs:
builder.Services.ConfigureHttpJsonOptions(options =>
{
options.SerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
options.SerializerOptions.Converters.Add(
new JsonStringEnumConverter(JsonNamingPolicy.CamelCase));
});Configured in Program.cs:
options.UseDefaultSerialization(
enumStorage: EnumStorage.AsString,
casing: Casing.CamelCase);Rule: Use Guid.CreateVersion7() for all entity identifiers.
✅ Correct:
public record CreateBook(
Guid Id, // ✅ Assigned by client (UUIDv7)
string Title,
string? Isbn,
IReadOnlyList<Guid> AuthorIds);
var bookId = Guid.CreateVersion7();❌ Incorrect:
var bookId = Guid.NewGuid(); // WRONG - creates random UUIDv4Benefits of UUIDv7:
- ✅ Time-ordered: Naturally sortable by creation time
- ✅ Database performance: Better index locality and reduced fragmentation
- ✅ Distributed systems: Safe to generate across multiple servers
- ✅ No collisions: Globally unique without coordination
Format:
graph TD
UUID[UUIDv7: 018d5e4a-7b2c-7000-8000-123456789abc]
UUID --> Time[Timestamp<br/>48 bits]
UUID --> Ver[Version<br/>4 bits]
UUID --> Var[Variant<br/>2 bits]
UUID --> Rand[Random<br/>74 bits]
Note
The BookStore project includes a Roslyn analyzer (BS1006) that enforces this convention by flagging any use of Guid.NewGuid().
Rule: Use record types for immutable data structures (DTOs, commands, events).
✅ Correct:
// DTOs
// DTOs (in BookStore.Shared.Models)
public record BookDto(
Guid Id,
string Title,
string? Isbn,
PublisherDto? Publisher,
IReadOnlyList<AuthorDto> Authors); // ✅ Use IReadOnlyList for collections
// Commands
public record CreateBook(
Guid Id,
string Title,
string? Isbn,
IReadOnlyList<Guid> AuthorIds); // ✅ Use IReadOnlyList for collections
// Events
public record BookAdded(
Guid Id,
string Title,
DateTimeOffset Timestamp);❌ Incorrect:
// WRONG - using class for immutable data
public class BookDto
{
public Guid Id { get; set; }
public string Title { get; set; }
}Benefits:
- ✅ Immutability: Value-based equality by default
- ✅ Concise: Less boilerplate code
- ✅ Thread-safe: Immutable objects are inherently thread-safe
- ✅ Event sourcing: Perfect for immutable events
Rule: Enable nullable reference types and use ? for optional values.
✅ Correct:
public record BookDto(
Guid Id,
string Title, // Required
string? Isbn, // Optional
string? Description, // Optional
PublisherDto? Publisher // Optional
);❌ Incorrect:
public record BookDto(
Guid Id,
string Title,
string Isbn, // WRONG - should be string? if optional
string Description // WRONG - should be string? if optional
);Configuration:
<!-- In .csproj -->
<PropertyGroup>
<Nullable>enable</Nullable>
</PropertyGroup>Rule: Use consistent pagination response format.
public record PagedListDto<T>(
IReadOnlyList<T> Items,
long PageNumber,
long PageSize,
long TotalItemCount)
{
public long PageCount { get; init; } =
(long)double.Ceiling(TotalItemCount / (double)PageSize);
public bool HasPreviousPage { get; init; } = PageNumber > 1;
public bool HasNextPage { get; init; } =
PageNumber < (long)double.Ceiling(TotalItemCount / (double)PageSize);
}Most endpoints return PagedListDto<T>. When a query uses Marten's IPagedList<T> and needs a serialization-friendly wrapper directly from that source, use PagedListAdapter<T> (currently used in publisher listing endpoints). The JSON shape remains the same pagination contract (items, pageNumber, pageSize, totalItemCount, pageCount, hasPreviousPage, hasNextPage).
JSON Response:
{
"items": [
{ "id": "...", "title": "Clean Code" },
{ "id": "...", "title": "Design Patterns" }
],
"pageNumber": 1,
"pageSize": 20,
"totalItemCount": 42,
"pageCount": 3,
"hasPreviousPage": false,
"hasNextPage": true
}Rule: Use page and pageSize query parameters.
GET /api/books?page=2&pageSize=20&search=architecture
Default Values:
page: 1pageSize: 20 (configurable viaPaginationOptions)
Rule: All async API endpoints and handlers MUST accept and propagate CancellationToken.
✅ Correct:
static async Task<Ok<BookDto>> GetBook(
Guid id,
[FromServices] IDocumentStore store,
CancellationToken cancellationToken) // ✅ Accepted
{
await using var session = store.QuerySession();
var book = await session.LoadAsync<BookProjection>(id, cancellationToken); // ✅ Propagated
return TypedResults.Ok(book);
}❌ Incorrect:
static async Task<Ok<BookDto>> GetBook(
Guid id,
[FromServices] IDocumentStore store) // ❌ Missing parameter
{
await using var session = store.QuerySession();
var book = await session.LoadAsync<BookProjection>(id); // ❌ Not propagated
return TypedResults.Ok(book);
}Benefits:
- ✅ Resource Hygiene: Frees up database connections and memory immediately when a client disconnects.
- ✅ Responsiveness: Prevents ghost processes from consuming CPU for abandoned requests.
- ✅ Throughput: Improves overall system capacity under load.
The API uses the following standard headers:
| Header | Required | Description | Example |
|---|---|---|---|
Accept-Language |
No | Preferred language for localized content | pt-PT, en-US |
If-None-Match |
No | ETag for conditional requests (caching) | "5" |
If-Match |
Yes* | ETag for optimistic concurrency control | "5" |
* Enforced by ETagValidationMiddleware on protected write routes (for example: admin PUT, admin soft-delete DELETE, and restore POST actions). Missing If-Match returns 428 Precondition Required. Specific high-concurrency/idempotent routes are excluded (book rating, favorites, and cart routes).
ETagValidationMiddleware runs in the API pipeline and performs header-presence enforcement before handlers execute:
- Validates
If-Matchpresence for configured write routes - Returns
428 Precondition Requiredwhen header is missing - Leaves version match validation to handler/aggregate logic (which can still return
412 Precondition Failed)
| Header | Description | Example |
|---|---|---|
ETag |
Entity version for caching and concurrency | "5" |
Cache-Control |
Caching directives | private, max-age=60 |
Rule: Use X-Correlation-ID and X-Causation-ID for distributed tracing.
POST /api/admin/books
X-Correlation-ID: workflow-123
X-Causation-ID: user-action-456
Content-Type: application/json
{
"title": "Clean Code",
...
}Behavior:
- If
X-Correlation-IDis not provided, the API generates a new UUIDv7 - The API echoes back the correlation ID in the response
- All events in the event store are tagged with correlation and causation IDs
Benefits:
- ✅ Traceability: Track requests across services
- ✅ Debugging: Correlate logs and events
- ✅ Auditing: Understand cause-and-effect relationships
Rule: Use api-version header for API versioning.
GET /api/books
api-version: 1.0Current Version: 1.0
Benefits:
- ✅ Clean URLs: No version in the path
- ✅ Flexible: Can version individual endpoints
- ✅ Backward compatible: Defaults to latest version
The API supports multiple languages as configured in appsettings.json.
Example configuration:
"Localization": {
"DefaultCulture": "en",
"SupportedCultures": ["pt", "en", "fr", "de", "es"]
}Rule: Use Accept-Language header for localized content.
GET /api/categories
Accept-Language: pt-PTResponse:
{
"items": [
{ "id": "...", "name": "Ficção" },
{ "id": "...", "name": "Mistério" }
]
}The API uses a 5-step fallback strategy via LocalizationHelper:
- Exact culture match - e.g., "pt-PT"
- Two-letter user culture - e.g., "pt" from "pt-PT"
- Default culture - configured in
LocalizationOptions - Two-letter default culture - e.g., "en" from "en-US"
- Fallback value - empty string or "Unknown"
This ensures users always see content, even if their preferred language isn't available.
Translations are stored in Dictionary<string, string> properties within projection documents:
public class CategoryProjection
{
public Dictionary<string, string> Names { get; set; } = [];
}Endpoints use LocalizationHelper to extract the correct translation:
var localizedName = LocalizationHelper.GetLocalizedValue(
category.Names,
culture,
defaultCulture,
"Unknown");See the Localization Guide for complete details.
Rule: Use the Result pattern for domain logic and validation. Avoid exceptions for control flow.
The Result and Result<T> types (in BookStore.Shared) encapsulate success/failure state and error details.
✅ Correct:
public Result<BookAdded> CreateEvent(...)
{
if (invalid)
{
return Result.Failure<BookAdded>(Error.Validation("ERR_CODE", "Message"));
}
return new BookAdded(...);
}❌ Incorrect:
public BookAdded CreateEvent(...)
{
if (invalid)
{
throw new DomainException("Message"); // Avoid exceptions
}
return new BookAdded(...);
}Rule: All error responses use the Problem Details format (RFC 7807), mapped from Result errors.
Use result.ToProblemDetails() extension method (defined in ResultExtensions) in handlers to automatically map errors to IResult:
ErrorType.Validation→400 Bad RequestErrorType.NotFound→404 Not FoundErrorType.Conflict→409 ConflictErrorType.Unauthorized→401 UnauthorizedErrorType.Forbidden→403 ForbiddenErrorType.InternalServerError/ErrorType.Failure→500 Internal Server Error
Rate-limit rejections use the same RFC 7807 structure with 429 Too Many Requests:
{
"type": "https://tools.ietf.org/html/rfc6585#section-4",
"title": "Too Many Requests",
"status": 429,
"detail": "Rate limit exceeded. Please retry after the specified duration.",
"error": "ERR_AUTH_RATE_LIMIT_EXCEEDED",
"retryAfter": 12.5
}{
"type": "https://tools.ietf.org/html/rfc7231#section-6.5.1",
"title": "Bad Request",
"status": 400,
"detail": "The language code 'xx' is not valid.",
"error": "ERR_BOOK_LANGUAGE_INVALID"
}Note
The error field is a top-level extension member of the RFC 7807 document (not nested under an extensions object). This is how Results.Problem(extensions: ...) serializes the dictionary entries.
Rule: Unhandled exceptions are caught by a global handler and returned as 500 Internal Server Error Problem Details with code ERR_INTERNAL_ERROR.
The application uses a fixed-per-tenant strategy for database schemas (Marten) and logical isolation for other resources.
Rule: Tenants are identified via the X-Tenant-ID header.
GET /api/books
X-Tenant-ID: default- Development: Defaults to
defaultif missing. - Production: Required for all tenant-specific endpoints.
In code, inject ITenantContext to access the current tenant:
public static async Task<IResult> GetBooks(
[FromServices] ITenantContext tenant,
...)
{
var tenantId = tenant.TenantId;
// ...
}For command endpoints, tenant context is also propagated into Wolverine dispatch via DeliveryOptions.TenantId, ensuring command handling executes under the resolved tenant.
return bus.InvokeAsync<IResult>(command,
new DeliveryOptions { TenantId = tenantContext.TenantId },
cancellationToken);Rule: Mutating handlers invalidate HybridCache entries with tag-based invalidation immediately after appending events.
- Use list tags for collection queries (for example
CacheTags.BookList) - Use item tags for entity queries (for example
CacheTags.ForItem(CacheTags.BookItemPrefix, id)) - Combine both tags on updates/deletes/restores to prevent stale list and item reads
await cache.RemoveByTagAsync(
[CacheTags.BookList, CacheTags.ForItem(CacheTags.BookItemPrefix, command.Id)],
default);Always use DateTimeOffset.UtcNow for event timestamps:
public static BookAdded Create(Guid id, string title, ...)
{
return new BookAdded(
id,
title,
...,
DateTimeOffset.UtcNow // ✅ Always UTC
);
}Use DateTimeOffset for all timestamp properties:
public record BookAdded(
Guid Id,
string Title,
DateTimeOffset Timestamp // ✅ DateTimeOffset with UTC
);Use DateTimeOffset.UtcNow for time-based queries:
var recentBooks = await session.Query<BookSearchProjection>()
.Where(b => b.LastModified > DateTimeOffset.UtcNow.AddDays(-7))
.ToListAsync();- ✅ No timezone conversion errors
- ✅ Consistent across all servers
- ✅ Works globally without confusion
- ✅ Simplifies distributed systems
- ✅ Universal standard (RFC 3339)
- ✅ Sortable as strings
- ✅ Human-readable
- ✅ Supported by all platforms
- ✅ Time-ordered for better performance
- ✅ Database index optimization
- ✅ Natural sorting by creation time
- ✅ Distributed generation without conflicts
- ✅ Immutability by default
- ✅ Value-based equality
- ✅ Less boilerplate code
- ✅ Perfect for DTOs and events
- ✅ Self-documenting APIs
- ✅ Safe enum reordering
- ✅ Easier debugging
- ✅ Better database queries
- ✅ JavaScript/TypeScript convention
- ✅ Consistent with web standards
- ✅ Better readability in JSON
var timestamp = DateTime.Now; // WRONG - uses local timezonepublic DateTime Timestamp { get; set; } // WRONG - no timezone infovar id = Guid.NewGuid(); // WRONG - creates random UUIDv4public class BookDto // WRONG - should use record
{
public Guid Id { get; set; }
}var dateStr = date.ToString("yyyy-MM-dd"); // WRONG - use serialization// WRONG - will serialize as integer without configuration
public enum Status { Active, Inactive }public record BookDto(
string Isbn // WRONG - should be string? if optional
);Test JSON serialization format:
[Test]
public async Task DateTimeOffset_Should_Serialize_As_ISO8601()
{
var obj = new { timestamp = DateTimeOffset.UtcNow };
var json = JsonSerializer.Serialize(obj);
// Should match ISO 8601 format: "2025-12-26T17:16:09.123Z"
await Assert.That(json).Matches(@"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z");
}
[Test]
public async Task Enum_Should_Serialize_As_String()
{
var obj = new { status = Status.Active };
var json = JsonSerializer.Serialize(obj);
await Assert.That(json).Contains("\"Active\"");
await Assert.That(json).DoesNotContain("0");
}
[Test]
public async Task Guid_Should_Be_Version7()
{
var id = Guid.CreateVersion7();
var bytes = id.ToByteArray();
// Version 7 has version bits set to 0111
await Assert.That((bytes[7] & 0xF0) >> 4).IsEqualTo(7);
}Golden Rules:
- ✅ Always use
DateTimeOffset.UtcNow(neverDateTime.Now) - ✅ Always use
DateTimeOffsettype (neverDateTime) - ✅ Always use
Guid.CreateVersion7()(neverGuid.NewGuid()) - ✅ Always use
recordtypes for DTOs, commands, and events - ✅ ISO 8601 format is automatic (don't format manually)
- ✅ Enums serialize as strings (configured globally)
- ✅ JSON properties use camelCase (configured globally)
- ✅ Use nullable reference types (
string?for optional values) - ✅ Use
X-Correlation-IDandX-Causation-IDfor tracing - ✅ Use
Accept-Languageheader for localization - ✅ Use Problem Details (RFC 7807) for error responses
- ✅ Propagate
CancellationTokenin all async methods
These standards ensure the API is:
- Consistent: Same format everywhere
- Interoperable: Works with all clients
- Maintainable: Easy to understand and debug
- Scalable: Works across timezones and regions
- Performant: Optimized for databases and distributed systems