System design
Stateless Relay Node Resolution with UUIDv7 and Ent
EntGo is generally a great framework. You can define your schema, generate an ORM and generate your types for whatever protocol you’re using, be it gRPC, GraphQL or OpenAPI. If you’re using GraphQL or gRPC, it pairs with Gqlgen by 99Designs or Protoc by Google for your resolver and rpc stubs. In GraphQL it automatically generates dataloaders to avoid the N+1 query problem. This means you just need to bring your ABAC/RBAC layer and you’ll have a functional and performant CRUD application.
As easy as EntGo makes things, it does seem to have a half-baked Relay node interface integration.
For those who are unacquainted, Relay is one of the main GraphQL clients that allows you to introspect, generate typescript types, use GraphQL fragments for components to define their own queries, and then batch the queries to the backend for efficient retrieval.
Relay defines a generic noder interface that allows you to just call node, and then use the on directive to grab the proper return contract. EntGo’s main way of generating “find” queries where a single record is returned, is via the Noder interface.
{
node(id: "4") {
id
... on User {
name
}
}
EntGo’s solution is something called “withGloballyUniqueID”. The short of it is that you must use integers for IDs. EntGo has a scheme where each entity gets 32² IDs. They go on to claim this gives the ability to have up to 65535 tables. By my math, in the Int64 range this would actually mean 4,294,967,296 tables. Based on the documentation alone, this is a bit of a black box. The documentation doesn’t clearly indicate the actual implementation and hence we really have no idea if this ID scheme is safe for distributed computing.
Hijacking UUIDv7
UUIDv7 is the preferred UUID version for distributed computing due to it fixing the sparse index problem. Prior UUIDs are truly random with no part of them having sequential data, meaning, building indices out of them is incredibly inefficient. B-Trees would be fragmented leading to UUIDs spread out over many pages, and hence poor cache locality and many cache misses.
UUIDv7 solves this issue by adding a millisecond timestamp in the UUID which allows it to be sortable for B-Trees and other types of indices allowing better cache locality, followed by a version (set to 0b0111 for 7) and a rand_a field for the first 12 random bits, and a var field which tells the UUID parser how to interpret the remaining bits, and then 62 random bits for 74 total random bits in addition to a millisecond timestamp for uniqueness.
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| unix_ts_ms |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| unix_ts_ms | ver | rand_a |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|var| rand_b |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| rand_b |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
We can hijack this rand_a field and still have 62 bits of randomness in rand_b in addition to the unix timestamp in milliseconds. This gives us 12 bits to represent tables or entities (2¹²-1) and still gives us plenty of randomness. We would need to generate 2.1 Billion UUID/second to reach a 50% chance of collision.
Encoding Tables Into Our UUID
The implementation is surprisingly straightforward. We hijack the 12-bit rand_a field to store our entity type identifier while preserving all other UUID v7 semantics:
package uuid
import "github.com/google/uuid"
type EntityType uint16
const (
TypeUser EntityType = 1
TypeDocument EntityType = 2
TypeOrg. EntityType = 4
// ... up to 4,095 entity types
)
func NewTyped(entityType EntityType) uuid.UUID {
id := uuid.Must(uuid.NewV7())
bytes := [16]byte(id)
// Preserve version bits, encode type in rand_a
bytes[6] = (bytes[6] & 0xF0) | byte((entityType>>8)&0x0F)
bytes[7] = byte(entityType & 0xFF)
return uuid.UUID(bytes)
}
The beauty lies in the extraction — we can determine the entity type without touching the database:
func GetType(id uuid.UUID) EntityType {
bytes := [16]byte(id)
typeCode := (uint16(bytes[6]&0x0F) << 8) | uint16(bytes[7])
return EntityType(typeCode)
}
As you can see, we’re doing constant time bit manipulation. No database checks, no config file changes, no integer-based IDs.
Putting it all together
With our UUIDv7 hijack implementation in place, the entire Relay node resolution pipeline becomes elegant and performant. Here’s how it all connects:
func (r *Resolver) Node(ctx context.Context, id string) (Node, error) {
uuid, err := uuid.Parse(id)
if err != nil {
return nil, err
}
entityType := GetType(uuid)
switch entityType {
case TypeUser:
return r.userService.Get(ctx, uuid)
case TypeDocument:
return r.documentService.Get(ctx, uuid)
case TypeOrg:
return r.orgService.Get(ctx, uuid)
}
}
query GetMixedEntities($ids: [ID!]!) {
nodes(ids: $ids) {
id
... on User {
name
email
}
... on Document {
title
status
createdAt
}
... on Org {
name
address
}
}
}
Since we’re still using standard UUID v7 format, your database queries remain clean and performant.
EntGo’s approach:
- Parse ID → determine range → calculate entity type → query database
- Multiple potential points of failure and configuration drift
- Requires coordination for ID allocation in distributed systems
Our UUIDv7 hijack:
- Parse ID → extract entity type → route to service
- Zero configuration, zero coordination, zero database overhead
- Works identically across all environments and scales
Have you run into similar limitations with EntGo’s node resolution? I’d be interested to hear about other approaches to this problem. Thanks for reading.