Chapter 02 · Property Graph Model: Nodes, Relationships, Labels, Properties, Types, and Schema Discipline
Schema-Optional Does Not Mean Model-Free: Contracts, Naming, Cardinality, and Invariants
Schema-optional is not model-free: make vocabulary, identity, cardinality, required shape, and evolution rules executable and testable.
Learning outcomes
A schema-optional database can accept many shapes, which is useful during evolution but dangerous when teams interpret “optional” as “anything goes.” AtlasMart needs a contract describing which labels/types exist, which domain IDs are unique, which relationships are allowed, which properties are required by business policy, and which degree/cardinality boundaries are monitored.
Separate physical schema flexibility from explicit domain-model contracts.
Adopt consistent naming for labels, relationship types, properties, and named constraints.
Measure cardinality/degree and identify one-to-one, one-to-many, and many-to-many expectations that Neo4j does not automatically enforce.
Use Community uniqueness constraints plus validation queries for integrity that requires Enterprise existence/type/key constraints.
Design safe model evolution with additive changes, backfill, validation, cutover, and rollback.
Continue Chapter 01's free/local baseline: Neo4j Community
2026.07.1, database neo4j, explicit
CYPHER 25 in version-sensitive examples, local
container atlasmart-neo4j, Bolt
127.0.0.1:7687, HTTP 127.0.0.1:7474,
and disposable password atlasmart-course-2026.
Existing AtlasMart identifiers use Community-supported
uniqueness constraints. This chapter adds
:Category, :Supplier,
:Store, and :Shipment identities and
makes graph-model contracts observable.
The generation environment does not provide a running Docker daemon or Neo4j server, so commands are documentation-checked but not presented as captured output. Community supports the uniqueness constraints used in the mandatory lab. Current property-existence, property-type, and key constraints are Enterprise-only; when those stronger contracts are discussed, the Community path uses validation queries plus application/import checks rather than fabricating successful Community DDL.
Schema-optional means enforcement is selective, not absent
Neo4j does not require every :Product node to have
the same property set merely because they share a label. That
flexibility is different from having no model. AtlasMart still
needs an agreed vocabulary and invariants:
:Customer(customerId),
:Product(productId), :Order(orderId),
:Category(categoryId), and relationships such as
PLACED, CONTAINS,
IN_CATEGORY, SUPPLIED_BY, and
STOCKED_AT.
Current Neo4j naming recommendations use PascalCase labels,
SCREAMING_SNAKE_CASE relationship types, and camelCase property
names. Consistency matters because these names are case
sensitive. :Product and :product are
different labels; productId and
ProductId are different property keys.
| Contract layer | Example | Enforcement in mandatory Community lab |
|---|---|---|
| Vocabulary |
:Product, :Supplier,
SUPPLIED_BY
|
Team/model review + tests |
| Business identity | Product.productId unique |
Named uniqueness constraint |
| Required property | Every product must have name |
Validation query + import/application test; existence constraint is Enterprise-only |
| Property type | price numeric |
Validation query + application contract; type constraint is Enterprise-only |
| Cardinality | One order should be placed by exactly one customer | Degree/cardinality validation query + transaction/application policy |
| Relationship endpoint contract |
SUPPLIED_BY should connect Product→Supplier
|
Pattern validation query |
Freeze a model contract as executable evidence
CYPHER 25 CREATE CONSTRAINT customer_id IF NOT EXISTS FOR (c:Customer) REQUIRE c.customerId IS UNIQUE;CYPHER 25 CREATE CONSTRAINT product_id IF NOT EXISTS FOR (p:Product) REQUIRE p.productId IS UNIQUE;CYPHER 25 CREATE CONSTRAINT order_id IF NOT EXISTS FOR (o:Order) REQUIRE o.orderId IS UNIQUE;CYPHER 25 CREATE CONSTRAINT category_id IF NOT EXISTS FOR (c:Category) REQUIRE c.categoryId IS UNIQUE;CYPHER 25 CREATE CONSTRAINT supplier_id IF NOT EXISTS FOR (s:Supplier) REQUIRE s.supplierId IS UNIQUE;CYPHER 25 CREATE CONSTRAINT store_id IF NOT EXISTS FOR (s:Store) REQUIRE s.storeId IS UNIQUE;CYPHER 25 CREATE CONSTRAINT shipment_id IF NOT EXISTS FOR (s:Shipment) REQUIRE s.shipmentId IS UNIQUE;
Uniqueness prevents duplicate non-null values; it does not require the property to exist on every node. That distinction is important. An Enterprise node key combines existence and uniqueness, and Enterprise property-existence/type constraints can enforce additional contracts, but this course does not use those as the mandatory path.
CYPHER 25MATCH (p:Product)WHERE p.productId IS NULL OR p.name IS NULLRETURN p, 'missing required product identity/name' AS violation;CYPHER 25MATCH (p:Product)WHERE p.price IS NOT NULL AND NOT (p.price IS :: INTEGER | FLOAT)RETURN p.productId, p.price, valueType(p.price) AS actualType;CYPHER 25MATCH (o:Order)OPTIONAL MATCH (c:Customer)-[:PLACED]->(o)WITH o, count(c) AS placingCustomersWHERE placingCustomers <> 1RETURN o.orderId, placingCustomers;
These validation queries are executable model documentation. They can run in CI against fixtures, after imports, and during migrations. They do not provide the same concurrency-time enforcement as database constraints, so the course labels that difference rather than pretending validation is equivalent.
Cardinality lives in the data unless you enforce it
Graph diagrams often draw one arrow and imply “one.” The database stores however many matching relationships the application creates. AtlasMart expects each order to have one placing customer, products may have several suppliers, products may be stocked at many stores, and categories may contain many products. These are business cardinalities, not automatic consequences of the property-graph model.
CYPHER 25MATCH (o:Order)OPTIONAL MATCH (c:Customer)-[:PLACED]->(o)RETURN o.orderId, count(c) AS inboundPLACEDORDER BY inboundPLACED DESC, o.orderId;CYPHER 25MATCH (p:Product)OPTIONAL MATCH (p)-[:SUPPLIED_BY]->(s:Supplier)RETURN p.productId, count(s) AS suppliersORDER BY suppliers DESC, p.productId;CYPHER 25MATCH (p:Product)OPTIONAL MATCH (p)-[:STOCKED_AT]->(s:Store)RETURN p.productId, count(s) AS storesORDER BY stores DESC, p.productId;
Beyond correctness, degree affects traversal fan-out and
performance. A :Product with ten supplier edges
behaves differently from a hub with ten million edges. Chapter
26 will profile those costs; Chapter 02 records the expected
cardinality distribution so “high degree” is a measurable model
property, not a surprise incident symptom.
Deliberately break an invariant: two customers place one order
CYPHER 25MATCH (o:Order {orderId:'O-5001'}), (c:Customer {customerId:'C-1002'})MERGE (c)-[:PLACED]->(o);CYPHER 25MATCH (o:Order)OPTIONAL MATCH (c:Customer)-[:PLACED]->(o)WITH o, collect(c.customerId) AS customerIdsWHERE size(customerIds) <> 1RETURN o.orderId, customerIds;
Neo4j accepts the second relationship because no current
Community constraint states “exactly one inbound PLACED edge per
order.” The repair is to decide the invariant owner. For this
course, the write transaction/application policy must match the
order by orderId, reject an existing different
placer, and only then create the relationship. The validation
query remains the regression check.
CYPHER 25MATCH (:Customer {customerId:'C-1002'})-[r:PLACED]->(:Order {orderId:'O-5001'})DELETE r;MATCH (o:Order {orderId:'O-5001'})OPTIONAL MATCH (c:Customer)-[:PLACED]->(o)RETURN o.orderId, collect(c.customerId) AS customerIds;
The command deletes only the known synthetic relationship. A production repair must prove which relationship is authoritative, preserve audit requirements, and define rollback/reconciliation before mutating connected data.
Evolve the model with an explicit migration contract
Chapter 01 stored Product.category='Cameras' as a
scalar. Chapter 02 decides Category deserves independent
identity and traversal. A safe evolution keeps old and new
representations temporarily: create
:Category nodes, backfill IN_CATEGORY,
compare coverage, update readers/writers, and only then remove
the old property if it is no longer part of the contract.
CYPHER 25MATCH (p:Product)WHERE p.category IS NOT NULLMERGE (c:Category {categoryId:'CAT-' + toUpper(replace(p.category,' ','-'))})ON CREATE SET c.name=p.categoryMERGE (p)-[:IN_CATEGORY]->(c);CYPHER 25MATCH (p:Product)WHERE p.category IS NOT NULLOPTIONAL MATCH (p)-[:IN_CATEGORY]->(c:Category)WITH p, count(c) AS mappedWHERE mapped = 0RETURN p.productId, p.category;
Only after readers and writers have moved and validation returns
no unexplained gaps should AtlasMart consider
REMOVE p.category. Keeping a rollback window is a
data-model concern, not just a deployment concern.
Lab: produce a schema-discipline report
SHOW CONSTRAINTS YIELD name, type, entityType, labelsOrTypes, propertiesRETURN name, type, entityType, labelsOrTypes, properties ORDER BY name;CYPHER 25MATCH (n)RETURN labels(n) AS labels, count(*) AS nodes ORDER BY labels;CYPHER 25MATCH ()-[r]->()RETURN type(r) AS relationshipType, count(*) AS relationships ORDER BY relationshipType;CYPHER 25MATCH (p:Product)WHERE p.productId IS NULL OR p.name IS NULLRETURN p.productId, p.name;
Acceptance criteria: every domain label has a documented stable key; all current keys are protected by named uniqueness constraints in Community; naming conventions are consistent; relationship endpoint/direction rules are documented; cardinality validation queries return only known exceptions; model migrations specify dual representation, backfill, validation, cutover, and rollback.
Check your understanding
- Why does a uniqueness constraint not prove every Product has productId?
- What is the difference between model cardinality and relationship direction?
- Why are validation queries not fully equivalent to database constraints?
- How should AtlasMart migrate category from a scalar property to a node relationship safely?
- Why record degree distributions now?
Review the answers
1. Uniqueness applies to values that are present; missing values are not forced to exist by a uniqueness constraint.
2. Direction states edge orientation; cardinality states how many such edges/entities are allowed or expected.
3. Validation detects violations when run but may not prevent concurrent invalid writes at commit time.
4. Create category identities and IN_CATEGORY edges while the old property remains, verify coverage and reader/writer cutover, then remove the old property only after a rollback-aware migration decision.
5. Degree controls correctness expectations and future traversal fan-out/performance, so a baseline makes later anomalies measurable.
Summary and next step
Neo4j's flexible schema becomes production-safe only when vocabulary, identity, property shape, cardinality, relationship endpoints, and migrations are explicit. Community uniqueness constraints plus deterministic validation give this free lab a real integrity contract while preserving the edition boundary. Next, choose durable identifiers and decide when facts belong on relationships versus nodes, especially when time changes their meaning.
Authoritative references
- Current Neo4j versions — Official current-release and 5.26 LTS patch snapshot.
- Cypher Manual — Current Cypher language reference.
- Property, structural, and constructed values — Current property/storage type rules, including lists and maps.
- Working with null — Current missing-property and three-valued null semantics.
- Create constraints — Constraint syntax, integrity semantics, backing indexes, and edition boundaries.
- Scalar functions: elementId() — Current elementId() guarantees and warning against durable application identity.
- MATCH — Directed and undirected relationship-pattern matching semantics.
- CREATE — Node and relationship creation semantics.
- Naming rules and recommendations — Current naming recommendations and case sensitivity.