Skip to content

Instantly share code, notes, and snippets.

@vito
Last active April 5, 2026 17:01
Show Gist options
  • Select an option

  • Save vito/158619f941f529244dda00b0d45a8a1c to your computer and use it in GitHub Desktop.

Select an option

Save vito/158619f941f529244dda00b0d45a8a1c to your computer and use it in GitHub Desktop.
Dagger Design: First-Class Interfaces and Unified IDs in DagQL

First-Class Interfaces and Unified IDs in DagQL

Table of Contents

Problem

  1. Interfaces were objects. DagQL had no concept of GraphQL interfaces. Module-defined interfaces were installed as Class[*InterfaceAnnotatedValue] with Kind: ast.Object — not ast.Interface.

  2. asFoo conversion fields. The only way to "upcast" from a concrete object to an interface was via synthetic asFoo fields bolted onto every implementing object by ModDeps.lazilyLoadSchema.

  3. Per-type ID scalars. Every object had its own FooID scalar (ContainerID, DirectoryID, etc.) which cluttered the schema and made interface-typed arguments difficult — SDKs couldn't accept "any ID that implements this interface" without DynamicID wrapping.

  4. No fragment support. parseASTSelections didn't handle *ast.InlineFragment and didn't check type conditions on fragment spreads.

  5. InterfaceAnnotatedValue + DynamicID wrapping. Interface values were wrapped in InterfaceAnnotatedValue (implementing dagql.InterfaceValue), and toSelectable had a special unwrapping path. Fragile complexity.

  6. No implements in the schema. Object definitions never declared which interfaces they implement, and possibleTypes was empty.

  7. Subtype checking lived in core. ObjectTypeDef.IsSubtypeOf and friends implemented covariant/contravariant type checking, but dagql knew nothing about any of it.

Solution

Two complementary changes, developed together on this branch:

  • Unified ID scalar: Replace all per-type FooID scalars with a single ID scalar. Type information is conveyed via @expectedType(name: "Foo") directives on arguments and field definitions.

  • First-class interfaces: DagQL natively represents ast.Interface types, tracks which objects implement them, populates possibleTypes, and supports inline fragments and fragment spreads with type condition checking.

Together these eliminate InterfaceAnnotatedValue, DynamicID for interfaces, the dagql.InterfaceValue unwrap path, wrapIface, and the synthetic asFoo schema fields.

Unified ID Scalar

Schema change

# Before
type Container {
  id: ContainerID!
  withDirectory(path: String!, directory: DirectoryID!): Container!
}

# After
type Container {
  id: ID! @expectedType(name: "Container")
  withDirectory(path: String!, directory: ID! @expectedType(name: "Directory")): Container!
}

AnyID type

dagql.AnyID is the single scalar type for all IDs. It wraps a *call.ID and handles encode/decode. Every object's id field uses AnyID with an @expectedType directive.

@expectedType directive

Installed on the server as a schema directive:

directive @expectedType(name: String!) on ARGUMENT_DEFINITION | FIELD_DEFINITION

Applied in two places:

  1. Arguments (dagql/objects.go InputSpecsForType, core/typedef.go FunctionFieldSpec, core/interface.go): When an argument's input type is ID[T] or a module object/interface type, the directive is added with the resolved type name. List wrappers are walked through to find the element type.

  2. Field definitions (dagql/objects.go): When a field's return type is ID (e.g. sync, id), the directive is added to the field definition itself.

SDKs read @expectedType from introspection to generate properly typed method signatures instead of raw ID/string parameters.

First-Class Interfaces

dagql.Interface

A new type alongside Class:

type Interface struct {
    name         string
    description  string
    fields       map[string][]*InterfaceFieldSpec  // view-keyed, same as Class
    fieldsL      *sync.Mutex
    implementors map[string]struct{}               // type names
    interfaces   map[string]*Interface             // interface-extends-interface
    directives   []*ast.Directive
}

Key methods:

Method Purpose
AddField(spec) Add a field spec to the interface
FieldSpecs(view) Return visible fields for a view
ParseField(ctx, view, astField, vars) Parse an AST field selection against interface specs
Definition(view) Generate ast.Definition with Kind: ast.Interface
Satisfies(obj, view, ...checker) Structural conformance check with optional covariant return support
SatisfiedByInterface(other, view) Interface-to-interface conformance
ImplementInterface(other) Declare this interface extends another

Server integration

// dagql/server.go
type Server struct {
    interfaces map[string]*Interface  // alongside objects, scalars, etc.
}

func (s *Server) InstallInterface(iface *Interface, directives ...*ast.Directive) *Interface
func (s *Server) InterfaceType(name string) (*Interface, bool)

