Contracts
A contract is a static class marked [RivetContract] whose static readonly fields define endpoints via the Define factory. Contracts are plain C# — the Rivet.Attributes package has no hard ASP.NET hosting dependency in your contract code — and they serve two roles:
- Generation time: Roslyn reads the
Definechain and emits the OpenAPI operation. - Runtime: controllers use the same definition to bind inputs and construct responses, so input/output types and selected statuses are enforced against the contract.
Defining a contract
This is the samples/ContractApi contract, trimmed:
using Rivet;
[RivetContract]
public static class MembersContract
{
public static readonly RouteDefinition<PagedResult<MemberDto>> List =
Define.Get<PagedResult<MemberDto>>("/api/members")
.Description("List all team members");
public static readonly RouteDefinition<InviteMemberRequest, InviteMemberResponse> Invite =
Define.Post<InviteMemberRequest, InviteMemberResponse>("/api/members")
.Description("Invite a new team member")
.Status(201)
.Returns<ValidationErrorDto>(422, "Validation failed")
.Secure("admin");
public static readonly RouteDefinition Remove =
Define.Delete("/api/members/{id}")
.Returns<NotFoundDto>(404, "Member not found")
.Secure("admin");
// Input only, 204 — chain .Accepts<T>() from a void definition
public static readonly InputRouteDefinition<UpdateRoleRequest> UpdateRole =
Define.Put("/api/members/{id}/role")
.Accepts<UpdateRoleRequest>()
.Status(204)
.Returns<NotFoundDto>(404, "Member not found");
public static readonly RouteDefinition Health =
Define.Get("/api/health").Anonymous();
}Define.Get/Post/Put/Patch/Delete come in three arities: <TInput, TOutput>, <TOutput>, and untyped (void). Default success statuses: 200 for GET/PUT/PATCH, 201 for POST, 204 for void DELETE (typed DELETE defaults to 200 — a 204 with a body is invalid HTTP). Define.File creates binary/stream endpoints — see File Uploads.
The full builder surface (.Status(), .Returns(), .Secure(), .Anonymous(), .QueryAuth(), .FormEncoded(), .AcceptsFile(), ...) is documented in the Route Definition API.
Implementing a contract
For an input-bearing endpoint, bind the transport input, execute ordinary application code, then construct the response with .Success(...), .Error(...), or .File(...):
[Route("api/members")]
public sealed class MembersController : ControllerBase
{
[HttpPost]
public async Task<IActionResult> Invite(
[FromBody] InviteMemberRequest request, CancellationToken ct)
{
var endpoint = MembersContract.Invite.Bind(request);
var response = await memberService.Invite(request, ct);
// Must be InviteMemberResponse — compiler enforced
return endpoint.Success(response).ToActionResult();
}
}Bind is required only for definitions with TInput; it returns a bound endpoint whose terminal methods retain the contract's output type. Definitions without input call the same terminal methods directly. Application services remain ordinary C# — Rivet does not own their invocation.
The terminal methods return a framework-agnostic RivetResult. Rivet provides the host bridges: .ToActionResult() for MVC controllers and .ToResult() for minimal APIs.
Minimal APIs work the same way — .Route and .Method are available at runtime:
app.MapGet(MembersContract.Health.Route, () =>
MembersContract.Health.Success().ToResult());Success, errors, and files
.Success(...) selects the declared success response. .Error(status, ...) selects a response declared with .Returns(...); undeclared statuses, missing or unexpected payloads, and incompatible payload types throw RivetContractViolationException. .File(...) constructs a binary response for Define.File or .ProducesFile(...) definitions. Each result is adapted to ASP.NET only at the final .ToActionResult() / .ToResult() boundary.
See Runtime Validation for the precise scope of what is and isn't enforced at runtime — in short: declared statuses, C# payload types, body presence, and content representations, not serialized response shape or constraint attributes.
Definitions are immutable once used
Contract definitions live in shared static fields. The first Bind, Success, Error, or File publishes the definition; after that all builder methods throw. Configure the definition fully in its field initializer.
Verifying coverage
dotnet rivet --project … --check confirms every contract field has an implementation whose route and method match. See Contract Coverage.
