Repository F# setup
open Reified
open Reified.Refinements
open Reified.ResultVersioned Contracts
A Contract<'model> is a chain of frozen wire schemas and explicit migrations. Use it when stored configuration,
queued messages, or events must remain readable after their wire shape changes. Contract versioning belongs at the
wire boundary; map the current wire value into a strict domain type afterwards.
Schema derivation is documented separately. Derived Schemas explains how to generate schemas from
ordinary [<DeriveSchema>] F# records, configure MSBuild, and see every supported attribute.
Wire and domain models
A wire model describes what the format can carry. Keep it public and permissive. A domain model describes what business code may rely on and should protect its invariants with refined values or private construction.
// Wire DTO: shaped like persisted input.
type OrderWire = { Sku: string; Quantity: int }
// Domain constructor owns business invariants.
// Order.create : string -> int -> Result<Order, OrderError>
let toDomain (wire: OrderWire) =
Order.create wire.Sku wire.QuantityParse or migrate to the current wire model first, then call toDomain. See
Separate Wire and Domain Models.
Declare a contract by hand
Contract.create starts with the current version and schema. Add each immediately preceding version with
Contract.supersedes, then choose how the input version is discovered with Contract.build.
open Reified
type ConfigV1 = { Host: string }
type Config = { Host: string; Port: int }
let migrateV1ToV2 (v1: ConfigV1) : Result<Config, MigrationError> =
match v1.Host.Split ':' with
| [| host; port |] ->
match System.Int32.TryParse port with
| true, parsed -> Ok { Host = host; Port = parsed }
| false, _ -> Error(MigrationError.MigrationFailed $"unreadable port in '{v1.Host}'")
| _ -> Ok { Host = v1.Host; Port = 5432 }
let configContract : Contract<Config> =
Contract.create "Config" 2 configSchema
|> Contract.supersedes 1 configV1Schema migrateV1ToV2
|> Contract.build (VersionSource.Field "schemaVersion")Migrations are hand-written, typed, contiguous, and may fail. Parsing selects the frozen schema for the input version, parses it, migrates each adjacent step, and checks the result against the current schema. There is no automatic structural migration.
Version sources
| Source | Use |
|---|---|
VersionSource.Field "schemaVersion" |
Read a positive integer version from an input field. |
VersionSource.External |
The caller knows the version out of band and calls Contract.parseVersion. |
VersionSource.UnversionedMeans 1 |
Treat input without a marker as one registered version. |
match Contract.parse configContract raw with
| Ok config -> printfn $"%s{config.Host}:%d{config.Port}"
| Error ContractError.VersionMissing -> eprintfn "no readable schema version"
| Error (ContractError.VersionUnrecognized version) -> eprintfn $"version %d{version} is not registered"
| Error (ContractError.VersionTooNew(found, supported)) ->
eprintfn $"payload is v%d{found}; this build supports v%d{supported}"
| Error (ContractError.ParseFailed(version, diagnostics)) ->
eprintfn $"v%d{version} payload is malformed: %A{diagnostics}"
| Error (ContractError.Migration failure) -> eprintfn $"migration failed: %A{failure}"ContractError.ParseFailed and MigrationError.RevalidationFailed carry the same path-aware SchemaErrors used by
Schema.parse.
Generate a version series from records
The generator can group [<DeriveSchema>] records into a version series and write the Contract wiring for you. It
does this by examining the names and attribute arguments of the marked records in the project.
How records are grouped
By default, a marked record whose name ends in V followed by a number (ProfileV1, ProfileV2) belongs to the
contract named by the rest of its name, but only when a marked record named Profile exists in the same namespace
or module. The bare record is the current version. Its version number is never written down; it is always one more
than the highest frozen version.
open Reified.DerivedSchema
[<DeriveSchema>]
type ProfileV1 = { Name: string } // Profile v1 (frozen)
[<DeriveSchema>]
type Profile = { Name: string; Email: string } // Profile v2 (current: highest Vn + 1)
ReifiedDerivedSchemaReified.DerivedSchema.DeriveSchemaAttributeMarks a plain record for schema derivation: schemagen generates its permissive schema. The advice is to put this on wire DTOs — records that carry no invariants of their own. The attributes in this namespace are inert metadata: they are read from source text at generation time, never by runtime reflection.
FsLiveDocsGeneratedPage8_B9CE9993F023.ProfileV1Name: stringstringAn abbreviation for the CLI type . Basic Types
FsLiveDocsGeneratedPage8_B9CE9993F023.ProfileEmail: stringProfileV1 on its own, with no marked Profile, is not a series. It is an ordinary record whose schema happens to
have a V1 in its name. The full rules, including explicit Contract/Version, are in
Schema Inference.
What is generated
Every version gets its own module with schema, parse, and validate, exactly as for any derived record (see
Generated Code). The current version's module also gets one extra function,
contract:
| Generated binding | Type |
|---|---|
ProfileV1.schema |
Schema<ProfileV1>: the frozen v1 wire shape |
ProfileV1.parse / ProfileV1.validate |
as for any derived record |
Profile.schema |
Schema<Profile>: the current (v2) wire shape |
Profile.parse / Profile.validate |
parse or check only the current shape; no version handling |
Profile.contract |
(ProfileV1 -> Result<Profile, MigrationError>) -> VersionSource -> Contract<Profile> |
The generated contract is just the hand-written chain from the section above, with the versions filled in:
let contract
(migrateV1ToV2: ProfileV1 -> Result<Profile, MigrationError>)
(source: VersionSource)
: Contract<Profile> =
Contract.create "Profile" 2 schema
|> Contract.supersedes 1 ProfileV1.schema migrateV1ToV2
|> Contract.build sourceThe generator does not write migrations. It cannot know how a v1 value becomes a v2 value, so each migration is a
parameter that you supply. contract only chains the frozen schemas and your migrations in the right order:
let migrateV1ToV2 (v1: ProfileV1) : Result<Profile, MigrationError> =
Ok { Name = v1.Name; Email = "" }
let profileContract : Contract<Profile> =
Profile.contract migrateV1ToV2 (VersionSource.Field "schemaVersion")
// Reads v1 or v2 input and always returns the current Profile.
let load raw = Contract.parse profileContract rawUse Contract.parse profileContract for stored or incoming data that may be any version. Profile.parse accepts only
the current shape.
Adding a third version
The bare name always means "current", so adding a version means freezing the current record under a Vn name and
writing the new shape under the bare name. Existing version numbers never change.
- Rename the current
ProfiletoProfileV2, and leave its fields exactly as they were. It is now frozen. - Declare the new shape as
Profile. It becomes v3, because the highest frozen version is now 2. - Keep the records in version order:
ProfileV1,ProfileV2, thenProfile. They can share a file, or the frozen versions can move to an earlier file (see Keep frozen versions elsewhere).
open Reified.DerivedSchema
[<DeriveSchema>]
type ProfileV1 = { Name: string }
[<DeriveSchema>]
type ProfileV2 = { Name: string; Email: string } // was Profile
[<DeriveSchema>]
type Profile = { Name: string; Email: string; Verified: bool } // v3
ReifiedDerivedSchemaReified.DerivedSchema.DeriveSchemaAttributeMarks a plain record for schema derivation: schemagen generates its permissive schema. The advice is to put this on wire DTOs — records that carry no invariants of their own. The attributes in this namespace are inert metadata: they are read from source text at generation time, never by runtime reflection.
FsLiveDocsGeneratedPage9_B9CE9993F023.ProfileV1Name: stringstringAn abbreviation for the CLI type . Basic Types
FsLiveDocsGeneratedPage9_B9CE9993F023.ProfileV2Email: stringFsLiveDocsGeneratedPage9_B9CE9993F023.ProfileVerified: boolboolAn abbreviation for the CLI type . Basic Types
After a build, the generated contract has one more parameter:
let contract
(migrateV1ToV2: ProfileV1 -> Result<ProfileV2, MigrationError>)
(migrateV2ToV3: ProfileV2 -> Result<Profile, MigrationError>)
(source: VersionSource)
: Contract<Profile> =
Contract.create "Profile" 3 schema
|> Contract.supersedes 2 ProfileV2.schema migrateV2ToV3
|> Contract.supersedes 1 ProfileV1.schema migrateV1ToV2
|> Contract.build sourceThe compiler now points at everything that needs attention:
- The existing
migrateV1ToV2must returnProfileV2instead ofProfile. Change its return type; the body usually stays the same. - The
Profile.contractcall site needs the newmigrateV2ToV3. - Code that maps the current wire record into your domain type now sees the v3
Profile.
Parsing a v1 payload now runs migrateV1ToV2 and then migrateV2ToV3. Payloads already stored as v1 or v2 are read by
their frozen schemas, so nothing has to be rewritten or renumbered.
Contract only reads. Whatever writes new payloads must stamp the new version number itself. Use
Contract.currentVersion profileContract rather than hard-coding the number, so the writer moves to 3 together with
the contract.
Names that do not follow the convention
Contract names the contract a record belongs to, and Version pins a frozen version number. Leave Version off the
current version:
[<DeriveSchema(Contract = "Profile", Version = 1)>]
type LegacyProfile = { Name: string }
[<DeriveSchema(Contract = "Profile")>]
type ProfileWire = { Name: string; Email: string } // current: v2
Reified.DerivedSchema.DeriveSchemaAttributeMarks a plain record for schema derivation: schemagen generates its permissive schema. The advice is to put this on wire DTOs — records that carry no invariants of their own. The attributes in this namespace are inert metadata: they are read from source text at generation time, never by runtime reflection.
Contract: stringThe versioned contract this record belongs to, when its name does not follow the `XxxVn` / `Xxx` convention. A dotted name is fully qualified (`"My.Contracts.Profile"`); a bare name is relative to the record's own namespace or module. Versions of one contract may live in any files and containers, as long as compile order declares them oldest to newest.
Version: intThe frozen version number of this record within its contract. Omit it on the contract's current version, whose number is always one past the highest frozen version.
FsLiveDocsGeneratedPage8_B9CE9993F023.LegacyProfileName: stringstringAn abbreviation for the CLI type . Basic Types
FsLiveDocsGeneratedPage8_B9CE9993F023.ProfileWireEmail: stringThe generated ProfileWire.contract takes LegacyProfile -> Result<ProfileWire, MigrationError> and builds a
Contract<ProfileWire> named "Profile". A contract has exactly one current version; a second record without a
version is reported as a generation error.
Keep frozen versions elsewhere
A contract's versions do not have to share a file, namespace, or module. The only requirement is compile order: the
current version's contract builder refers to every older schema, so older versions must be declared first.
A bare Contract name is relative to the record's own namespace or module. A dotted name is fully qualified, which
lets frozen versions live in a separate container:
// Profile.History.fs: compiled first, never edited again
module MyApp.Wire.History
open Reified.DerivedSchema
[<DeriveSchema(Contract = "MyApp.Wire.Profile", Version = 1)>]
type ProfileV1 = { Name: string }// Profile.fs
namespace MyApp.Wire
open Reified.DerivedSchema
[<DeriveSchema>]
type Profile = { Name: string; Email: string } // current: v2<Compile Include="Profile.History.fs" />
<Compile Include="Profile.fs" />
The generated Profile.contract refers to the frozen version by its full name,
MyApp.Wire.History.ProfileV1. Declaring Profile.fs first is reported as a compile-order error. ProfileV1 without
the Contract argument would not join here, because the Vn convention only looks in its own namespace or module.
When both files use the same namespace, the convention works across files without any arguments.
See Build Generation for setup.
Design rules
- Keep every shipped wire schema frozen.
- Write and test each adjacent migration; do not infer renames or defaults automatically.
- Keep generated records at the wire tier and map the current value into the domain.
- Revalidation after migration ensures the result passes the current schema.
- Generated output is ordinary Schema DSL code; contract versioning does not introduce runtime reflection.