Metadata-Driven Development
Understanding how structured metadata becomes APIs, UI, workflows, and AI-agent tools
What is Metadata-Driven Development?
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.
The Problem with Code-First
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.
Example: Adding a "Phone Number" Field
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.
The Metadata-Driven Solution
ObjectStack centralizes the "Intent" into a single TypeScript-authored, Zod-validated Protocol Definition. The implementation layers act as Runtime Engines that interpret this protocol.
Example: The ObjectStack Way
// ONE definition (in objectstack.config.ts)
import { ObjectSchema, Field } from '@objectstack/spec/data';
export const User = ObjectSchema.create({
name: 'user',
sharingModel: 'private',
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
The Three Truths
In metadata-driven development, we embrace three core truths:
1. The UI is a Projection
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' }2. The API is a Consequence
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/:id3. The Schema is the Application
Traditional: 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.
4. The Tool Surface is Generated
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.
Benefits of Metadata-Driven
1. Single Source of Truth
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 updates2. Type Safety by Default
All 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);3. Technology Agnostic
Because logic is declarative, you can swap implementations:
Same Metadata Definition
↓
┌─────────┴─────────┐
│ │
PostgreSQL MongoDB
Node.js Python
React Flutter4. Small Enough to Fit in an Agent's Context
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 bundled example CRM
(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 example app — 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',
sharingModel: 'private',
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.
5. Agent-Ready by Construction
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.
Real-World Analogy
Think of metadata-driven development like HTML vs Canvas:
HTML (Declarative)
<h1>Hello World</h1>You describe what you want. The browser handles how to render it.
Canvas (Imperative)
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.
When to Use Metadata-Driven
✅ 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
Summary
| 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 |
Object Definition Rules
When defining objects and metadata in ObjectStack, follow these strict rules and principles:
1. Always Use ObjectSchema.create() with Field.* Helpers
✅ Correct:
import { ObjectSchema, Field } from '@objectstack/spec/data';
export const Account = ObjectSchema.create({
name: 'account',
sharingModel: 'private',
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
2. Naming Conventions
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, lookupFilters |
Example:
// ✅ Correct naming
export const TodoTask = ObjectSchema.create({
name: 'todo_task', // snake_case machine name
sharingModel: 'private', // OWD — required on custom objects
label: 'Todo Task',
fields: {
due_date: Field.date({ // snake_case field name
label: 'Due Date',
defaultValue: null, // camelCase config key
}),
},
});3. Prefer Explicit Label/Value Objects for Select Options
✅ 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).
4. Lookup Fields Must Specify Target Object
✅ Correct:
owner: Field.lookup('sys_user', {
label: 'Owner',
required: true,
}),❌ Incorrect:
owner: Field.lookup({
object: 'user', // Wrong property name
}),5. Always Include Descriptive Labels
✅ 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
}),6. Use Enable Flags for Object Capabilities
export const Account = ObjectSchema.create({
name: 'account',
sharingModel: 'private',
label: 'Account',
fields: { /* ... */ },
enable: {
trackHistory: true, // Enable field history tracking
searchable: true, // Include in global search
apiEnabled: true, // Expose object via automatic APIs (REST)
files: true, // Enable file attachments
feeds: true, // Enable activity feed
activities: true, // Enable tasks and events
},
});Quick Reference
import { ObjectSchema, Field } from '@objectstack/spec/data';
export const ExampleObject = ObjectSchema.create({
name: 'example_object', // Required: snake_case
sharingModel: 'private', // Required on custom objects: the OWD baseline
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,
},
});Next Steps
- 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