Hasura v3 (DDN) – Data Modeling with HML

August 17, 20264 min readUpdated 8/21/2026

In v3 the metadata is the product. There is no console to click and no engine holding state — there is a directory of .hml files that compile into an API. This lesson walks every object kind by modelling the same booking schema the v2 half of this track uses.

On the v3 sections. Everything marked v3 (DDN) is taken from the official Hasura DDN documentation as read on 2026-08-21 and was not run locally — it shows configuration, never claimed output.

The v3 (DDN) object kinds

  • DataConnectorLink
  • ObjectType
  • Model
  • Command
  • Relationship
  • BooleanExpressionType
  • AggregateExpression
  • OrderByExpression
  • TypePermissions
  • ModelPermissions
  • CommandPermissions
  • GraphqlConfig
  • AuthConfig
  • CompatibilityConfig
  • EnginePlugins

That is more moving parts than v2, and the reason is consistent throughout: v2 generated capabilities from your database, v3 makes you declare them. More to write, less that appears without you asking.

Connecting the source

Everything starts from a DataConnectorLink, which is what introspection produces:

kind: DataConnectorLink
version: v1
definition:
  name: my_connector
  url:
    readWriteUrls:
      read:
        valueFromEnv: MY_CONNECTOR_READ_URL
      write:
        valueFromEnv: MY_CONNECTOR_WRITE_URL
  schema:
    version: v0.1

You rarely hand-write this. ddn connector introspect maintains it.

Types and models

An ObjectType describes a shape. It is deliberately independent of where the data lives, which is what lets one type be served by different connectors:

kind: ObjectType
version: v1
definition:
  name: Properties
  fields:
    - name: publicId
      type: Uuid!
    - name: title
      type: String!
    - name: city
      type: String!
    - name: pricePerNight
      type: Decimal!
    - name: status
      type: String!
  graphql:
    typeName: Properties
  dataConnectorTypeMapping:
    - dataConnectorName: my_connector
      dataConnectorObjectType: properties
      fieldMapping:
        publicId: { column: { name: public_id } }
        pricePerNight: { column: { name: price_per_night } }

fieldMapping is where snake_case becomes camelCase. In v2 that was one environment variable applied globally; in v3 it is explicit per field — more typing, and no surprises about what a field is called.

A Model makes the type queryable:

kind: Model
version: v1
definition:
  name: Properties
  objectType: Properties
  source:
    dataConnectorName: my_connector
    collection: properties
  filterExpressionType: PropertiesBoolExp
  orderByExpression: PropertiesOrderBy
  graphql:
    selectMany:
      queryRootField: properties
    selectUniques:
      - queryRootField: propertiesByPublicId
        uniqueIdentifier: [publicId]

selectUniques has no v2 equivalent worth naming: it generates a root field that returns exactly one row, typed as such, rather than a list you index into.

Declaring what a query can do

In v2, tracking a table made every column filterable and sortable. In v3 those are objects.

kind: BooleanExpressionType
version: v1
definition:
  name: PropertiesBoolExp
  operand:
    object:
      type: Properties
      comparableFields:
        - fieldName: city
          booleanExpressionType: StringBoolExp
        - fieldName: pricePerNight
          booleanExpressionType: DecimalBoolExp
      comparableRelationships:
        - relationshipName: host
  logicalOperators: { enable: true }
  isNull: { enable: true }
  graphql:
    typeName: PropertiesBoolExp

Note comparableRelationships — that is what makes where: {host: {...}} possible, and it is opt-in. OrderByExpression and AggregateExpression follow the same pattern for sorting and aggregation.

The upside is real: a column you did not list cannot be used to probe your data with a range scan, and aggregation over a sensitive field is not exposed by default.

Relationships

kind: Relationship
version: v1
definition:
  name: host
  sourceType: Properties
  target:
    model:
      name: Users
      relationshipType: Object
  mapping:
    - source:
        fieldPath: [{ fieldName: hostId }]
      target:
        modelField: [{ fieldName: id }]

Change relationshipType to Array for the many side. The capability worth repeating: the target may live in a different connector or a different subgraph, which no v2 relationship could do.

Permissions, in two objects

TypePermissions governs fields:

kind: TypePermissions
version: v1
definition:
  typeName: Properties
  permissions:
    - role: anonymous
      output:
        allowedFields: [publicId, title, city, pricePerNight, status]
    - role: staff
      output:
        allowedFields: [publicId, title, city, pricePerNight, status, addressLine1, postalCode]

ModelPermissions governs rows:

kind: ModelPermissions
version: v1
definition:
  modelName: Properties
  permissions:
    - role: anonymous
      select:
        filter:
          fieldComparison:
            field: status
            operator: _eq
            value:
              literal: PUBLISHED
    - role: host
      select:
        filter:
          relationship:
            name: host
            predicate:
              fieldComparison:
                field: publicId
                operator: _eq
                value:
                  sessionVariable: x-hasura-user-id

Everything from lesson 9 survives — roles, row filters, field allowlists, session variables, filters that walk relationships. The split by what is protected rather than by operation is arguably clearer, and it is certainly more verbose.

Business logic

A Command is v3’s Action: a function on a connector, exposed as a field. Governed by CommandPermissions, and a Relationship can target it, so its result joins back into the graph.

The global objects

Three live in the globals subgraph and apply to the whole supergraph: AuthConfig (lesson 8), GraphqlConfig — which holds the naming and schema conventions that were environment variables in v2 — and CompatibilityConfig, which pins engine behaviour to a date so an upgrade cannot silently change your API’s semantics. There is no v2 equivalent of that last one, and it is a good idea.

EnginePlugins is the extension point, and it matters more than it sounds: caching, allow-lists and RESTified endpoints are all delivered as plugins in v3 rather than built in.

Is v3’s verbosity worth it?

For one Postgres database and one team, honestly, often not — v2 gives you the same API from a fraction of the configuration.

It starts paying when there is more than one source or more than one team. Declared capabilities review well in a pull request, subgraphs give teams real boundaries, and immutable builds make promotion safe. That is the trade v3 is making, and it is worth taking on those terms rather than because the number is higher.

Next

Lesson 20 closes the track with the questions interviews ask.