Model Classes in AD4M
Model classes in AD4M provide a way to define, store, and query structured data in your application. Let's start with a simple example that we'll build upon:
Quick Start
There are two ways to create model classes in AD4M:
Option 1: Using Decorators (Traditional)
import { Ad4mModel, ModelOptions, Property } from '@coasys/ad4m';
@ModelOptions({ name: "Recipe" })
class Recipe extends Ad4mModel {
@Property({
through: "recipe://name",
resolveLanguage: "literal"
})
name: string = "";
}
// Using the model
const recipe = new Recipe(perspective);
recipe.name = "Chocolate Cake";
await recipe.save();
// Reading it back
const recipes = await Recipe.findAll(perspective);
console.log(recipes[0].name); // "Chocolate Cake"Option 2: From JSON Schema (Dynamic)
Perfect for integrating with external systems or when you have existing JSON Schema definitions:
import { Ad4mModel } from '@coasys/ad4m';
// Define your data structure using JSON Schema
const recipeSchema = {
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Recipe",
"type": "object",
"properties": {
"name": { "type": "string" },
"ingredients": {
"type": "array",
"items": { "type": "string" }
},
"difficulty": { "type": "number" }
},
"required": ["name"]
};
// Dynamically create the model class
const Recipe = Ad4mModel.fromJSONSchema(recipeSchema, {
name: "Recipe",
namespace: "recipe://",
resolveLanguage: "literal"
});
// Use exactly like decorator-based models
const recipe = new Recipe(perspective);
recipe.name = "Chocolate Cake";
recipe.ingredients = ["flour", "sugar", "cocoa"];
recipe.difficulty = 3;
await recipe.save();Before using any model class, register it with your perspective:
await perspective.ensureSDNASubjectClass(Recipe);Basic Concepts
Properties
Properties are the basic building blocks of your models. They can be required or optional:
@ModelOptions({ name: "Recipe" })
class Recipe extends Ad4mModel {
// Required property
@Property({
through: "recipe://name",
resolveLanguage: "literal"
})
name: string = "";
// Optional property
@Optional({
through: "recipe://description",
resolveLanguage: "literal"
})
description?: string;
}Collections
Collections represent one-to-many relationships:
@ModelOptions({ name: "Recipe" })
class Recipe extends Ad4mModel {
@Property({
through: "recipe://name",
resolveLanguage: "literal"
})
name: string = "";
@Collection({ through: "recipe://ingredient" })
ingredients: string[] = [];
}
// Using collections
const recipe = new Recipe(perspective);
recipe.name = "Chocolate Cake";
recipe.ingredients = ["flour", "sugar", "cocoa"];
await recipe.save();Working with Models
Creating & Saving
// Create new instance
const recipe = new Recipe(perspective);
recipe.name = "Chocolate Cake";
await recipe.save();
// Create with specific ID
const recipe = new Recipe(perspective, "recipe://chocolate-cake");
await recipe.save();Reading & Updating
// Get by ID
const recipe = new Recipe(perspective, existingId);
await recipe.get();
// Update
recipe.name = "Dark Chocolate Cake";
await recipe.update();Basic Querying
// Get all recipes (uses SurrealDB by default - 10-100x faster)
const allRecipes = await Recipe.findAll(perspective);
// Get with simple conditions
const recipes = await Recipe.findAll(perspective, {
where: { name: "Chocolate Cake" }
});Query Performance with SurrealDB
Important: Ad4mModel now uses SurrealDB by default for all query operations, providing 10-100x faster performance compared to the legacy Prolog engine. This means:
findAll()automatically uses SurrealDBfindAllAndCount()automatically uses SurrealDB- Query builder methods automatically use SurrealDB
- Live query subscriptions automatically use SurrealDB
// Uses SurrealDB by default (fast!)
const highRated = await Recipe.findAll(perspective, {
where: { rating: { gt: 4 } },
order: { rating: "DESC" },
limit: 10
});
// You can explicitly use Prolog if needed (for backward compatibility)
const recipesProlog = await Recipe.findAll(perspective, {
where: { rating: { gt: 4 } }
}, false); // useSurrealDB = falseFor advanced graph traversal queries and direct SurrealQL access, see the SurrealDB Queries Guide.
Understanding the Underlying System
Now that you've seen the basics, let's understand how it works under the hood.
Graph-Based Storage
Each model instance is stored as a subgraph in an AD4M perspective. For example, our recipe is stored as:
recipe://chocolate-cake-123 -[recipe://name]-> literal://string:"Chocolate Cake"
recipe://chocolate-cake-123 -[recipe://ingredient]-> ingredient://flour
recipe://chocolate-cake-123 -[recipe://ingredient]-> ingredient://sugarEvery node in this graph is a URL pointing to an Expression in some language.
Base Expressions
Each model instance has a unique identifier called a "base expression" that serves as the root node of its subgraph. You can specify it or let AD4M generate one:
// Auto-generated base expression
const recipe = new Recipe(perspective);
// Custom base expression
const recipe = new Recipe(perspective, "recipe://chocolate-cake-2024-03");Literals for Simple Values
AD4M provides Literals for storing simple values without needing a full language:
// These are equivalent:
recipe.name = "Chocolate Cake";
// Stored as: literal://string:Chocolate Cake
recipe.cookingTime = 45;
// Stored as: literal://number:45Advanced Features
Property Types
AD4M provides several specialized property decorators:
@ReadOnly
For computed or immutable properties:
@ReadOnly({
through: "recipe://rating",
getter: `
findall(Rating, triple(Base, "recipe://user_rating", Rating), Ratings),
sum_list(Ratings, Sum),
length(Ratings, Count),
Value is Sum / Count
`
})
averageRating: number = 0;@Flag
For immutable type markers:
@Flag({
through: "ad4m://type",
value: "recipe://main-course"
})
type: string = "";Advanced Collections
Collections can be filtered and typed:
// Collection of other model instances
@Collection({
through: "recipe://comment",
where: { isInstance: Comment }
})
comments: Comment[] = [];
// Collection with custom filter
@Collection({
through: "recipe://review",
where: { condition: `triple(Target, "review://rating", Rating), Rating > 4` }
})
topReviews: string[] = [];Advanced Querying
The query builder provides a fluent interface for complex queries and uses SurrealDB by default for optimal performance:
// Uses SurrealDB by default (10-100x faster)
const recipes = await Recipe.query(perspective)
.where({
category: "MainCourse",
rating: { gt: 4 }
})
.order({ createdAt: "DESC" })
.limit(5)
.get();
// Explicitly control query engine
const recipesSurreal = await Recipe.query(perspective)
.where({ rating: { gt: 4 } })
.useSurrealDB(true) // Default is true
.get();
// Use Prolog for backward compatibility
const recipesProlog = await Recipe.query(perspective)
.where({ rating: { gt: 4 } })
.useSurrealDB(false) // Switch to Prolog
.get();Real-time Updates
Subscribe to changes in your data. Subscriptions also use SurrealDB by default for better performance:
// Create a query builder (uses SurrealDB by default)
const builder = Recipe.query(perspective)
.where({ status: "cooking" });
// Subscribe to updates
const initialRecipes = await builder.subscribe(recipes => {
console.log("Currently cooking:", recipes);
});
// Subscribe with Prolog (for backward compatibility)
const builderProlog = Recipe.query(perspective)
.where({ status: "cooking" })
.useSurrealDB(false); // Use Prolog
await builderProlog.subscribe(recipes => {
console.log("Currently cooking (Prolog):", recipes);
});
// Important: Clean up when done
builder.dispose();The query builder's subscription system:
- Returns initial results immediately
- Calls your callback whenever the results change
- Maintains an active connection to receive updates
- Automatically cleans up resources when disposed
Always call dispose() when you're done with the subscription to:
- Stop keepalive signals
- Unsubscribe from updates
- Notify the backend to clean up resources
- Prevent memory leaks
You can also subscribe to just the count of matching items:
const builder = Recipe.query(perspective)
.where({ category: "Dessert" });
const initialCount = await builder.countSubscribe(count => {
console.log("Number of desserts:", count);
});
// Remember to clean up
builder.dispose();Dynamic Models from JSON Schema
The fromJSONSchema() method allows you to dynamically create AD4M model classes from JSON Schema definitions. This is perfect for:
- Integrating with external systems that use JSON Schema
- Runtime model generation based on user-defined schemas
- Partner app integration (like Holons) that define types using JSON Schema
- Rapid prototyping without writing decorator-based classes
Basic Usage
import { Ad4mModel } from '@coasys/ad4m';
const schema = {
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "BlogPost",
"type": "object",
"properties": {
"title": { "type": "string" },
"content": { "type": "string" },
"tags": {
"type": "array",
"items": { "type": "string" }
},
"metadata": {
"type": "object",
"properties": {
"author": { "type": "string" },
"publishedAt": { "type": "string" }
}
}
},
"required": ["title"]
};
const BlogPost = Ad4mModel.fromJSONSchema(schema, {
name: "BlogPost",
namespace: "blog://",
resolveLanguage: "literal"
});
// Use like any other Ad4mModel
const post = new BlogPost(perspective);
post.title = "My First Post";
post.tags = ["ad4m", "tutorial"];
post.metadata = { author: "Alice", publishedAt: "2025-09-23" };
await post.save();Configuration Options
The second parameter to fromJSONSchema() is optional and accepts these options:
interface JSONSchemaToModelOptions {
// Model configuration
name?: string; // Class name override (optional: falls back to x-ad4m.className → schema.title → schema.$id)
namespace?: string; // Base namespace for predicates (optional: falls back to x-ad4m.namespace → schema.title → schema.$id)
// Predicate generation helpers
// 1) Direct mapping: propertyName -> predicate URI
propertyMapping?: Record<string, string>;
// 2) Template: supports ${scheme}, ${namespace} (or ${ns}), ${title}, ${property}
predicateTemplate?: string;
// 3) Generator callback: receives schema title and property name
predicateGenerator?: (title: string, property: string) => string;
// Global property settings (applied to all properties unless overridden)
resolveLanguage?: string; // Default language for all properties
local?: boolean; // Whether properties are stored locally
// Property-specific overrides
// Accepts any PropertyOptions fields: through, initial, required, writable,
// resolveLanguage, getter, setter, local, transform
propertyOptions?: Record<string, Partial<PropertyOptions>>;
}Class Name Resolution (Cascading Priority)
The class name is determined using this priority order:
- Explicit
options.name(highest priority) x-ad4m.classNamemetadata in schema- Schema
title - Schema
$id(extracted from URI) - Error if none found (lowest priority)
// Priority 1: Explicit name override
const Model1 = Ad4mModel.fromJSONSchema(schema, {
name: "CustomModelName"
});
// Priority 2: x-ad4m.className metadata
const schemaWithClassName = {
"title": "Person",
"x-ad4m": {
"className": "PersonModel"
},
"properties": { /* ... */ }
};
const PersonClass = Ad4mModel.fromJSONSchema(schemaWithClassName);
// Priority 3: Inferred from title
const schemaWithTitle = {
"title": "BlogPost", // becomes "BlogPost" class name
"properties": { /* ... */ }
};
const BlogPostClass = Ad4mModel.fromJSONSchema(schemaWithTitle);
// Priority 4: Inferred from $id
const schemaWithId = {
"$id": "https://example.com/schemas/product.schema.json", // becomes "Product"
"properties": { /* ... */ }
};
const ProductClass = Ad4mModel.fromJSONSchema(schemaWithId);Namespace Resolution (Cascading Priority)
The namespace is determined using this priority order:
- Explicit
options.namespace(highest priority) x-ad4m.namespacemetadata in schema- Schema
title(converted to namespace) - Schema
$id(extracted from URI) - Error if none found (lowest priority)
// Priority 1: Explicit namespace
const Model1 = Ad4mModel.fromJSONSchema(schema, {
namespace: "custom://"
});
// Priority 2: x-ad4m.namespace metadata
const schemaWithMetadata = {
"title": "Person",
"x-ad4m": {
"namespace": "person://"
},
"properties": { /* ... */ }
};
// Priority 3: Inferred from title
const schemaWithTitle = {
"title": "BlogPost", // becomes "blogpost://"
"properties": { /* ... */ }
};Property Predicate Resolution
Each property's predicate URI is determined by (highest to lowest precedence):
options.propertyMapping[name](explicit mapping)x-ad4m.throughin property schemapredicateTemplate(supports${scheme},${namespace}/${ns},${title},${property})predicateGenerator(title, property)- Default:
${namespace}/${propertyName}(normalized)
const schema = {
"title": "Product",
"properties": {
"name": { "type": "string" },
"price": {
"type": "number",
"x-ad4m": {
"through": "product://cost" // Custom predicate
}
}
}
};
const Product = Ad4mModel.fromJSONSchema(schema, {
name: "Product",
namespace: "product://",
// Option A: direct mapping
propertyMapping: {
name: "product://title"
}
// Option B: or per-property options
// propertyOptions: { name: { through: "product://title" } }
});Advanced Schema Features
Using x-ad4m Extensions
Add AD4M-specific metadata directly to your JSON Schema:
const advancedSchema = {
"title": "User",
"x-ad4m": {
"namespace": "user://",
"resolveLanguage": "literal"
},
"properties": {
"email": {
"type": "string",
"x-ad4m": {
"through": "user://email_address",
"local": true
}
},
"profile": {
"type": "object",
"x-ad4m": {
"resolveLanguage": "literal" // Store as JSON
}
},
"tags": {
"type": "array",
"items": { "type": "string" },
"x-ad4m": {
"through": "user://interests"
}
}
},
"required": ["email"]
};
const User = Ad4mModel.fromJSONSchema(advancedSchema, { name: "User" });Complex Object Handling
Object properties are automatically stored as JSON literals:
const schema = {
"title": "Article",
"properties": {
"metadata": {
"type": "object",
"properties": {
"author": { "type": "string" },
"publishedAt": { "type": "string" },
"views": { "type": "number" }
}
}
},
"required": ["metadata"]
};
const Article = Ad4mModel.fromJSONSchema(schema, {
namespace: "article://"
});
const article = new Article(perspective);
article.metadata = {
author: "Alice",
publishedAt: "2025-09-23",
views: 42
};
await article.save();
// Complex objects are preserved
const saved = await Article.findAll(perspective);
console.log(saved[0].metadata.author); // "Alice"Array/Collection Mapping
Arrays in JSON Schema automatically become AD4M collections:
const schema = {
"title": "BlogPost",
"properties": {
"tags": {
"type": "array",
"items": { "type": "string" }
},
"categories": {
"type": "array",
"items": { "type": "string" }
}
}
};
const BlogPost = Ad4mModel.fromJSONSchema(schema, {
namespace: "blog://"
});
const post = new BlogPost(perspective);
post.tags = ["ad4m", "tutorial"];
post.categories = ["technology"];
await post.save();
// Collections work with all standard AD4M collection methods
post.addTags("blockchain");
post.removeCategories("technology");Type Safety Considerations
Since properties are added dynamically, TypeScript won't know about them at compile time. You can:
- Use type assertions for better IDE support:
const BlogPost = Ad4mModel.fromJSONSchema(schema) as typeof Ad4mModel & {
new(perspective: PerspectiveProxy): Ad4mModel & {
title: string;
tags: string[];
metadata: { author: string; publishedAt: string };
}
};- Create TypeScript interfaces for your schemas:
interface BlogPostData {
title: string;
tags: string[];
metadata: { author: string; publishedAt: string };
}
const post = new BlogPost(perspective) as Ad4mModel & BlogPostData;
post.title = "Type-safe access";Limitations and Best Practices
- No
authorproperty: JSON schemas cannot define a top-levelauthorproperty (conflicts with built-in AD4M property) - Required properties: At least one property must be required or have an initial value
- Complex objects: Nested objects are stored as JSON literals, limiting semantic querying capabilities
- Performance: For better semantic querying, consider flattening complex objects into separate properties
Under the Hood
Social DNA and Prolog
When you define a model class, AD4M generates a Social DNA (SDNA) representation in Prolog:
@ModelOptions({ name: "Recipe" })
class Recipe extends Ad4mModel {
@Property({
through: "recipe://name",
resolveLanguage: "literal",
required: true
})
name: string = "";
}Relationship with SHACL and Linked Data
AD4M's model classes share concepts with SHACL (Shapes Constraint Language):
- Both define property constraints and validation rules
- Both work with graph-based data
- AD4M adds TypeScript integration, CRUD operations, and real-time features
Best Practices
-
Use Meaningful Predicates: Structure your predicate URIs logically:
through: "recipe://name" // Good through: "myapp://x" // Bad -
Type Safety: Always define proper TypeScript types:
@Property({ through: "recipe://servings" }) servings: number = 0; // Good @Property({ through: "recipe://servings" }) servings: any; // Bad -
Collection Filtering: Use
whereconditions to filter collections:@Collection({ through: "recipe://review", where: { isInstance: Review } // Only include Review instances }) reviews: Review[] = []; -
Query Optimization: Use specific queries instead of filtering in memory, and leverage SurrealDB's performance:
// Good - Let SurrealDB do the filtering (fast) const topRated = await Recipe.query(perspective) .where({ rating: { gt: 4 } }) .get(); // Bad - Fetching all and filtering in memory (slow) const all = await Recipe.findAll(perspective); const topRated = all.filter(r => r.rating > 4); -
Use SurrealDB by Default: SurrealDB is 10-100x faster than Prolog for most queries:
// Good - Uses SurrealDB by default const recipes = await Recipe.findAll(perspective, { where: { rating: { gt: 4 } } }); // Only use Prolog if you need backward compatibility const recipesProlog = await Recipe.findAll(perspective, { where: { rating: { gt: 4 } } }, false); // useSurrealDB = false -
Subscriptions: Clean up subscriptions when they're no longer needed:
const builder = Recipe.query(perspective) .where({ status: "active" }); await builder.subscribe(recipes => { // Handle updates }); // Later... builder.dispose(); -
Use Literals Appropriately:
// Good - Using literals for simple values @Property({ through: "recipe://name", resolveLanguage: "literal" }) name: string = ""; // Good - Using specific language for rich content @Property({ through: "recipe://instructions", resolveLanguage: "markdown" }) instructions: string = ""; -
Meaningful Base Expressions:
// Good - Descriptive base expression const recipe = new Recipe(perspective, "recipe://chocolate-cake-2024-03"); // Bad - Random or meaningless base expression const recipe = new Recipe(perspective, "x://123");
Error Handling
Always handle potential errors when working with models:
try {
const recipe = new Recipe(perspective);
recipe.name = "Failed Recipe";
await recipe.save();
} catch (error) {
console.error("Failed to save recipe:", error);
// Handle error appropriately
}Integration with AD4M Perspectives
Before using a model class, register it with the perspective:
// Register the model class
await perspective.ensureSDNASubjectClass(Recipe);
// Now you can use it
const recipe = new Recipe(perspective);
await recipe.save();This ensures the perspective knows how to handle instances of your model class.