Object conformance

Class.Implements(iface) and Class.ImplementInterfaceUnchecked(iface) record that an object implements an interface. At schema generation time:

  • The object's ast.Definition gets Interfaces: []string{"Foo", "Bar"}
  • schema.PossibleTypes["Foo"] includes this object's definition

Auto-implementing Node

Every class with an ID automatically implements the built-in Node interface (id: ID!), registered in Server.InstallObject:

if _, hasID := class.IDType(); hasID {
    if nodeIface, ok := s.interfaces["Node"]; ok {
        if nodeIface.Satisfies(class, "") {
            impl.ImplementInterface(nodeIface)
        }
    }
}

Schema generation

Server.SchemaForView emits both objects and interfaces:

// Objects → ast.Object, with def.Interfaces populated
sortutil.RangeSorted(s.objects, func(_ string, t ObjectType) {
    def := definition(ast.Object, t, view)
    for _, ifaceName := range def.Interfaces {
        schema.AddPossibleType(ifaceName, def)
    }
    schema.AddTypes(def)
})

// Interfaces → ast.Interface
sortutil.RangeSorted(s.interfaces, func(_ string, iface *Interface) {
    def := iface.Definition(view)
    schema.AddTypes(def)
    for _, ifaceName := range def.Interfaces {
        schema.AddPossibleType(ifaceName, def)
    }
})

Module interface installation

InterfaceType.Install (in core/interface.go) creates a dagql.Interface with field specs derived from the module's InterfaceTypeDef, then calls dag.InstallInterface().

Object-to-interface relationships are registered in registerInterfaceImpls (core/schema_build.go), which uses IsSubtypeOf for semantic validation and dagqlIface.Satisfies with a covariant return type checker for structural conformance.

Fragment Support

parseASTSelections handles all three selection types:

case *ast.Field:
    // Standard field resolution against object or interface parser

case *ast.FragmentSpread:
    // Check type condition via typeConditionMatches()
    if s.typeConditionMatches(fragment.TypeCondition, self.Name()) { ... }

case *ast.InlineFragment:
    // Check type condition; empty condition always matches
    if x.TypeCondition == "" || s.typeConditionMatches(x.TypeCondition, self.Name()) { ... }

typeConditionMatches supports exact type name match plus interface membership (checks iface.Implementors()).

Both objects and interfaces can serve as the fieldParser in parseASTSelections — the server checks s.objects[name] then falls back to s.interfaces[name].

__typename

The __typename meta-field is handled as a built-in in parseASTSelections and resolved in Server.Resolve by returning the concrete object's type name.

Interface-Implements-Interface

Interfaces can implement other interfaces via duck typing. registerInterfaceToInterfaceImpls (core/schema_build.go) compares every pair of module-defined interfaces: if interface A has all fields required by interface B with compatible types, A declares ImplementInterface(B).

Circular references are prevented: if B already implements A, A won't also declare it implements B.

SDK Codegen

All SDKs have been updated. The @expectedType directive is the key mechanism that lets SDKs map the unified ID scalar back to typed parameters.

Go

  • Interfaces → Go interface type + unexported FooClient query-builder struct
  • AsFoo() adapters → Generated on implementing objects as client-side type conversions (no schema field). Needed because Go lacks covariant return types on interface methods.
  • Concrete(ctx) (Node, error) → On interface clients, queries __typename + id and type-switches on possibleTypes to load the concrete type.
  • Node interface → Base interface for all typed objects, returned by Concrete().
  • Unified ID → Single type ID string. No per-type ContainerID aliases. XXX_GraphQLIDType() returns "ID" everywhere.
  • Ref[T] and Load[T] → Generic helpers for loading objects by ID via node(id:). Ref returns a lazy reference (no network call); Load verifies the object exists and matches the expected type.

Python

  • Interfaces@runtime_checkable Protocol classes
  • as_foo() adapters → Returns Self (the object already structurally satisfies the protocol via duck typing)
  • Unified IDstr type used in module runtime

TypeScript

  • Interfaces → TypeScript interface + _FooClient query-builder class
  • Structural typing → Concrete classes satisfy interfaces without adapters
  • Unified IDID type alias

PHP

  • Interfaces → PHP interface + FooClient implementing class
  • Unified ID → Single Id scalar, per-type FooId classes removed
  • loadObjectFromId() → Generic helper on AbstractClient replaces dynamic load*FromId() calls

Java

  • Interfaces → Java interface + FooClient implements Foo
  • Unified ID → Updated IDAbleSerializer for single ID type
  • loadObjectFromID(Class<T>, ID) → Generic helper on generated Client class

