Declarative API and persistence shape in Vary

tl;dr: api blocks declare routes; data with table declares schemas; query declares typed read-model projections. The compiler owns the manifest, OpenAPI, clients, and row decoders. New check rules reject the hand-rolled versions.


For a while the Vary boundary story had a gap. Types, contracts, and tests inside an app looked great. But the public surface - the routes, the JSON payloads, the SQL schema, the cache and auth metadata - was still mostly stitched together by hand. Route catalogs lived in service modules. Schemas were projected separately. Handlers used adapter classes for dependency injection. Response builders carried repeated JSON setter code. The shape of the system was real, but it lived in strings and helpers rather than in the language.

That is what the recent declarative API and persistence work changes. The compiler now owns those shapes. One api block defines a public surface. One data with table metadata defines a schema. One query declaration defines a typed projection. The compiler takes it from there.

API contracts

An api block declares the public surface of a service. Routes carry their method, path, query and body types, response type, auth, cache, capabilities, and idempotency in one place:

api IssueTrackerApi version "v1" base "/api/v1" stability stable {
    service IssueTrackerService at "https://issue-tracker.local" {
        get get_issues "/issues" query ListIssuesQuery -> IssueListResponse
            auth none
            cache public(max_age=60, s_maxage=300)
            capability issues.private.read

        post post_issue "/issues" body CreateIssueRequest -> IssueMutationResponse
            auth session
            idempotent

        patch patch_issue "/issues/{number}" path number: Int body UpdateIssueRequest -> IssueMutationResponse
            auth session
            capabilities issues.status.update, issues.labels.update
    }
}

Each route is one line of declaration and a few lines of metadata. The path parameters, query type, body type, and response type are all real Vary types that the type checker enforces. auth, cache, capability, and idempotent are typed metadata, not comments.

From this single declaration the compiler builds an ApiSpec model and projects it into every artifact that has to agree with it. The Public JSON APIs guide has the command reference for the route manifest, schemas, test metadata, docs metadata, compatibility checks, and generated CLI output. The HTTP services docs cover route inspection, OpenAPI output, and typed service clients:

ProjectionWhat it carries
Route manifestThe path/method/handler binding the runtime serves.
OpenAPI documentThe spec downstream tools consume.
Typed clientThe call site other Vary code uses to reach the service.
Docs and compatibility metadataThe published contract shape.
Generated CLI sourceThe CLI for endpoints that opt into one.

Every projection reads the same ApiSpec. So the path in the manifest, the path in the OpenAPI document, and the path the typed client constructs are the same path by construction. Drift between them is not a class of bug anymore.

Persistence shape

Tables are declared on data types. A data block defines the row, and an attached table block carries the schema metadata the compiler needs to validate queries and emit migrations:

data PersistedUser {
    id: Str
    display_name: Str
    email: Str
    created_at: Str
    disabled_at: Str
} table {
    name users
    primary_key id
    unique email
    default disabled_at = ""
    index created_at
}

A table block can declare primary_key, unique, index, default, foreign_key, and the column metadata that follows from the data type's fields. The compiler validates references between tables - a foreign key whose target column does not exist is a type error, not a runtime surprise.

The output is a managed SqlSchema value. Migrations, schema introspection, and table-bound queries all consume it from the same place. On Via, that schema can run against a managed SQLite binding instead of a path owned by the app.

Query declarations

Read models are declared, not hand-written:

query LabelListRow list_visible_labels_read_model {
    from PersistedLabel as l
    order {
        l.slug asc
    }
    select {
        id = l.id
        slug = l.slug
        name = l.name
        color = l.color
        created_at = l.created_at
    }
}

A query declaration names a row type, names the query function the rest of the program calls, and describes the projection as structured clauses: from, where, join, order, select. The compiler type checks the projection against the table metadata, lowers it to SQL, generates the row decoder, and exposes the result as a typed function that returns List[LabelListRow].

Repository declarations and handwritten store classes call those declared queries instead of building rows out of row.get_* calls. Read-model code becomes the declaration plus the call site, with no manual mapping layer in between.

Service implementations

The other half of an api block is its implementation. A class implements the service interface that the contract generated, and the compiler binds the routes to its methods. For public API services, the declarative handlers and service implementations docs cover the generated binding path:

class IssueTrackerAuthApi(db_path: Str = tracker_database_binding())
    implements IssueTrackerAuthService {
    mut store: TrackerStore
    mut auth_service: AuthService

    def post_register(self, body: RegisterRequest) -> RegistrationResponse {
        return register_handler(self.store, self.auth_service, body)
    }
}

The handler class holds its dependencies directly. The framework wires the runtime, the request context, and the response envelope. implements ties the class to the contract so any drift between declared routes and implemented methods is a compile error.

The companion runtime additions - typed request context, response envelopes, field projection and JSON mappings, cursor paging, JSON object construction, and escaped XML builders - are stdlib modules now, so applications use stable entry points instead of one-off helpers.

What this replaces

The point of moving shape into declarations is to make the manual versions unnecessary - and then to make them errors. vary check gained a set of great-code rules that reject the patterns the declarations are meant to replace:

RuleWhat it flags
manual-route-catalogPositional HTTP route constants and ad-hoc route registries. Use an api block.
manual-json-stringHand-built JSON strings with quoting and escaping. Use typed responses or JSON builders.
manual-handler-adapterWrapper classes whose only job is to call a free handler with stored dependencies. Bind handlers through the contract instead.
manual-row-mappingConstructors built from row.get_* calls. Use a query declaration or sql ... into form.
projection-setter-sequenceResponse objects built field by field with setters. Use a typed response or projection.
structured-markup-stringHand-built XML or other structured markup strings. Use the structured builders.

The rules are precise. They name the canonical replacement, and the diagnostics point at it. The check engine does what the type system cannot do alone: it pushes application code onto the supported path before drift starts.

How the pieces fit

A working mental model:

DeclarationWhat it isWhat the compiler does with it
apiThe shape of the public boundaryProjects it into manifests, schemas, clients, and CLI source
data plus tableThe shape of stored rowsProjects it into the managed schema and migrations
queryThe shape of a read modelProjects it into SQL and a typed row decoder
A class with implementsThe shape of the serviceBinds it to the contract and rejects drift at compile time

Each one is a single source of truth. The compiler is responsible for every artifact that has to agree with it. Application code calls the typed surfaces and writes the parts that actually express product behaviour.

Where this lands

These additions are in the language and compiler today, on top of v122. The full set will be folded into the next release notes with the surrounding tooling and check-rule documentation.

If you want to see the patterns at scale, the issue tracker we ship as a working example has been rewritten on top of all of this, and the compiler test suite is validated against it.

The direction here is the same one the rest of Vary points at. The compiler should own the shape of things that have to agree across boundaries. A string is not a contract.

More articles

What's new in Vary v147-alpha.1 v147-alpha.1 is out. Since v122, Vary has grown a much larger server story: Via, an early-alpha application server for Vary apps, plus declarative API contracts, table schemas, query declarations, managed SQLite bindings, Javalin-backed HTTP service jars, OpenAPI output, and stronger deploy diagnostics.
Introducing Via secrets and config Via separates ordinary runtime settings from sensitive values, then delivers secrets through workload identity instead of shell commands or generated manifests.