Skip to content
Purview Dev logo

Contracts and Data Exchange

Experimental Engineering Playbook Reviewed 2026-10-15 purview-dev/engineering-playbook Star on GitHub architecture-tests best-practices coding-agents conventions csharp documentation dotnet engineering msbuild-sdk tunit

Experimental. This project is an experiment: the public API, defaults and packaging can change between prereleases, and there is no production support guarantee.

RuleStatement
1A value is a type, not a primitive. An email address is EmailAddress, not string.
2Two doors. Create validates and can fail; Hydrate trusts and cannot.
3Normalise, then validate — in that order, in one place.
4Expected outcomes are values. Exceptional circumstances throw.
5The schema is the single source of truth for a wire shape. Everything else is derived.

Rule 1 exists because a primitive is an unchecked assumption. string email is a promise the compiler cannot verify, so every consumer re-verifies it or hopes. A type makes the promise checkable once and carries its own rules to every place it appears — including places the original author never saw.

Rule 2 exists because validation has a direction. Inbound data must be checked; data you produced yourself must not be re-checked, because re-checking a value that is already known-good turns a data-quality problem into a crash in an unrelated place. Two doors with different contracts make the choice explicit and reviewable: Create at the boundary, Hydrate everywhere past it.

Rule 3 exists because the order is not arbitrary. " [email protected] " and "[email protected]" are the same address, and a validator that rejects the first is wrong. Normalising inside the factory means there is exactly one normalised form, so equality, hashing, dictionary keys and database lookups all agree.

Rule 4 exists because an exception is a statement that the caller did not expect this. “This email is taken” is expected; it is a branch, not a fault. Modelling it as a value keeps the compiler’s help — the caller must handle it — without the cost of exceptions as control flow. Faults still throw, and that distinction is the whole point.

Rule 5 exists because a wire shape described twice is a wire shape that disagrees with itself. The schema is the contract; the C# type, the JSON binding, and the documentation are all derived from it.

inbound
request body ──schema──▶ validated shape ──Create──▶ domain value
│
▼
domain logic
│
Result<TValue, TError> ◀──────────┘
│
├── success ──▶ TValue
└── failure ──▶ TError ──map──▶ response

Four conversions, each with one owner:

  • Schema → validated shape. The schema decides what a well-formed payload is, before anything is constructed.
  • Validated shape → domain value. Create is the only door that validates; it runs normalisation and the type’s own invariants.
  • Domain value → result. Domain logic returns Result<TValue, TError> rather than throwing for expected failure.
  • Result → response. A mapping layer turns a TError into a status code and a body. Nothing above it knows what an HTTP status is.

value-objects is the reference implementation, and it is source-generated rather than hand-written: mark a partial record struct with [Scalar] and the generator emits the factories, the equality, the comparison, the conversions and the JSON converter.

[Scalar]
public readonly partial record struct EmailAddress
{
public string Value { get; }
static partial void OnNormalize(ref string value) => value = value?.Trim().ToLowerInvariant()!;
static partial void OnValidate(ref string value)
{
if (!value.Contains('@', StringComparison.Ordinal))
{
throw new ArgumentException($"'{value}' is not an email address.", nameof(value));
}
}
}

Note what the two hooks encode: OnNormalize runs first and makes the value canonical, OnValidate runs second and can reject it. The generator supplies Create, Hydrate and TryCreate around them, plus Empty, CompareTo, the underlying value’s formatting and parsing interfaces, and a JSON converter that serialises the scalar as its underlying value rather than as an object. The Value property stays visible, so a value object is never a black box that has to be unwrapped through a helper.

The same generator covers the storage boundary: reference Microsoft.EntityFrameworkCore and it emits the value converters, comparers and complex-type mapping, plus a ConfigureValueObjects extension — so queries use the value object type directly instead of .Value.

results supplies the type and the ergonomics:

Result<Tenant, TenantError> GetTenant(TenantId tenantId) =>
_tenants.TryGet(tenantId, out var tenant)
? Result<Tenant, TenantError>.Success(tenant)
: new TenantNotFound(tenantId).AsFailure<Tenant>();

The error side is a C# union, so the set of failures is closed and the compiler knows it:

[GenerateResult]
public readonly union TenantError(TenantNotFound, TenantDisabled, TenantAlreadyExists);

AsFailure<TValue>() is generated per case by the source generator bundled in Purview.Results, which is what keeps the call site free of a new TenantNotFound(...) cast to the union type. The union receiver gets Union.Failure(...) and Union.Success(...) factories, so constructing a result never requires naming the union’s underlying representation.

The sentence to keep in mind is the one in the package’s own README: “Exceptional circumstances still throw; expected outcomes are values.” A missing tenant is expected. A database that is unreachable is not, and it should surface as an exception rather than as a TenantError case, because a caller who is forced to handle it as a value will handle it wrongly.

zodsharp is the schema layer: a schema validation library for C# ported from TypeScript’s Zod, with struct-based rules, a fluent API, a [ZodSchema] source generator, and DataAnnotations support.

The important property is not the performance — it is that one schema serves several jobs:

ConsumerWhat it derives from the schema
Inbound validationThe rules, with zero-allocation checking
JSON Schema exportA machine-readable contract for consumers and documentation
JSON Schema importA schema built from an existing contract, so the C# side can adopt one that already exists
ASP.NET Core integrationModel validation wired to the framework’s own pipeline
Result mappingValidationResult<T> bridged into Result<TValue, TError>, and failures mapped onto ProblemDetails

The bridges are separate packages rather than one package with everything in it — Purview.Results.ZodSharp and Purview.Results.ZodSharp.AspNetCore — so a consumer that does not need the mapping does not take the dependency.

A validation outcome is modelled, not thrown:

validator.ValidateAsync(Any(), Any()).Returns(ValidationResult<TestAggregate>.Success(aggregate));
validator.Validate(Any()).Returns(
ValidationResult<TestAggregate>.Failure(
ValidationError.Create("any", "error", [nameof(TestAggregate.Name)])));

IsValid, Failures, and each failure’s PropertyName are the read surface, so a caller can map failures onto a response without parsing a message. That is what makes the boundary conversion mechanical rather than clever.

RuleEnforced by
A value is a typeReview — and, once the package is referenced, the source generator’s diagnostics
Create and Hydrate are the only doorsThe generator emits them; a hand-written constructor is a review failure
Normalise before validateThe generator runs the hooks in that order
Expected failures are valuesReview — a thrown exception for an expected outcome is visible in review
The schema is the source of truthReview, plus the export/import tooling that makes a second definition unnecessary
JSON serialisation of a scalar valueThe generator’s emitted converter; a test asserting the wire shape is the check

The one that most benefits from a test is the wire shape: a value object’s JSON representation is a contract with something outside the repository, so it belongs in a test that asserts the exact payload rather than in a hope.

  • One project. Adopt rule 1 for the identifiers and the values you already validate by hand. Add rule 4 for the one failure mode your callers currently catch.
  • A service with a boundary. Adopt all five. Rules 2 and 3 pay for themselves the first time an inbound payload arrives with different casing and a lookup fails for a value that is already in the database.
  • A published contract. Export the schema and check it in. A consumer-facing wire shape that only exists as C# code is a wire shape nobody outside can test against.
  • Several services. Version the schema, not the code. The schema is the thing both sides agreed on, and it is the thing to keep compatible.