Maintained in timewarp-architecture · Canonical file: SKILL.md

Install

npx skills add TimeWarpEngineering/timewarp-architecture --skill web-api-contracts

Or copy the SKILL.md into your agent's skills directory.


Web API Contracts

Endpoint-centric, JSON-over-HTTP contracts designed for Blazor front ends. Each endpoint owns its request/response types. Mutability signals purpose: immutable members are read-only display data; mutable members on I*Details interfaces bind in EditForm without a separate view model. Shared validation lives on interfaces and is composed into per-endpoint validators.

This pattern appears across TimeWarp-based solutions. Project names vary (Web.Contracts, Api.Contracts, …) but the contract shape is the same.

Detection — find the pattern in the current repo

Activate when any signal matches:

Signal How to find it
Contracts project *.csproj named *Contracts* referencing Features/
Contract file layout **/Features/**/Commands/*.cs or **/Features/**/Queries/*.cs
Contract shell public static partial class + nested Query/Command + [RouteMixin(...)]
MediatR return IRequest<OneOf<Response, SharedProblemDetails>>
Shared validation I*Details interface + AbstractValidator<I*Details>

Before adding a contract, read 2–3 existing contracts in the same repo to match namespace root, test project layout, and mock-service registration conventions.

Folder and namespace rules

Concern Rule Example
Feature folder Singular, domain-oriented Features/Admin/SecurityRole/
Namespace Plural — avoids class/name conflicts {Root}.Features.Admin.SecurityRoles
Commands / Queries Subfolders under feature Commands/CreateSecurityRole.cs
Shared bindable shape Separate file in feature folder SecurityRoleDetails.cs

Namespaces do not mirror folder names. Plural namespaces and singular folders are intentional — two separate concerns.

Contract shell

Every operation is a public static partial class named for the operation:

public static partial class CreateSecurityRole
{
  [RouteMixin("api/SecurityRoles", HttpVerb.Post)]
  public sealed partial class Command
    : IApiRequest, ISecurityRoleDetails, IRequest<OneOf<Response, SharedProblemDetails>>
  { /* properties */ }

  public sealed class Validator : AbstractValidator<Command> { /* rules */ }
  public sealed class Response { /* ... */ }
}

Nested types

Type Name Role
Read request Query GET operations
Write request Command POST/PUT/DELETE
Output Response Success payload
Input rules Validator AbstractValidator<Query\|Command>

Return type is always IRequest<OneOf<Response, SharedProblemDetails>> unless returning a stream/file (OneOf<Stream, SharedProblemDetails>).

Route parameters

Route/template parameters (SecurityRoleId, AccountId, …) come from [RouteMixin] via source generation. The Query/Command body is often empty (;) — do not re-declare generated route properties by hand.

HTTP verbs

Operation Verb
Query Get
Create Post
Update Put
Delete Delete

Workflow

1. Identify the operation

Read → Queries/Get*.cs · Write → Commands/Create|Update|Delete*.cs

2. Scaffold the partial class

3. Bindable data — interface-driven validation

When Blazor will bind and edit the payload:

  1. Define I<Feature>Details in a feature-level file (e.g. SecurityRoleDetails.cs).
  2. Mutable bindable properties use { get; set; } on the interface.
  3. Identity/read-only keys on implementations use { get; init; } or { get; }.
  4. Add AbstractValidator<I<Feature>Details> in the same file.
  5. Create* / Update* Command implements the interface.
  6. Get* Response implements the interface when the form loads existing data for edit.
  7. Endpoint Validator composes: RuleFor(x => x).SetValidator(new SecurityRoleDetailsValidator());

This is the core value over default .NET DTO patterns: one shape, shared rules, no parallel view model.

See mutability.md.

4. Apply nullability — type declares intent

Nullability is not inferred from validators. The type annotation is the contract; validators must agree.

Intent Type Initializer Validator
Required after validation string = null! NotEmpty() / NotNull()
Truly optional / absent OK string? none No unconditional NotEmpty(); use .When(x => x != null) if format rules apply when present
Required nested object Person = null! RuleFor(x => x.Person).NotNull().SetValidator(...)
Optional nested object Person? none Validate only when present
Required value type int, Guid, … default GreaterThan(0), NotEmpty(), etc.
Optional value type int?, DateTime? none Rules only when .HasValue / .When(...)

Forbidden

See nullability.md.

5. Apply mutability — accessor declares intent

Intent Accessor Collection
Display / server-built { get; } or { get; init; } IReadOnlyList<T>
Blazor bindable / edit { get; set; } on I*Details List<T> when editable

Read-only display sharing across endpoints: get-only interfaces (e.g. IPolicyDto) — not bindable, not I*Details.

6. Response patterns

Case Pattern
Display DTO Parameterized ctor + Guard.Against.*; immutable { get; }
Editable load Implements I*Details; ctor sets identity; mutable { get; set; } on bindable fields
List Response : ListResponse<TDto>
No body public sealed class Response;
Created id public required int Id { get; init; } or ctor
File/stream IRequest<OneOf<Stream, SharedProblemDetails>>

7. Query-string queries

Implement IQueryStringRouteProvider + GetRouteWithQueryString() for optional filters. Optional filter properties are string? / nullable value types with no unconditional required rules.

8. Validator

9. Contract tests (required)

In the repo's *Contracts.Tests project, add SerializeAndDeserialize for Command/Query and Response using camelCase JsonSerializerOptions. This validates JSON round-trip shape.

Do not test validators in isolation here.

10. Mock response factory (required)

Every contract needs GetMockResponseFactory() and SPA registration. Use the mock-response-factory skill for implementation and wiring details.

Validation checklist

Common pitfalls

Pitfall Fix
string? + NotEmpty() Required field → string + = null! + NotEmpty()
Separate Blazor view model Command/Response implement I*Details; bind the interface
Entity-centric shared DTO per endpoint Endpoint-centric types; share only validation interfaces or read-only display interfaces
sealed record request/response Classes + partial + source generation
Namespace matches folder name Namespace plural; folder singular
Hand-declared route params Trust RouteMixin source generation
Missing mock factory Add GetMockResponseFactory() — required for SPA mock mode
Copying paths from another repo Read existing contracts in this repo first

Canonical examples in this skill

See examples.md for inline reference implementations and how to discover equivalents in the repo you are working in.