Omnigraph
Concepts

Graph Modeling

How to turn your domain into nodes, edges, and properties.

Graph modeling is the practice of deciding what becomes a node, what becomes an edge, and what becomes a property. The choices you make here determine how naturally your queries read and how well your graph scales.

The core question

For every piece of information in your domain, ask:

If it is a...Make it a...
Thing with identity (can be referenced, queried, connected)Node
Connection between two thingsEdge
Fact about a thing or connectionProperty

A Person is a node. The fact that a person WorksAt a company is an edge. The person's age is a property. The edge's start_date is a property on the edge.

Modeling patterns

Hub pattern

One central node type connected to many others. Good for 360-degree views.

node Account {
    name: String @key
    tier: enum(free, pro, enterprise)
}

node Contact { name: String @key }
node Deal { name: String @key }
node Activity { summary: String }

edge HasContact: Account -> Contact
edge HasDeal: Account -> Deal
edge HasActivity: Account -> Activity

Event sourcing

Model state changes as nodes, not property updates. Gives you a full audit trail.

node Ticket { slug: String @key }

node StatusChange {
    from_status: String
    to_status: String
    changed_at: DateTime
    reason: String?
}

edge HasStatusChange: Ticket -> StatusChange

Annotation pattern

Attach metadata to entities without polluting the entity's own properties.

node Document { title: String @key }

node Annotation {
    label: String
    confidence: F64
    created_by: String
}

edge HasAnnotation: Document -> Annotation

Lineage pattern

Track where data came from. Useful for trust, debugging, and compliance.

node Dataset { name: String @key }
node Transform { name: String, code: String }

edge DerivedFrom: Dataset -> Dataset
edge ProducedBy: Dataset -> Transform

Taxonomy pattern

Hierarchical categories as a node type with self-referencing edges.

node Category {
    name: String @key
    level: I32
}

edge SubcategoryOf: Category -> Category
edge BelongsTo: Document -> Category

Temporal pattern

Track changes over time by making time-bounded edges or versioned nodes.

node Employee { name: String @key }
node Role { title: String @key }

edge HasRole: Employee -> Role {
    start_date: Date
    end_date: Date?
}

Anti-patterns

Hiding relationships in properties

# Bad: relationship is a string property
node Person {
    name: String @key
    company_name: String    # Can't traverse, can't enforce, can't query
}

# Good: relationship is a typed edge
node Person { name: String @key }
node Company { name: String @key }
edge WorksAt: Person -> Company

If you find yourself storing an entity's name or ID as a string property on another entity, you probably want an edge instead.

Making everything a node

Not every piece of data needs to be a node. If something has no identity of its own and is never queried or connected independently, it belongs as a property.

# Overkill: Age as a node
node Age { value: I32 }
edge HasAge: Person -> Age

# Better: Age as a property
node Person {
    name: String @key
    age: I32?
}

Mega-nodes

A single node connected to millions of edges becomes a traversal bottleneck. If your Company node has 100,000 WorksAt edges, every query touching that company fans out to 100k results.

Solutions:

  • Add filtering properties to edges so queries can narrow early.
  • Introduce intermediate nodes (e.g., Team or Department between Person and Company).

Worked example: customer support

A customer support system needs to track customers, tickets, messages, and agents.

node Customer {
    email: String @key
    name: String @index
    plan: enum(free, pro, enterprise)
}

node Ticket {
    slug: String @key
    title: String @index
    status: enum(open, pending, resolved, closed)
    priority: enum(low, medium, high, critical)
    created_at: DateTime
}

node Message {
    body: String @index
    sent_at: DateTime
    sender_type: enum(customer, agent, bot)
    embedding: Vector(1536) @index
}

node Agent {
    name: String @key
    team: String
}

edge Filed: Customer -> Ticket
edge HasMessage: Ticket -> Message
edge AssignedTo: Ticket -> Agent
edge RelatedTo: Ticket -> Ticket {
    relation: enum(duplicate, follow_up, related)
}

This lets agents:

  • Traverse from a customer to all their tickets, then to messages.
  • Find unassigned tickets with not { $t assignedTo $a }.
  • Search message content with search($m.body, "refund").
  • Find related tickets by traversal instead of text matching.

On this page