Headlessly
Concepts

Data Model

The 35-entity typed graph — why these entities, how they connect, and the philosophy behind the data model.

Why 35 Entities

Not 5, not 500. headless.ly ships exactly 35 entities because of a single constraint: every entity exists because headless.ly needs it to run its own business. This is the dogfooding principle.

headless.ly is a startup that needs to track contacts, close deals, bill customers, manage projects, publish content, handle support tickets, run experiments, and measure growth. The 35 entities are the minimum set required to do all of that in a single system.

If headless.ly does not need an entity to operate, it does not ship. No speculative schemas, no "just in case" tables, no enterprise bloat.

The 11 Domains

DomainCountEntities
Identity2User, ApiKey
CRM6Organization, Contact, Lead, Deal, Activity, Pipeline
Billing7Customer, Product, Plan, Price, Subscription, Invoice, Payment
Projects3Project, Issue, Comment
Content3Content, Asset, Site
Support1Ticket
Analytics4Event, Metric, Funnel, Goal
Marketing3Campaign, Segment, Form
Experimentation2Experiment, FeatureFlag
Platform3Workflow, Integration, Agent
Communication1Message

Every domain maps to an @headlessly/* package. Import from the most specific domain:

import { Contact, Deal } from '@headlessly/crm'
import { Subscription, Invoice } from '@headlessly/billing'
import { Experiment } from '@headlessly/experiments'

Base Meta-Fields

Every entity carries seven meta-fields that the system manages automatically:

FieldTypeDescription
$typestringPascalCase entity discriminator (Contact, Deal, Subscription)
$idstringUnique ID in {type}_{sqid} format (contact_fX9bL5nRd)
$contextstringTenant namespace URL (https://headless.ly/~acme)
$versionnumberMonotonically increasing version, incremented on every mutation
$createdAtdatetimeISO 8601 timestamp of creation
$createdBystringID of the user or agent that created the entity
$updatedAtdatetimeISO 8601 timestamp of last mutation
import { Contact } from '@headlessly/crm'

const contact = await Contact.create({ name: 'Alice', stage: 'Lead' })

console.log(contact.$type)      // 'Contact'
console.log(contact.$id)        // 'contact_fX9bL5nRd'
console.log(contact.$context)   // 'https://headless.ly/~acme'
console.log(contact.$version)   // 1
console.log(contact.$createdAt) // '2026-01-15T12:00:00Z'

Meta-fields are read-only. You cannot set $id, $version, or $createdAt directly. They are managed by the system on every write.

Cross-Domain Connections

The 35 entities form a single connected graph. Relationships cross domain boundaries freely:

CRM                    Billing                 Support
───                    ───────                 ───────
Contact ──────────────► Customer               Ticket
   │                      │                      ▲
   ▼                      ▼                      │
  Deal                 Subscription           Contact
   │                      │
   ▼                      ▼
Organization           Invoice ──► Payment

A Contact in CRM is the same person as a Customer in Billing and the requester on a Ticket in Support. Relationships link them without duplication:

import { $ } from '@headlessly/sdk'

const contact = await $.Contact.get('contact_fX9bL5nRd', {
  include: ['deals', 'tickets', 'organization']
})

// Traverse across domains from a single root
contact.deals          // Deal[] (CRM)
contact.tickets        // Ticket[] (Support)
contact.organization   // Organization (CRM)

Relational-Document-Graph Hybrid

Traditional databases force you to pick a model. headless.ly combines all three:

ModeWhat It EnablesHow It Works
RelationalTyped schemas, foreign keys, joinsNoun definitions produce typed columns with constraints
DocumentFlexible fields, nested data, schema evolutionjson columns store arbitrary nested structures
GraphBidirectional traversal, cross-domain links-> and <- relationships create indexed edges

This means you can query relationally (Contact.find({ stage: 'Lead' })), store unstructured data in json fields, and traverse the entity graph in any direction -- all in one system.

How This Differs from Traditional Schemas

In a traditional SaaS stack, CRM is one database, billing is another, support is a third. You write glue code to sync data between them. Entity IDs do not match. Schema changes in one system break integrations with others.

In headless.ly, all 35 entities live in the same typed graph. A Contact has a first-class relationship to Subscription without any integration layer. Schema changes propagate through Apache Iceberg metadata -- add columns without rewriting data, roll back without losing history.

import { Deal } from '@headlessly/crm'

// When a deal closes, create a subscription -- no integration needed
Deal.closed((deal, $) => {
  $.Subscription.create({
    plan: 'pro',
    contact: deal.contact,
  })
})

One graph, one event log, one SDK. The architecture eliminates the integration layer entirely.

On this page