Skip to content

Latest commit

 

History

History
552 lines (432 loc) · 15.9 KB

File metadata and controls

552 lines (432 loc) · 15.9 KB
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';

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](/docs/getting-started/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:

  1. Database: SQL constraints (NOT NULL, CHECK)
  2. Backend: ORM validation (TypeORM decorators, Prisma schemas)
  3. Frontend: UI validation (React Hook Form + Zod)
  4. Documentation: API specs (OpenAPI/Swagger)
  5. 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',
  label: 'User',
  icon: 'user',
  
  fields: {
    phone: Field.phone({
      label: 'Phone Number',
      required: true,
    }),
  },
});

📘 Syntax Rules: Always use ObjectSchema.create() with Field.* 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/:id

3. 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 updates

2. 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           Flutter

4. 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 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 -l

The 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, not user. Use Field.lookup('sys_user', ...) to reference it. Names like task or account in 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',
  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, 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
    }),
  },
});

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',
  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
  },
});

Quick Reference

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,
  },
});

Next Steps