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 things | Edge |
| Fact about a thing or connection | Property |
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 -> ActivityEvent 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 -> StatusChangeAnnotation 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 -> AnnotationLineage 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 -> TransformTaxonomy 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 -> CategoryTemporal 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 -> CompanyIf 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.,
TeamorDepartmentbetweenPersonandCompany).
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.