| title | Metadata-Driven Development |
|---|---|
| description | Understanding how structured metadata becomes APIs, UI, workflows, and AI-agent tools |
import { Database, Code, Link, Laptop } from 'lucide-react';
Metadata-driven development is a paradigm shift where business intent is defined by declarative, analyzable metadata instead of scattered imperative code.
This is also what makes ObjectStack **AI-native**: because the whole app is a few hundred lines of typed, analyzable metadata — not tens of thousands of lines of glue — it fits in an agent's context window. Claude Code authors it, `os validate` catches mistakes, and you verify in the Console. See [How AI Development Works](/docs/getting-started/how-ai-development-works).In traditional development, the "Intent" (e.g., "This field is a required email address") is scattered across multiple layers:
- Database: SQL constraints (
NOT NULL,CHECK) - Backend: ORM validation (TypeORM decorators, Prisma schemas)
- Frontend: UI validation (React Hook Form + Zod)
- Documentation: API specs (OpenAPI/Swagger)
- AI Tools: MCP/tool definitions, function-call schemas, prompts, and guardrails
When a business requirement changes, you must update code in three, four, or five places. When AI agents enter the system, they often receive yet another hand-written tool layer that can drift from the real app. This is Implementation Coupling.
Traditional Approach:
-- 1. Database migration
ALTER TABLE users ADD COLUMN phone VARCHAR(20);// 2. Backend model
class User {
@Column()
@IsPhoneNumber()
phone: string;
}// 3. Frontend validation
const schema = z.object({
phone: z.string().regex(/^\+?[1-9]\d{1,14}$/),
});# 4. API documentation
components:
schemas:
User:
properties:
phone:
type: string
pattern: '^\+?[1-9]\d{1,14}$'4 files to change. 4 places to keep in sync. 4 opportunities for bugs.
ObjectStack centralizes the "Intent" into a single TypeScript-authored, Zod-validated Protocol Definition. The implementation layers act as Runtime Engines that interpret this protocol.
// ONE definition (in objectstack.config.ts)
import { ObjectSchema, Field } from '@objectstack/spec/data';
export const User = ObjectSchema.create({
name: 'user',
label: 'User',
icon: 'user',
fields: {
phone: Field.phone({
label: 'Phone Number',
required: true,
}),
},
});📘 Syntax Rules: Always use
ObjectSchema.create()withField.*helpers for strict TypeScript validation and runtime checking. See Object Definition Rules below.
From this single definition, ObjectStack automatically:
✅ Generates database schema
✅ Creates validation rules
✅ Builds CRUD APIs
✅ Renders form fields
✅ Produces API documentation
✅ Exposes agent-ready tool metadata
In metadata-driven development, we embrace three core truths:
Traditional: Build a form component manually
ObjectStack: The form is a projection of the schema
The UI doesn't "build" a form; it projects the Object schema into visual components.
// Conceptual: the schema IS the form. The runtime renders a view
// definition (a FormView metadata object) — there is no FormView
// React component to import; you declare the view, not the JSX.
const taskForm = { type: 'form', object: 'task' }Traditional: Write REST endpoints by hand
ObjectStack: APIs are generated from the schema
You don't write controllers or routes. ObjectStack generates the entire API graph based on your Object definitions and permission rules.
# Automatically available after defining the object:
GET /api/v1/data/task
POST /api/v1/data/task
GET /api/v1/data/task/:id
PATCH /api/v1/data/task/:id
DELETE /api/v1/data/task/:idTraditional: The "application" is code scattered across many files
ObjectStack: The "application" is a collection of metadata files
Your entire business logic lives in:
- Object definitions (
.object.ts) - View configurations (
.view.ts) - Automation flows (
.flow.ts) and lifecycle hooks (.hook.ts)
The Kernel simply interprets these definitions.
Traditional: Every AI tool is manually described and wired to custom queries
ObjectStack: Agent tools are generated from the same object, permission, and workflow metadata that powers the app
An agent does not need to guess which query is safe to call. It can inspect the schema, available actions, field constraints, and permission boundaries exposed by the runtime.
Change the metadata once, everything updates automatically.
// Change this:
phone: Field.phone({ required: true })
// To this:
phone: Field.phone({ required: false })
// ✅ Database constraint updates
// ✅ API validation updates
// ✅ UI form updates
// ✅ Documentation updatesAll metadata is defined with Zod schemas:
// Source: Zod schema
const FieldSchema = z.object({
name: z.string(),
type: z.enum(['text', 'number', 'date']),
});
// Derived: TypeScript type
type Field = z.infer<typeof FieldSchema>;
// Derived: JSON Schema (for IDE autocomplete)
const jsonSchema = z.toJSONSchema(FieldSchema);Because logic is declarative, you can swap implementations:
Same Metadata Definition
↓
┌─────────┴─────────┐
│ │
PostgreSQL MongoDB
Node.js Python
React Flutter
A simple CRUD feature that takes ~300 lines of hand-written code is ~30 lines of
metadata. But per-feature ratios are the least interesting part. Here is the
measurement that matters — the CRM bundled in this repo
(examples/app-crm:
six objects, views, a dashboard, a lead-conversion flow, permission sets,
actions, translations):
| Files | 31 |
| Lines | 1,792 |
| Approximate tokens | ~16,000 |
That's the whole business system — about 8% of a 200k-token context window. Count it yourself:
find examples/app-crm/src -name '*.ts' -not -name '*.test.ts' | xargs cat | wc -lThe point isn't lines of code. The point is fit in an agent's context window. When the entire business system is small, typed, and declarative, an AI agent can load it end-to-end, reason about every dependency, and safely refactor across data, API, UI, and permissions in a single change — it can answer "what breaks if I change this?" instead of grepping and hoping. That turns AI from an autocomplete tool into a real co-maintainer.
// All you need:
import { ObjectSchema, Field } from '@objectstack/spec/data';
export const Task = ObjectSchema.create({
name: 'task',
label: 'Task',
icon: 'check-square',
fields: {
title: Field.text({
label: 'Title',
required: true,
}),
status: Field.select({
label: 'Status',
options: [
{ label: 'To Do', value: 'todo', default: true },
{ label: 'In Progress', value: 'in_progress' },
{ label: 'Done', value: 'done' },
],
}),
assignee: Field.lookup('sys_user', {
label: 'Assignee',
}),
},
});
// That's it. Full CRUD, REST endpoints, typed SDK, MCP tools,
// validation, and permission scaffolding are ready.📘 Note: Built-in platform objects carry system prefixes — the standard user object is
sys_user, notuser. UseField.lookup('sys_user', ...)to reference it. Names liketaskoraccountin these examples are illustrative custom objects.
Because objects, fields, relationships, permissions, and workflows are explicit, the runtime can expose a bounded tool surface for AI agents:
- Tools map to real business objects and actions.
- Inputs inherit field validation and type information.
- Permissions still apply through RBAC, RLS, and FLS.
- Executions can be audited against the same metadata artifact.
Think of metadata-driven development like HTML vs Canvas:
<h1>Hello World</h1>You describe what you want. The browser handles how to render it.
ctx.font = '32px Arial';
ctx.fillText('Hello World', 10, 50);You specify exactly how to draw each pixel.
ObjectStack is the "HTML" of AI-native business applications.
✅ Use metadata-driven when:
- Building CRUD-heavy applications
- Need rapid prototyping and iteration
- Want database flexibility (may change backends)
- Building multi-tenant SaaS platforms
- Require strict type safety and validation
- Need AI agents to safely inspect and operate business data
❌ Don't use metadata-driven when:
- Building highly custom, pixel-perfect UIs
- Need real-time 3D graphics or games
- The problem domain is too unique for abstraction
- Performance requires hand-optimized algorithms
| Aspect | Traditional | Metadata-Driven |
|---|---|---|
| Definition | Code in multiple files | Single metadata definition |
| Changes | Update 3-4 places | Update once |
| Type Safety | Manual synchronization | Automatic from Zod |
| Flexibility | Locked to tech stack | Technology agnostic |
| Boilerplate | High (300+ lines) | Low (30 lines) |
| Agent Tools | Hand-written and drift-prone | Generated from metadata and permissions |
When defining objects and metadata in ObjectStack, follow these strict rules and principles:
✅ Correct:
import { ObjectSchema, Field } from '@objectstack/spec/data';
export const Account = ObjectSchema.create({
name: 'account',
label: 'Account',
fields: {
name: Field.text({ required: true }),
industry: Field.select({
options: [
{ label: 'Technology', value: 'technology' },
{ label: 'Finance', value: 'finance' },
],
}),
},
});❌ Deprecated:
// Old pattern - no runtime validation
const Account: ServiceObject = {
name: 'account',
fields: {
name: { type: 'text', required: true }
}
};Why?
- ✅ Type Safety: Compile-time type checking via
z.input<typeof ObjectSchemaBase> - ✅ Runtime Validation: Zod validates structure at runtime
- ✅ IDE Autocomplete: Field helpers provide intelligent code completion
- ✅ Error Prevention: Catches typos and invalid configurations immediately
Follow these strict naming conventions for consistency:
| Element | Convention | Examples |
|---|---|---|
| Object Names (machine names) | snake_case |
todo_task, project_milestone, user_profile |
| Field Names (machine names) | snake_case |
first_name, annual_revenue, is_active |
| Constant Names (exports) | PascalCase |
TodoTask, ProjectMilestone, UserProfile |
| Configuration Keys (props) | camelCase |
maxLength, defaultValue, referenceFilters |
Example:
// ✅ Correct naming
export const TodoTask = ObjectSchema.create({
name: 'todo_task', // snake_case machine name
label: 'Todo Task',
fields: {
due_date: Field.date({ // snake_case field name
label: 'Due Date',
defaultValue: null, // camelCase config key
}),
},
});✅ Recommended:
status: Field.select({
label: 'Status',
options: [
{ label: 'Open', value: 'open', default: true },
{ label: 'In Progress', value: 'in_progress' },
{ label: 'Closed', value: 'closed' },
],
}),✅ Also valid (auto-normalized):
status: Field.select({
options: ['Open', 'In Progress', 'Closed'],
}),
// Helper converts each string to a lowercase snake_case value:
// [{ label: 'Open', value: 'open' }, { label: 'In Progress', value: 'in_progress' }, ...]Why? Option values are machine identifiers stored in the database, so they must be lowercase to avoid case-sensitivity issues in queries. Field.select() accepts plain string arrays and auto-normalizes them to lowercase snake_case { label, value } pairs for you. Use explicit { label, value } objects when the display label must differ from the stored value (or to set default: true).
✅ Correct:
owner: Field.lookup('sys_user', {
label: 'Owner',
required: true,
}),❌ Incorrect:
owner: Field.lookup({
object: 'user', // Wrong property name
}),✅ Correct:
annual_revenue: Field.currency({
label: 'Annual Revenue',
scale: 2,
min: 0,
}),❌ Avoid:
annual_revenue: Field.currency({
// Missing label - field name will be used as fallback
}),export const Account = ObjectSchema.create({
name: 'account',
label: 'Account',
fields: { /* ... */ },
enable: {
trackHistory: true, // Enable field history tracking
searchable: true, // Include in global search
apiEnabled: true, // Expose via REST/GraphQL
files: true, // Enable file attachments
feeds: true, // Enable activity feed
activities: true, // Enable tasks and events
trash: true, // Enable soft delete
mru: true, // Track Most Recently Used
},
});import { ObjectSchema, Field } from '@objectstack/spec/data';
export const ExampleObject = ObjectSchema.create({
name: 'example_object', // Required: snake_case
label: 'Example Object', // Required: Human-readable
pluralLabel: 'Example Objects', // Optional
icon: 'box', // Optional: Lucide icon name
description: 'Description text', // Optional
fields: {
// Text field
text_field: Field.text({
label: 'Text Field',
required: true,
maxLength: 255,
}),
// Number field
number_field: Field.number({
label: 'Number',
min: 0,
max: 100,
}),
// Currency field
price: Field.currency({
label: 'Price',
scale: 2,
min: 0,
}),
// Select field
status: Field.select({
label: 'Status',
options: [
{ label: 'Active', value: 'active', default: true },
{ label: 'Inactive', value: 'inactive' },
],
}),
// Lookup field
owner: Field.lookup('sys_user', {
label: 'Owner',
required: true,
}),
// Boolean field
is_active: Field.boolean({
label: 'Active',
defaultValue: true,
}),
// Date field
due_date: Field.date({
label: 'Due Date',
}),
},
enable: {
trackHistory: true,
apiEnabled: true,
},
});- Metadata Lifecycle & HMR - How writes propagate through the Repository / Change Log / Cache / Registry
- Architecture - How the three protocols work together
- Core Concepts - Deep dive into the kernel, services, and object model
- ObjectQL Protocol - Learn the data protocol specification