Rust

  • Unified ID → Updated scalar templates, regenerated bindings
  • Interfaces → Not yet generating native Rust trait types (regen only)

Elixir

  • Inline fragmentsinline_fragment/2 added to QueryBuilder
  • Unified ID@expectedType directive support on args and fields, unified ID scalar, INTERFACE kind handling
  • node(id:) + inline fragment → Replaces all loadFooFromID calls
  • Node interface → Generated as a type
  • 52 per-type FooID scalar files removed

.NET

  • Interfaces → C# interface generation
  • Unified IDId class with @expectedType support, inline fragments

Status

Done:

  • dagql.Interface type with field specs, ParseField, Satisfies, SatisfiedByInterface
  • Server.InstallInterface, InterfaceType, schema generation with ast.Interface and possibleTypes
  • Class.Implements / ImplementInterfaceUnchecked with covariant return type checking via ImplementsChecker
  • Auto-Node interface for all ID-able classes
  • InterfaceType.Install using dagql.Interface (not Class[*InterfaceAnnotatedValue])
  • registerInterfaceImpls and registerInterfaceToInterfaceImpls in core/schema_build.go
  • Inline fragment and fragment spread support with typeConditionMatches
  • __typename meta-field support
  • AnyID scalar with @expectedType directive replacing all per-type ID scalars
  • asFoo schema fields removed
  • InterfaceAnnotatedValue, wrapIface, dagql.InterfaceValue removed
  • SDK codegen updated: Go, Python, TypeScript, PHP, Java, Rust, Elixir, .NET
  • All generated SDK files regenerated
  • Integration tests updated for dagger.ID and ID! query strings
  • Global Object Identification: node(id: ID!): Node field on root query
  • All per-type loadFooFromID fields removed from schema
  • Object interface renamed to Node
  • CLI @expectedType-based flag resolution for unified ID args (scalar and list element types)

Global Object Identification

Problem

Every object type gets a loadFooFromID(id: ID!): Foo! field on the root query — 59 of them in the current core schema, plus more for every module type. These are all identical: decode the ID, validate the type name, call Server.Load. They clutter the schema, bloat SDK codegen (each one generates a public LoadFooFromID method), and deviate from the standard GraphQL pattern for object refetching.

With unified IDs and first-class interfaces already landed, there is no longer any reason for per-type load fields. The ID itself carries the concrete type, and every object already implements an interface with id: ID!.

Solution

Adopt GraphQL Global Object Identification. Replace all loadFooFromID fields with a single node field:

interface Node {
  id: ID!
}

type Query {
  node(id: ID!): Node
}

Every object with an ID implements Node. Clients use inline fragments to access type-specific fields:

{
  node(id: "...") {
    id
    ... on Container {
      platform
      rootfs { entries }
    }
  }
}

Schema changes

Before After
interface Object { id: ID! } interface Node { id: ID! }
loadContainerFromID(id: ID!): Container! removed
loadDirectoryFromID(id: ID!): Directory! removed
... ×59 node(id: ID!): Node

The return type is nullable Node — if the ID is invalid or refers to a type not in the current schema view, the field returns null (standard GraphQL convention for node).

For module-defined interfaces, the per-interface loadFooFromID is also removed. The node field handles them the same way: the ID encodes the concrete type, Server.Load resolves it, and the client narrows via fragment spreads.

Engine implementation

Node interface and node field:

The Node interface is auto-implemented by every class with an ID, installed in Server.InstallObject. The node(id: ID!): Node field is installed on the root query:

// dagql/server.go — installed once, before any objects
nodeIface := NewInterface("Node", "An object with a globally unique ID.")
nodeIface.AddField(InterfaceFieldSpec{
    FieldSpec: FieldSpec{
        Name: "id",
        Type: AnyID{},
    },
})
s.InstallInterface(nodeIface)
s.Root().ObjectType().Extend(
    FieldSpec{
        Name:        "node",
        Description: "Load any object by its ID.",
        Type:        nodeIface.Typed(),  // returns Node interface
        Args: NewInputSpecs(
            InputSpec{Name: "id", Type: AnyID{}},
        ),
        IsNullable: true,
    },
    func(ctx context.Context, _ AnyResult, args map[string]Input) (AnyResult, error) {
        idable, ok := args["id"].(IDable)
        if !ok {
            return nil, fmt.Errorf("expected IDable, got %T", args["id"])
        }
        return s.Load(ctx, idable.ID())
    },
)

All per-type loadFooFromID fields have been removed from Server.InstallObject and module interface installation.

SDK codegen

All SDKs use node(id:) with inline fragments internally. The Go SDK provides generic helpers:

