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
DataConnectorLinkObjectTypeModelCommandRelationshipBooleanExpressionTypeAggregateExpressionOrderByExpressionTypePermissionsModelPermissionsCommandPermissionsGraphqlConfigAuthConfigCompatibilityConfigEnginePlugins
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.1You 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: PropertiesBoolExpNote 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-idEverything 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.