Ontology Modeling Best Practices: 6 Golden Rules
After participating in over 20 Ontology modeling projects, we have identified a pattern: 80% of modeling problems are not technical problems but design decision problems.
Ontology Modeling Best Practices: 6 Golden Rules
“Series: S4 Ontology Modeling · Article 14 | Level: Intermediate | Reading Time: 18 min
#TL;DR
- Naming conventions are the foundation of maintainability — adopt "domain-first, English PascalCase, no abbreviations" principles to make ObjectType and property names self-documenting, so new team members can understand the model without reading documentation.
- Granularity control determines the model's long-term viability — too coarse leads to property explosion, too fine leads to relation explosion. The golden rule is "one ObjectType corresponds to one independently operable business entity."
- Relation direction, interface extraction, metric design, and version management are the dividing line between "working" and "well-designed" — these four rules elevate your Ontology from PoC-level to production-grade.
#1. Introduction: Why Best Practices Matter
After participating in over 20 Ontology modeling projects, we have identified a pattern: 80% of modeling problems are not technical problems but design decision problems.
Common pitfalls include:
- Inconsistent naming, where the same concept has different names across ObjectTypes
- ObjectType granularity out of control, with one "God object" stuffed with 100 properties
- RelationType direction chaos, with the same relationship defined in both directions
- InterfaceType not extracted, leading to massive property duplication
- Metric definitions scattered in code, impossible to manage or audit centrally
- Schema changes without version control, causing frequent compatibility issues in production
These problems seem trivial at the start, but as the model grows to 50+ ObjectTypes, they become the core source of technical debt.
Model Scale Common Problem Timeline
───────────────────────────────────────────
5 ObjectTypes "Just pick a name, let's get it running"
15 ObjectTypes "Is this property called status or state?"
30 ObjectTypes "What's the difference between these two ObjectTypes?"
50 ObjectTypes "Who changed the schema? Why is the API down?"
100 ObjectTypes "Refactoring cost is too high, just keep adding"
The following 6 golden rules are summarized precisely to prevent this "slippery slope."
#2. Rule One: Naming Conventions — Let Names Speak for Themselves
#2.1 ObjectType Naming
| Rule | Correct | Incorrect | Reason |
|---|---|---|---|
| PascalCase | ProductionOrder | production_order | Consistent with Protobuf/Java class names |
| Singular noun | Customer | Customers | ObjectType is a type definition, not a collection |
| Domain nouns | WorkOrder | WO | No abbreviations allowed |
| No prefix | Equipment | TblEquipment | Don't expose storage details |
| Be specific | MaintenanceRecord | Record | Avoid over-generalization |
# Good naming
object_types = [
"Customer",
"ProductionOrder",
"QualityInspection",
"MaintenanceSchedule",
"WarehouseLocation",
]
# Bad naming
bad_names = [
"Cust", # Abbreviation
"tbl_customer", # Exposes storage
"CustomerInfoData", # Redundant suffix
"Misc", # Too generic
"customer", # Lowercase start
]
#2.2 Property Naming
| Rule | Correct | Incorrect | Reason |
|---|---|---|---|
| camelCase | createdAt | created_at | JSON/TypeScript convention |
| Meaningful prefix | expectedDeliveryDate | date1 | Self-documenting |
| Boolean uses is/has | isActive | active | Type-obvious |
| Avoid ambiguity | orderTotalAmount | total | Prevent cross-type conflicts |
| Enum uses noun | status | getStatus | Properties are not methods |
# Property naming reference table
property_naming = {
# Time properties
"createdAt": "TIMESTAMP", # Creation time
"updatedAt": "TIMESTAMP", # Update time
"scheduledDate": "DATE", # Scheduled date
"completedAt": "TIMESTAMP", # Completion time
# Boolean properties
"isActive": "BOOLEAN", # Whether active
"hasWarranty": "BOOLEAN", # Has warranty
"isOverdue": "BOOLEAN", # Whether overdue
# Monetary properties
"unitPrice": "DOUBLE", # Unit price
"totalAmount": "DOUBLE", # Total amount
"discountRate": "DOUBLE", # Discount rate
# Identifier properties
"equipmentId": "STRING", # Equipment ID
"customerId": "STRING", # Customer ID
"externalRef": "STRING", # External reference
}
#2.3 RelationType Naming
Relation naming follows the "Verb + Noun" pattern, directed from source to target:
Pattern: {Source} --[{Verb}{Target}]--> {Target}
Customer --[PlacedOrder]--> Order
Order --[ContainsProduct]--> Product
Equipment --[BelongsToLine]--> ProductionLine
Employee --[ManagedBy]--> Employee (self-reference)
WorkOrder --[AssignedTo]--> Equipment
# Relation naming conventions
relation_examples = [
# Ownership relations
RelationType("BelongsToLine",
source="Equipment", target="ProductionLine"),
RelationType("BelongsToDepartment",
source="Employee", target="Department"),
# Action relations
RelationType("PlacedOrder",
source="Customer", target="Order"),
RelationType("CreatedInspection",
source="Inspector", target="QualityInspection"),
# Containment relations
RelationType("ContainsItem",
source="Order", target="OrderItem"),
RelationType("HasComponent",
source="Equipment", target="Component"),
# Reference relations
RelationType("AssignedTo",
source="WorkOrder", target="Equipment"),
RelationType("ReferencesSupplier",
source="PurchaseOrder", target="Supplier"),
]
#2.4 Naming Self-Check List
Before creating each ObjectType, use this checklist:
[] Name uses PascalCase?
[] Name is singular noun form?
[] Name is specific enough (not Data, Info, Item, etc.)?
[] Name avoids abbreviations?
[] Name is unambiguous with existing ObjectTypes?
[] Properties use camelCase?
[] Boolean properties start with is/has?
[] Time properties end with At/Date?
[] Relation name expresses semantic direction?
#3. Rule Two: Granularity Control — One ObjectType, One Responsibility
#3.1 Signs of Too-Coarse Granularity
┌────────────────────────────────────────────────────┐
│ Product (too coarse ObjectType) │
│ │
│ Basic info: name, sku, description, category │
│ Pricing: basePrice, discountPrice, vipPrice │
│ Inventory: totalStock, availableStock, reserved │
│ Supplier: supplierName, supplierContact │
│ Shipping: weight, dimensions, shippingClass │
│ Reviews: avgRating, totalReviews, recentComments │
│ Marketing: tags, promotionId, bannerUrl │
│ │
│ Property count: 30+ │
│ Problem: Changing pricing strategy requires │
│ modifying Product; changing inventory │
│ logic also requires modifying Product │
│ -> Violates Single Responsibility │
└────────────────────────────────────────────────────┘
#3.2 Signs of Too-Fine Granularity
┌───────────┐ ┌───────────┐ ┌───────────┐
│ProductName│ │ProductSku │ │ProductDesc│
│ │ │ │ │ │
│ name │ │ sku │ │ desc │
└─────┬─────┘ └─────┬─────┘ └─────┬─────┘
│ │ │
└───────────────┼───────────────┘
│
Relations: 3+ (just for basic product info)
Problem: Querying one product requires
JOINing 3 objects -> Over-splitting
#3.3 Correct Granularity Criteria
Golden Rule: One ObjectType corresponds to one independently operable business entity.
Decision criteria:
| Question | "Yes" -> Independent ObjectType | "No" -> Property or StructType |
|---|---|---|
| Does it have an independent lifecycle? | Order exists independent of Customer | Address doesn't exist independent of Customer |
| Does it have an independent primary key? | Each equipment has a unique ID | Equipment parameters have no independent ID |
| Can business operate on it independently? | Can query/modify inventory alone | Won't query product weight alone |
| Is it referenced by multiple other objects? | Supplier referenced by multiple POs | Order note belongs to only one order |
| Is its data volume worth independent management? | Sensor readings in millions | Product tags are just a few |
# Correct granularity split example
correct_modeling = {
"Product": {
"properties": ["productId", "name", "sku", "description",
"category", "weight", "dimensions"],
"structs": ["Dimensions"], # Nested value object
},
"Pricing": {
"properties": ["pricingId", "basePrice", "currency",
"discountRules", "effectiveFrom", "effectiveTo"],
"reason": "Pricing has independent effective periods and change approval workflows",
},
"Inventory": {
"properties": ["inventoryId", "totalStock", "availableStock",
"reservedStock", "warehouseId", "reorderPoint"],
"reason": "Inventory has independent in/out operations and alert rules",
},
"Supplier": {
"properties": ["supplierId", "name", "contact",
"rating", "certifications"],
"reason": "Supplier is referenced by multiple products/purchase orders",
},
}
#3.4 When to Use StructType
When a group of properties always appear together but don't need independent management, use StructType:
# Address — always a component of other objects
kind: StructType
metadata:
name: Address
spec:
properties:
province:
type: STRING
city:
type: STRING
district:
type: STRING
street:
type: STRING
postalCode:
type: STRING
coordinates:
type: STRUCT
structType: GeoLocation
# Reusable across multiple ObjectTypes
# Customer.shippingAddress: Address
# Supplier.headquarterAddress: Address
# Warehouse.location: Address
#4. Rule Three: Relation Direction — From "Who Owns Whom" to "Who Depends on Whom"
#4.1 Direction Selection Principles
Relation direction is not arbitrary — it determines query naturalness and performance:
Principle 1: From "many" to "one" (BelongsTo direction)
─────────────────────────────────────────
Order --[PlacedBy]--> Customer Good: natural, order belongs to customer
Customer --[HasOrder]--> Order OK but not preferred
Reason:
- One customer may have 10,000 orders
- Traversing from Order to Customer is 1:1 (fast)
- Traversing from Customer to Orders is 1:N (needs index)
Principle 2: From "dependent" to "dependency"
─────────────────────────────────────
WorkOrder --[AssignedTo]--> Equipment Good: work order depends on equipment
Equipment --[HasWorkOrder]--> WorkOrder Not preferred
Reason:
- Deleting equipment requires checking for open work orders
- Dependency direction = cascade check direction
Principle 3: From "action initiator" to "action target"
─────────────────────────────────────
Inspector --[PerformedInspection]--> QualityReport Good
QualityReport --[InspectedBy]--> Inspector Not preferred
Reason:
- Action initiator is subject, target is object
- This direction aligns with ActionType semantics
#4.2 Avoid Bidirectional Redundancy
Anti-pattern:
Customer --[HasOrder]--> Order
Order --[BelongsTo]--> Customer
Problems:
- Two relations express the same semantics
- Maintenance cost doubles
- Risk of data inconsistency
Correct approach:
Order --[PlacedBy]--> Customer
(Platform automatically supports reverse traversal without declaring reverse relation)
# Platform-supported reverse queries
# Only need to declare one relation
relation = RelationType(
name="PlacedBy",
source="Order",
target="Customer",
cardinality="MANY_TO_ONE",
)
# Forward query: from order find customer
customer = client.ontology.traverse("Order", order_id, "PlacedBy")
# Reverse query: from customer find all orders (auto-supported)
orders = client.ontology.traverse_reverse("Customer", customer_id, "PlacedBy")
#4.3 Self-Referencing Relations
# Employee reporting relationship
kind: RelationType
metadata:
name: ReportsTo
spec:
source: Employee
target: Employee
cardinality: MANY_TO_ONE
properties:
since:
type: DATE
reportType:
type: STRING
enum: [DIRECT, DOTTED_LINE]
# Supported queries:
# - Find someone's direct manager: traverse("Employee", id, "ReportsTo")
# - Find all reports: traverse_reverse("Employee", id, "ReportsTo")
# - Find entire reporting chain: traverse_recursive("Employee", id, "ReportsTo", maxDepth=10)
#4.4 Relation Direction Decision Tree
Q1: Is it a 1:N relationship?
|-- Yes -> From N side to 1 side (BelongsTo direction)
|-- No -> Q2
Q2: Is it a M:N relationship?
|-- Yes -> Choose "action initiator" as source
| (e.g., Student --[Enrolled]--> Course)
|-- No -> Q3
Q3: Is it a 1:1 relationship?
|-- Yes -> From "dependent" to "dependency"
| (e.g., UserProfile --[BelongsTo]--> User)
|-- No -> May not need RelationType, consider embedded property
#5. Rule Four: Interface Extraction — The Ontology Version of DRY
#5.1 Identifying Repeated Patterns
When you find multiple ObjectTypes sharing the same property combinations, it's time to extract an InterfaceType:
Before (duplicate properties scattered everywhere):
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ Equipment │ │ Vehicle │ │ Building │
│ │ │ │ │ │
│ createdAt │ │ createdAt │ │ createdAt │
│ createdBy │ │ createdBy │ │ createdBy │
│ updatedAt │ │ updatedAt │ │ updatedAt │
│ updatedBy │ │ updatedBy │ │ updatedBy │
│ status │ │ status │ │ status │
│ --unique-- │ │ --unique-- │ │ --unique-- │
│ model │ │ plateNumber │ │ floors │
│ serialNo │ │ mileage │ │ area │
└──────────────┘ └──────────────┘ └──────────────┘
After (extracted interfaces):
┌──────────────────┐ ┌──────────────────┐
│ <<interface>> │ │ <<interface>> │
│ Auditable │ │ Statusable │
│ │ │ │
│ createdAt │ │ status │
│ createdBy │ │ │
│ updatedAt │ └──────────────────┘
│ updatedBy │
└──────────────────┘
Equipment implements Auditable, Statusable
Vehicle implements Auditable, Statusable
Building implements Auditable, Statusable
#5.2 Common Reusable Interfaces
# Audit interface — who, when, what
kind: InterfaceType
metadata:
name: Auditable
spec:
properties:
createdAt: { type: TIMESTAMP }
createdBy: { type: STRING }
updatedAt: { type: TIMESTAMP }
updatedBy: { type: STRING }
---
# Geolocation interface — where
kind: InterfaceType
metadata:
name: Locatable
spec:
properties:
latitude: { type: DOUBLE }
longitude: { type: DOUBLE }
address: { type: STRING }
geofenceId: { type: STRING }
---
# State machine interface — current state and transitions
kind: InterfaceType
metadata:
name: Statusable
spec:
properties:
status: { type: STRING }
statusChangedAt: { type: TIMESTAMP }
statusChangedBy: { type: STRING }
previousStatus: { type: STRING }
---
# Tagging interface — classification and labels
kind: InterfaceType
metadata:
name: Taggable
spec:
properties:
tags: { type: ARRAY, itemType: STRING }
category: { type: STRING }
labels: { type: MAP, keyType: STRING, valueType: STRING }
---
# Measurable interface — has associated metrics
kind: InterfaceType
metadata:
name: Measurable
spec:
properties:
lastMeasuredAt: { type: TIMESTAMP }
measurementCount: { type: INTEGER }
measurementSource: { type: STRING }
---
# Archivable interface — soft delete and archive
kind: InterfaceType
metadata:
name: Archivable
spec:
properties:
isArchived: { type: BOOLEAN }
archivedAt: { type: TIMESTAMP }
archivedBy: { type: STRING }
archiveReason: { type: STRING }
#5.3 The Power of Polymorphic Queries
# Find all Locatable objects "in Shanghai" (regardless of Equipment, Vehicle, or Building)
result = client.ontology.query(
interface="Locatable",
filter="address LIKE '%Shanghai%'",
)
# Returns mixed results: Equipment + Vehicle + Building
for obj in result.objects:
print(f"[{obj.object_type}] {obj.name} at {obj.address}")
# Find all Auditable objects "modified in the last 24 hours"
recent_changes = client.ontology.query(
interface="Auditable",
filter="updatedAt > now() - interval('24h')",
order_by="updatedAt DESC",
)
#5.4 Interface Extraction Decision Criteria
Extract an interface when any of these conditions are met:
1. Property combination repeats in 3+ ObjectTypes
2. Cross-type polymorphic query requirement exists
3. Property combination represents an independent business capability
(e.g., "auditable", "locatable")
4. Need to uniformly apply rules or permissions to heterogeneous objects
#6. Rule Five: Metric Design — The Bridge from "Data" to "Insight"
#6.1 Metric Definition Principles
Principle 1: Every metric must have clear business meaning
─────────────────────────────────────
Good: "Overall Equipment Effectiveness (OEE)" — managers understand immediately
Bad: "avg_val_123" — nobody knows what this is
Principle 2: Metrics must declare aggregation method
─────────────────────────────
Good: AVG(oee) GROUP BY productionLine — average OEE per line
Bad: oee — unclear if single value or aggregate
Principle 3: Metrics must declare dimensions
─────────────────────────
Good: dimensions: [factory, productionLine, shift]
Bad: No dimensions = only global value
Principle 4: Derived metrics must declare dependencies
─────────────────────────────
Good: oee = availability * performance * quality
Bad: Opaque calculation relationships between metrics
#6.2 Metric Hierarchy Design
Level 4: Strategic Metrics (CEO/CFO)
|-- Enterprise-wide OEE
|-- Customer Satisfaction (NPS)
|-- Revenue Achievement Rate
|
Level 3: Operational Metrics (Department Managers)
|-- Line OEE
|-- Order On-Time Delivery Rate
|-- Inventory Turnover Rate
|
Level 2: Tactical Metrics (Shift Supervisors)
|-- Equipment Availability
|-- First Pass Yield
|-- Work Order Completion Rate
|
Level 1: Atomic Metrics (Sensors/Systems)
|-- Equipment Running Hours
|-- Product Inspection Results
|-- Work Order Status Changes
# Level 1: Atomic metrics (directly from data)
atomic_metrics = [
MetricSpec(
name="EquipmentRuntime",
object_type="Equipment",
property="runningHours",
aggregation="SUM",
unit="hours",
dimensions=["factory", "productionLine"],
),
MetricSpec(
name="InspectionPassCount",
object_type="QualityInspection",
property="isPassed",
aggregation="COUNT",
filter="isPassed == true",
dimensions=["productionLine", "shift"],
),
]
# Level 2: Tactical metrics (computed from atomic metrics)
tactical_metrics = [
MetricSpec(
name="EquipmentAvailability",
expression="EquipmentRuntime / PlannedProductionTime",
unit="percentage",
dimensions=["factory", "productionLine"],
),
MetricSpec(
name="FirstPassYield",
expression="InspectionPassCount / TotalInspectionCount",
unit="percentage",
dimensions=["productionLine", "shift"],
),
]
# Level 3: Operational metrics (computed from tactical metrics)
operational_metrics = [
MetricSpec(
name="LineOEE",
expression="EquipmentAvailability * PerformanceRate * QualityRate",
unit="percentage",
dimensions=["factory", "productionLine"],
),
]
# Level 4: Strategic metrics (aggregated from operational metrics)
strategic_metrics = [
MetricSpec(
name="EnterpriseOEE",
expression="AVG(LineOEE)",
unit="percentage",
dimensions=["factory"],
),
]
#6.3 Metric Alert Thresholds
# Configure alerts for metrics
client.metric.set_alert(
metric="LineOEE",
rules=[
AlertRule(
name="oee-critical",
condition="value < 0.60",
severity="CRITICAL",
message="Line {productionLine} OEE below 60%",
channels=["sms-factory-manager", "pagerduty"],
),
AlertRule(
name="oee-warning",
condition="value < 0.75",
severity="WARNING",
message="Line {productionLine} OEE below 75%",
channels=["slack-production"],
),
AlertRule(
name="oee-trend-down",
condition="trend(7d) < -0.05",
severity="INFO",
message="Line {productionLine} OEE 7-day trend down 5%",
channels=["email-production-manager"],
),
],
)
#6.4 Metric Version Management
# Metric definition changes require approval
proposal = client.metric.create_change_proposal(
metric="LineOEE",
change_type="FORMULA_UPDATE",
old_expression="availability * performance * quality",
new_expression="availability * performance * quality * sustainability",
reason="Add sustainability factor for ESG reporting requirements",
effective_date="2026-04-01",
)
# Submit for review
proposal.submit_for_review(reviewers=["data-governance-team"])
#7. Rule Six: Version Management — The Safety Net for Schema Changes
#7.1 Why Schema Version Management is Critical
World without version management:
Day 1: Add property email
Day 3: Rename email to emailAddress
Day 5: 10 downstream applications all break
Day 6: Emergency rollback, but data is already inconsistent
Day 7: Overtime fixing data
World with version management:
Day 1: Create Proposal "Add emailAddress property"
Day 2: Automatic compatibility check passes
Day 3: Reviewer approves
Day 4: Canary release to 10% of readers
Day 5: Full rollout
Day 6: Old property email marked DEPRECATED
Day 30: Confirmed no one uses it, remove old property
#7.2 Schema Change Classification
| Change Type | Compatibility | Auto-approve | Example |
|---|---|---|---|
| Add optional property | Backward compatible | Yes | Add nickname: STRING? |
| Add required property (with default) | Backward compatible | Yes | Add status: STRING = "ACTIVE" |
| Add required property (no default) | Incompatible | No | Add email: STRING |
| Remove property | Incompatible | No | Remove fax: STRING |
| Change property type | Incompatible | No | age: STRING -> INTEGER |
| Rename property | Incompatible | No | email -> emailAddress |
| Add relation | Backward compatible | Yes | Add BelongsTo |
| Remove relation | Incompatible | No | Remove BelongsTo |
#7.3 Proposal Workflow
┌─────────┐ submit ┌──────────┐ approve ┌──────────┐
│ DRAFT ├────────────►│ REVIEW ├────────────►│ APPROVED │
└────┬────┘ └────┬─────┘ └────┬─────┘
│ │ │
│ discard reject│ apply│
│ │ │
▼ ▼ ▼
┌─────────┐ ┌──────────┐ ┌──────────┐
│DISCARDED│ │ REJECTED │ │ APPLIED │
└─────────┘ └──────────┘ └──────────┘
# Create schema change proposal
proposal = client.schema.create_proposal(
title="Add loyalty-related properties to Customer",
description="Support loyalty points system launch",
changes=[
AddProperty("Customer", "loyaltyTier",
type="STRING", enum=["BRONZE","SILVER","GOLD","PLATINUM"],
default="BRONZE"),
AddProperty("Customer", "loyaltyPoints",
type="INTEGER", default=0),
AddProperty("Customer", "memberSince",
type="DATE", nullable=True),
AddRelation("Customer", "EarnedReward", "Reward",
cardinality="ONE_TO_MANY"),
],
)
# Submit for review
proposal.submit(reviewers=["schema-admin", "loyalty-team-lead"])
# Check compatibility results
compat = proposal.compatibility_check()
print(f"Backward compatible: {compat.backward_compatible}")
print(f"Forward compatible: {compat.forward_compatible}")
print(f"Breaking changes: {compat.breaking_changes}")
# Apply after approval
proposal.apply(
rollout_strategy="CANARY", # Canary release
canary_percentage=10, # 10% canary first
auto_promote_after="24h", # Auto-promote after 24h if no issues
)
#7.4 Version Number Strategy
Schema versioning follows semantic versioning: MAJOR.MINOR.PATCH
MAJOR: Incompatible changes
- Remove property
- Change property type
- Remove relation
MINOR: Backward-compatible changes
- Add optional property
- Add relation
- Add interface implementation
PATCH: Non-structural changes
- Modify display_name
- Modify description
- Modify constraints (relaxation)
Example version timeline:
Customer v1.0.0 -> v1.1.0 (add loyaltyTier) -> v1.1.1 (update description)
-> v2.0.0 (remove fax property, change phone type)
#8. Comprehensive Practice: Refactoring an Ontology with All 6 Rules
#8.1 Before Refactoring (Violating Multiple Rules)
# Anti-pattern example
bad_ontology = {
"cust_info": { # Violates naming: abbreviation, underscore, "info" suffix
"properties": {
"id": "STRING",
"nm": "STRING", # Abbreviation
"created": "STRING", # Wrong type, should be TIMESTAMP
"addr_province": "STRING", # Should use StructType
"addr_city": "STRING",
"addr_street": "STRING",
"total_orders": "INTEGER", # Where is the computation logic?
"vip": "BOOLEAN", # Should be isVip
},
},
"order_data": { # "data" suffix is meaningless
"properties": {
"oid": "STRING",
"cust_id": "STRING", # Should be RelationType
"items": "STRING", # JSON string? Should be relation
},
},
}
#8.2 After Refactoring (Following All 6 Rules)
# Best practice example
# Rule 1: Naming conventions
# Rule 4: Interface extraction
interfaces = [
InterfaceType("Auditable", properties=[
"createdAt", "createdBy", "updatedAt", "updatedBy",
]),
InterfaceType("Archivable", properties=[
"isArchived", "archivedAt", "archivedBy",
]),
]
# Rule 2: Granularity control (Address as StructType)
structs = [
StructType("Address", properties={
"province": "STRING",
"city": "STRING",
"district": "STRING",
"street": "STRING",
"postalCode": "STRING",
}),
]
# Rule 2: Granularity control (independent business entities)
object_types = [
ObjectType("Customer",
implements=["Auditable", "Archivable"],
properties={
"customerId": PropertySpec(type="STRING", primary_key=True),
"name": PropertySpec(type="STRING", required=True),
"email": PropertySpec(type="STRING"),
"shippingAddress": PropertySpec(type="STRUCT",
struct_type="Address"),
"isVip": PropertySpec(type="BOOLEAN", default=False),
# Rule 5: Metric design - derived property declares dependency
"totalOrders": PropertySpec(type="INTEGER", derived=True,
aggregation="COUNT",
source_relation="PlacedOrder"),
},
),
ObjectType("Order",
implements=["Auditable"],
properties={
"orderId": PropertySpec(type="STRING", primary_key=True),
"totalAmount": PropertySpec(type="DOUBLE"),
"status": PropertySpec(type="STRING",
enum=["PENDING","PAID","SHIPPED","COMPLETED"]),
},
),
ObjectType("OrderItem",
properties={
"itemId": PropertySpec(type="STRING", primary_key=True),
"quantity": PropertySpec(type="INTEGER"),
"unitPrice": PropertySpec(type="DOUBLE"),
},
),
]
# Rule 3: Relation direction (from N-side to 1-side)
relations = [
RelationType("PlacedBy", source="Order", target="Customer",
cardinality="MANY_TO_ONE"),
RelationType("ContainsItem", source="Order", target="OrderItem",
cardinality="ONE_TO_MANY"),
]
# Rule 6: Version management
proposal = schema.create_proposal(
title="Customer Loyalty System v1.1.0",
changes=[...],
)
#9. Common Anti-Pattern Checklist
| Anti-Pattern | Description | Fix |
|---|---|---|
| God Object | One ObjectType with 50+ properties | Split by responsibility into multiple ObjectTypes |
| Anemic Object | ObjectType has only properties, no ActionTypes | Bind business operations |
| Spaghetti Relations | Every pair of ObjectTypes has a relation | Review necessity, remove redundant relations |
| Interface Avoidance | Repeated properties scattered everywhere | Extract commonalities into InterfaceType |
| Metric Sprawl | Metrics grow without hierarchy or governance | Establish 4-level metric hierarchy |
| Schema Cowboy | Directly modifying production schema | Enforce Proposal workflow |
| Name Inconsistency | Same concept with multiple names | Establish and maintain naming dictionary |
| Over-Normalization | Over-splitting causes N+1 queries | Merge frequently co-queried properties |
#10. Modeling Review Checklist
Use this checklist in every Ontology modeling review:
Naming Convention (Rule 1)
[] All ObjectTypes use PascalCase
[] All properties use camelCase
[] No abbreviations, no prefixes, no redundant suffixes
[] Boolean properties start with is/has
[] Relation names express semantic direction
Granularity Control (Rule 2)
[] Each ObjectType maps to one independent business entity
[] No ObjectType has more than 25 properties
[] Nested value objects use StructType
[] No ObjectType with only 1-2 properties
Relation Direction (Rule 3)
[] 1:N relations go from N side to 1 side
[] No bidirectional redundant relations
[] Relation names follow "Verb+Noun" format
[] Self-referencing relations have clear semantics
Interface Extraction (Rule 4)
[] Property combinations appearing 3+ times extracted as interfaces
[] Audit properties unified into Auditable interface
[] Geo-location properties unified into Locatable interface
[] Interface names are adjectives (able/ible suffix)
Metric Design (Rule 5)
[] Metrics have clear business meaning and units
[] Metrics declare aggregation method and dimensions
[] Derived metrics declare dependencies
[] Key metrics have alert thresholds configured
Version Management (Rule 6)
[] All schema changes go through Proposal workflow
[] Incompatible changes have migration plans
[] Version numbers follow semantic versioning
[] Deprecated properties have clear removal schedules
#Key Takeaways
-
Naming conventions are not "minor details." In Ontology modeling, names are the API, the documentation, and the shared language of team communication. PascalCase ObjectTypes, camelCase properties, semantic relation names — these three rules reduce 50% of communication overhead.
-
Granularity is both "art" and "science." Use "independently operable business entity" as the judgment criterion and StructType for nested value objects, avoiding both God Objects and over-splitting.
-
Relation direction determines query efficiency. From "many" to "one," from "dependent" to "dependency" — these two principles multiply your Ontology graph query performance.
-
Interface extraction is the Ontology version of DRY. Universal interfaces like Auditable, Locatable, and Taggable not only reduce duplication but also enable powerful polymorphic query capabilities.
-
Metrics must be governed in layers. The 4-level hierarchy from atomic to strategic metrics ensures each role sees the data insights they care about.
-
Schema changes must go through the Proposal workflow. Compatibility checks, approval, canary release — this workflow is the last line of defense for production stability.
#Next Article
S4-15: Ontology Practice: E-Commerce Modeling — We will apply all 6 golden rules to a complete e-commerce scenario (Product/Order/User/Inventory/Logistics).
tags: ontology, best-practices, naming-convention, granularity, relation-direction, interface-extraction, metric-design, schema-versioning, coomia-dip