// selectNode is the canonical helper used by all generated code.
func selectNode(q *Selection, id any, typeName string) *Selection {
    return q.Select("node").Arg("id", id).InlineFragment(typeName)
}

// Ref returns a lazy reference to a node by its ID (no network call).
func Ref[T Loadable[T]](c *Client, id ID) T {
    var zero T
    return zero.WithGraphQLQuery(selectNode(c.query, id, zero.XXX_GraphQLType()))
}

// Load verifies the node exists and matches the expected type.
func Load[T Loadable[T]](ctx context.Context, c *Client, id ID) (T, error) { ... }

Other SDKs follow the same pattern with their own selectNode / node(id:) + inline fragment helpers (Python, TypeScript, PHP, Java, Elixir, .NET).

What this enables

  • Schema size: Root query drops from ~60 load fields to 1.
  • Spec compliance: Standard node + Node interface means any GraphQL client library with Relay-style caching works out of the box.
  • Normalized caching: Clients that implement normalized caches (Apollo, Relay, urql) can automatically refetch and cache objects by ID without per-type configuration.
  • Simpler codegen: SDKs generate one node method. Typed helpers like Ref[T] and Load[T] are client-side sugar with no schema coupling.

Loose Ends

  1. Core interface typescore/schema_build.go:69 has // TODO support core interfaces types. Only module-defined interfaces go through the TypeDefs → InterfaceType → registerInterfaceImpls path. Core-defined interfaces (e.g. Node) are installed directly via Server.InstallInterface but don't participate in the module-level conformance registration. If core ever defines richer interfaces this will need work.

  2. Evaluator module — Still uses its own EvalWorkspaceEvalID type (per-type scalar) in generated code. Needs revisiting once the unified ID story is settled for module-defined ID-like types.

  3. Rust SDK — Regenerated for unified ID scalar, but not yet generating native Rust trait types for GraphQL interfaces. Just regen'd the bindings.

  4. TODO in core/interface.goConvertFromSDKResult has a slog.Warn for nil values and a "this seems expensive" note on loadImpl being called on every conversion. Worth profiling.

  5. TODO in core/object.go — Same nil-value warning pattern as interfaces: ConvertFromSDKResult: got nil value with a suggestion that Nullable wrapping should handle this instead.

  6. Argument contravarianceInterface.Satisfies intentionally omits argument contravariance checking. Core's IsSubtypeOf handles the semantic rules, but dagql's structural check is return-type-only.

  7. DynamicID remnantdagql/types.go still has NewDynamicID (one reference). This was the old mechanism for wrapping interface values in IDs. May be dead code now that InterfaceAnnotatedValue is gone.

  8. Integration test cleanup — Some integration tests still use removed APIs (dag.Load*FromID) in inline Go module source strings. These are being fixed as they're found; grep for dag\.Load.*FromID in core/integration/ to find remaining instances.

@vito
Copy link
Copy Markdown
Author

vito commented Apr 1, 2026

Changelog

  • Added Global Object Identification section: replace all loadFooFromID fields with a single node(id: ID!): Node root field per the GraphQL spec
  • Rename Object interface to Node to match spec
  • SDK codegen: one schema-backed node method, typed LoadFooFromID helpers become client-side sugar calling node
  • Migration path: deprecate loadFooFromID for one release cycle
  • Added loose ends 9-11 covering the rename, new field, and interfaceTypedMarker cleanup

@vito
Copy link
Copy Markdown
Author

vito commented Apr 5, 2026

Changelog

  • Elixir SDK: Updated from "No changes" to fully done (inline fragments, unified ID, node(id:), 52 FooID files removed)
  • .NET SDK: Added new section (was missing)
  • Go/PHP/Java SDK: Added Ref[T]/Load[T], loadObjectFromId, loadObjectFromID helpers
  • Status: Added Global Object Identification done items (node field, loadFooFromID removal, Object→Node rename, CLI @expectedType flag resolution)
  • Auto-implementing Object → Node: Renamed throughout
  • AnyID type: Removed stale loadFooFromID reference
  • Module interface installation: Removed interfaceTypedMarker/loadFooFromID reference (removed from codebase)
  • Global Object Identification / Engine: Rewrote from future-tense proposals to past-tense descriptions of implemented code
  • Global Object Identification / SDK codegen: Replaced proposed LoadFooFromID wrappers with actual selectNode/Ref[T]/Load[T] implementation
  • Migration section: Removed (migration is complete, loadFooFromID removed not deprecated)
  • Loose Ends: Removed 4 completed items (#4 Elixir, #9 Object→Node, #10 node field, #11 interfaceTypedMarker). Added #8 for remaining test cleanup. Renumbered 1-8.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment