Skip to main content

Schema-Driven Development

The Power of Schema-First Design

Schema-driven development puts your API contract at the center of your development process. Instead of writing code first and documenting later, you define your API structure upfront and generate everything else from that single source of truth.

This approach provides several key benefits:

  • Contract-first development: Client and server teams can work in parallel
  • Type safety: Compile-time guarantees across the entire stack
  • Documentation: Always up-to-date API documentation
  • Validation: Automatic request/response validation
  • Consistency: Uniform data structures across all protocols

JSON Schema Fundamentals

Hypermodern uses an extended JSON schema format that's both human-readable and machine-processable.

Basic Schema Structure

{
  "models": {
    // Data model definitions
  },
  "services"endpoints": {
    // Service-grouped endpoint definitions
  },
  "enums": {
    // Enumeration definitions
  },
  "config": {
    // Schema configuration
  }
}

Service-Based Organization

Hypermodern organizes endpoints into logical services for better code generation and maintainability:

{
  "services"endpoints": {
    "user": {
      "$meta": {
        "description": "User management service"
      },
      "create_user": {
        "$meta": {
          "method": "POST",
        "path": "/users",
        "protocols"request": "@create_user_request",
        "response": "@user",
        "transports": ["http", "ws", "tcp"]
      },
      "request": "@create_user_request",
        "response": "@user"
      },
      "get_user": {
        "$meta": {
        "method": "GET", 
        "path": "/users/{id}"
        },
        "request": "@get_user_request",
        "response": "@user",
        "transports": ["http", "ws", "tcp"]
      },
      "update_user": {
        "$meta": {
        "method": "PUT",
        "path": "/users/{id}"
        },
        "request": "@update_user_request",
        "response": "@user",
        "transports": ["http", "ws", "tcp"]
      }
    },
    "post": {
      "$meta": {
        "description": "Blog post management service"
      },
      "create_post": {
        "$meta": {
          "method": "POST",
        "path": "/posts"
        },
        "request": "@create_post_request",
        "response": "@post",
        "transports": ["http", "ws", "tcp"]
      },
      "get_post": {
        "$meta": {
        "method": "GET",
        "path": "/posts/{id}"
        },
        "request": "@get_post_request",
        "response": "@post",
        "transports": ["http", "ws", "tcp"]
      }
    }
  }
}

Benefits of Service Organization

  1. Better Generated Code: Creates logical service classes (UserService, PostService)
  2. Cleaner Organization: Related endpoints grouped together
  3. Unified Metadata: Single $meta block for all metadata
  4. Self-Documenting: Service descriptions and endpoint documentation in one place

Model Definitions

Models define the structure of your data:

{
  "models": {
    "user": {
      "id": "int64",
      "username": "string",
      "email": "string",
      "status": "@user_status",
      "profile": "@user_profile?",
      "created_at": "datetime",
      "updated_at": "datetime"
    },
    "user_profile": {
      "first_name": "string?",
      "last_name": "string?",
      "bio": "string?",
      "avatar_url": "string?",
      "social_links": "<string, string>",
      "preferences": "@user_preferences"
    },
    "user_preferences": {
      "theme": "string",
      "notifications_enabled": "bool",
      "language": "string"
    }
  }
}

UnifiedAdding Metadata with $meta

Hypermodern uses unified metadata blocks for cleaner, more consistent schema structure:

{
  "models": {
    "user": {
      "$meta": {
        "table_name": "users",
        "fillable": ["username", "email"],
        "timestamps": true,
        "description": "User account information"
      },
      "username": "string",
      "email": "string",
      "created_at": "datetime",
      "updated_at": "datetime"
    }
  }
}

Benefits of Unified Metadata

  1. Single Source: All metadata in one $meta block
  2. Cleaner Structure: Clear separation between metadata and data fields
  3. Consistent Approach: Same pattern for models, services, and endpoints
  4. Self-Documenting: Description and configuration in one place

Type System Reference

Primitive Types:

{
  "string_field": "string",
  "integer_field": "int32",
  "long_field": "int64", 
  "float_field": "float32",
  "double_field": "float64",
  "boolean_field": "bool",
  "timestamp_field": "datetime",
  "binary_field": "bytes",
  "dynamic_field": "any"
}

Optional Types:

{
  "required_field": "string",
  "optional_field": "string?",
  "nullable_reference": "@user?"
}

Collections:

{
  "string_array": "[string]",           // List<String>
  "user_array": "[@user]",              // List<User>
  "nested_array": "[[string]]",         // List<List<String>>
  "string_set": "{string}",             // Set<String>
  "permission_set": "{@permission}",    // Set<Permission>
  "string_map": "<string, string>",     // Map<String, String>
  "user_map": "<string, @user>",        // Map<String, User>
  "mixed_map": "<string, any>"          // Map<String, dynamic>
}

Model References:

{
  "user_reference": "@user",
  "optional_user": "@user?",
  "user_list": "[@user]"
}

Advanced Schema Features

Enumerations

Define constrained string values:

{
  "enums": {
    "user_status": ["active", "inactive", "suspended", "pending"],
    "order_status": ["pending", "processing", "shipped", "delivered", "cancelled"],
    "priority_level": ["low", "medium", "high", "critical"]
  },
  "models": {
    "user": {
      "id": "int64",
      "status": "@user_status"
    },
    "task": {
      "id": "int64",
      "priority": "@priority_level"
    }
  }
}

Generated enum classes provide type safety:

enum UserStatus {
  active,
  inactive,
  suspended,
  pending;
  
  static UserStatus fromString(String value) {
    switch (value) {
      case 'active': return UserStatus.active;
      case 'inactive': return UserStatus.inactive;
      case 'suspended': return UserStatus.suspended;
      case 'pending': return UserStatus.pending;
      default: throw ArgumentError('Invalid UserStatus: $value');
    }
  }
  
  String toString() {
    switch (this) {
      case UserStatus.active: return 'active';
      case UserStatus.inactive: return 'inactive';
      case UserStatus.suspended: return 'suspended';
      case UserStatus.pending: return 'pending';
    }
  }
}

Model Metadata and Validation Constraints

AddModels use the $meta block for database configuration, and validation rulesis defined directly in yourfield schema:definitions:

{
  "models": {
    "user": {
      "$validation"meta": {
        "description": "User account information",
        "extends": "@base_entity",
        "table_name": "users",
        "indexes": [
          {"fields": ["email"], "unique": true},
          {"fields": ["username"], "unique": true}
        ]
      },
      "username": {
        "type": "string",
        "min_length": 3,
        "max_length": 20,
        "pattern": "^[a-zA-Z0-9_]+$"
      },
      "email": {
        "type": "string",
        "format": "email"
      },
      "age"display_name": {
          "minimum": 13,
          string?"maximum": 120
        },
      "tags": {
          "max_items": 10,
          "unique_items": true
        }
      },
      "username"image_url": "string",
      "email": "string",
      "age": "int32",
      "tags": "[string]string?"
    }
  }
}

Generated validation:

class User {
  final String username;
  final String email;
  final int age;
  final List<String> tags;
  
  User({
    required this.username,
    required this.email,
    required this.age,
    required this.tags,
  }) {
    _validate();
  }
  
  void _validate() {
    if (username.length < 3 || username.length > 20) {
      throw ValidationException('Username must be 3-20 characters');
    }
    if (!RegExp(r'^[a-zA-Z0-9_]+$').hasMatch(username)) {
      throw ValidationException('Username contains invalid characters');
    }
    if (!_isValidEmail(email)) {
      throw ValidationException('Invalid email format');
    }
    if (age < 13 || age > 120) {
      throw ValidationException('Age must be between 13 and 120');
    }
    if (tags.length > 10) {
      throw ValidationException('Maximum 10 tags allowed');
    }
    if (tags.toSet().length != tags.length) {
      throw ValidationException('Tags must be unique');
    }
  }
}

Inheritance and Composition

Model inheritance for shared fields:fields using $meta:

{
  "models": {
    "base_entity": {
      "$annotations"meta": {
        "description": "Base entity with common fields",
        "primary_key": {
          "field": "id",
          "type": "int64"
        }
      },
      "id": "int64",
      "created_at": "datetime",
      "updated_at": "datetime"
    },
    "user": {
      "$meta": {
        "description": "User account information",
        "extends": "@base_entity",
      "$annotations": {
        "table_name": "users"
      },
      "username": "string",
      "email": "string"
    },
    "post": {
      "$meta": {
        "description": "Blog post content",
        "extends": "@base_entity",
      "$annotations": {
        "table_name": "posts"
      },
      "title": "string",
      "content": "string",
      "author_id": "int64"
    }
  }
}

Composition with embedded models:

{
  "models": {
    "address": {
      "street": "string",
      "city": "string",
      "state": "string",
      "zip_code": "string",
      "country": "string"
    },
    "user": {
      "id": "int64",
      "name": "string",
      "billing_address": "@address",
      "shipping_address": "@address?"
    }
  }
}

Complete Model Configuration

Here's a comprehensive example showing all model instruction features:

{
  "models": {
    "base_entity": {
      "$annotations"meta": {
        "description": "Base entity with UUID primary key",
        "primary_key": {
          "field": "id",
          "type": "uuid_v7",
          "default": "generate_uuid_v7()"
        },
        "indexes": [
          {"fields": ["created_at"]}
        ],
        "requires_extensions": ["uuid-ossp", "pgcrypto"]
      },
      "id": "uuid_v7",
      "created_at": "datetime",
      "updated_at": "datetime"
    },
    "user": {
      "$meta": {
        "description": "User account with full configuration",
        "extends": "@base_entity",
      "$annotations": {
        "table_name": "users",
        "indexes": [
          {"fields": ["email"], "unique": true},
          {"fields": ["username"], "unique": true}
        ]
      },
      "$config": {
        "fillable": ["username", "email", "profile"],
        "hidden": ["password_hash", "api_token"],
        "timestamps": true,
        "soft_deletes": true
      },
      "$validation": {
        "username": {
          "min_length": 3,
          "max_length": 20,
          "pattern": "^[a-zA-Z0-9_]+$"
        },
        "email": {
          "format": "email"
        }true,

      },
      "username": "string",
      "email": "string",
      "password_hash": "string",
      "api_token": "string?",
      "profile": "@user_profile?",
      "tags": "{string}",
      "metadata": "<string, string>"
    }
  }
}

Model InstructionsMetadata Summary:Features:

  • $extends: Inherit fields from another model
  • $annotationstable_name: Database metadata (table names, indexes, constraints)name
  • $configindexes: ORMDatabase behaviorindexes (fillableand fields, hidden fields, timestamps)constraints
  • fillable: Fields that can be mass-assigned
  • hidden: Fields to hide in serialization
  • timestamps: Enable automatic timestamp fields
  • soft_deletes: Enable soft delete functionality

Field-Level Validation

Validation rules are defined directly in field definitions when using object format:

{
  "models": {
    "user_registration": {
      "$validationmeta": {
        "description": "User registration with validation"
      },
      "username": {
        "type": "string",
        "min_length": 3,
        "max_length": 20,
        "pattern": "^[a-zA-Z0-9_]+$"
      },
      "email": {
        "type": "string",
        "format": "email"
      },
      "age": {
        "type": "int32",
        "minimum": 13,
        "maximum": 120
      },
      "tags": {
        "type": "[string]",
        "max_items": 10,
        "unique_items": true
      },
      "preferences": {
        "type": "<string, string>",
        "max_properties": 20
      }
    }
  }
}

Available Validation Rules:

String Validations:

  • min_length: Minimum string length
  • max_length: Maximum string length
  • pattern: Regular expression pattern
  • format: Predefined format (email, url, etc.)

Numeric Validations:

  • minimum: Minimum value
  • maximum: Maximum value
  • exclusive_minimum: Exclusive minimum (value must be greater than)
  • exclusive_maximum: Exclusive maximum (value must be less than)

Collection Validations:

  • min_items: Minimum array/set items
  • max_items: Maximum array/set items
  • unique_items: Require unique items in arrays
  • min_properties: Minimum object properties
  • max_properties: Maximum object properties

General Validations:

  • required: Field validationis rulesrequired (default: false)
  • default: Default value if not provided
  • enum: List of allowed values

EndpointService Definitions

EndpointsServices defineorganize your API operations with full type safety.safety and logical grouping.

Basic EndpointService Structure

{
  "endpoints": {
    "user": {
      "$meta": {
        "description": "User management service"
      },
      "get_user": {
        "method": "GET",
        "path": "/users/{id}",
        "description": "Retrieve a user by ID",
      "request": {
          "id": "int64"
        },
        "response": "@user",
        "errors": ["not_found", "unauthorized"],
      "transports": ["http", "websocket"ws", "tcp"]
      }
    }
  }
}

HTTP Method Mapping

{
  "endpoints": {
    "user": {
      "$meta": {
        "description": "Complete user management service"
      },
      "create_user": {
        "method": "POST",
        "path": "/users",
        "request": {
          "username": "string",
          "email": "string",
          "password": "string"
        },
        "response": "@user",
        "transports": ["http", "ws", "tcp"]
      },
      "update_user": {
        "method": "PUT", 
        "path": "/users/{id}",
        "request": {
          "id": "int64",
          "username": "string?",
          "email": "string?"
        },
        "response": "@user",
        "transports": ["http", "ws", "tcp"]
      },
      "delete_user": {
        "method": "DELETE",
        "path": "/users/{id}",
        "request": {
          "id": "int64"
        },
        "response": {
          "success": "bool"
        },
        "transports": ["http", "ws", "tcp"]
      }
    }
  }
}

Complex Request/Response Types

{
  "endpoints": {
    "search_users": {
      "method": "POST",
      "path": "/users/search",
      "request": {
        "query": "string?",
        "filters": {
          "status": "@user_status?",
          "created_after": "datetime?",
          "tags": "[string]"
        },
        "pagination": {
          "page": "int32",
          "limit": "int32"
        },
        "sort": {
          "field": "string",
          "direction": "string"
        }
      },
      "response": {
        "users": "[@user]",
        "total_count": "int64",
        "page": "int32",
        "has_more": "bool"
      }
    }
  }
}

Streaming Endpoints

Define real-time streaming operations:

{
  "endpoints": {
    "watch_user_updates": {
      "type": "stream",
      "description": "Stream real-time user updates",
      "request": {
        "user_ids": "[int64]"
      },
      "response": "@user_update_event",
      "transports": ["websocket", "tcp"]
    },
    "chat_messages": {
      "type": "bidirectional_stream",
      "description": "Real-time chat messaging",
      "request": "@chat_message",
      "response": "@chat_message",
      "transports": ["websocket", "tcp"]
    }
  }
}

Error Definitions

Define custom error types:

{
  "errors": {
    "not_found": {
      "code": 404,
      "message": "Resource not found",
      "fields": {
        "resource_type": "string",
        "resource_id": "string"
      }
    },
    "validation_error": {
      "code": 400,
      "message": "Validation failed",
      "fields": {
        "field_errors": "<string, string>"
      }
    },
    "rate_limit_exceeded": {
      "code": 429,
      "message": "Rate limit exceeded",
      "fields": {
        "retry_after": "int32"
      }
    }
  }
}

Code Generation Process

Understanding the code generation process helps you optimize your schemas and troubleshoot issues.

Generation Pipeline

  1. Schema Parsing: Parse and validate JSON schemas
  2. Dependency Resolution: Resolve model references and inheritance
  3. Type Analysis: Analyze types and generate type mappings
  4. Code Generation: Generate Dart code for models, clients, and servers
  5. Validation: Validate generated code and run tests

Generated File Structure

lib/generated/
├── models/
│   ├── user.dart
│   ├── user_profile.dart
│   └── enums.dart
├── requests/
│   ├── get_user_request.dart
│   └── create_user_request.dart
├── responses/
│   ├── get_user_response.dart
│   └── search_users_response.dart
├── errors/
│   └── api_errors.dart
├── client/
│   ├── api_client.dart
│   └── streaming_client.dart
└── server/
    ├── endpoint_handlers.dart
    └── server_stubs.dart

Customizing Generation

Configure generation behavior:behavior using the root-level config section:

{
  "models": {
    // Your models here
  },
  "services": {
    // Your services here
  },
  "config": {
    "generation": {
      "null_safety": true,
      "immutable_models": true,
      "generate_json_serialization": true,
      "generate_binary_serialization": true,
      "generate_equality": true,
      "generate_copy_with": true,
      "generate_to_string": true
    },
    "naming": {
      "models": "pascal_case",
      "fields": "snake_case",
      "endpoints": "snake_case"
    },
    "output": {
      "directory": "lib/generated",
      "separate_files": true
    }
  }
}

Schema Evolution and Versioning

Managing schema changes over time is crucial for maintaining backward compatibility.

Versioning Strategy

Configure versioning at the root level:

{
  "models": {
    // Your models here
  },
  "services": {
    // Your services here
  },
  "config": {
    "version": "1.2.0",
    "compatibility": {
      "min_client_version": "1.0.0",
      "max_client_version": "2.0.0"
    }
  }
}

Safe Changes

Changes that maintain backward compatibility:

Adding Optional Fields:

// Before
{
  "models": {
    "user": {
      "id": "int64",
      "name": "string"
    }
  }
}

// After (safe)
{
  "models": {
    "user": {
      "id": "int64", 
      "name": "string",
      "email": "string?"  // New optional field
    }
  }
}

Adding New Endpoints:

{
  "endpoints": {
    "get_user": { /* existing */ },
    "get_user_preferences": { /* new endpoint */ }
  }
}

Adding Enum Values:

// Before
{
  "enums": {
    "user_status": ["active", "inactive"]
  }
}

// After (safe with proper handling)
{
  "enums": {
    "user_status": ["active", "inactive", "suspended"]
  }
}

Breaking Changes

Changes that require version bumps:

Removing Fields:

// Breaking: removes required field
{
  "models": {
    "user": {
      "id": "int64"
      // "name": "string" - removed
    }
  }
}

Changing Field Types:

// Breaking: changes type
{
  "models": {
    "user": {
      "id": "string"  // was int64
    }
  }
}

Removing Endpoints:

{
  "endpoints": {
    // "get_user": removed
    "list_users": { /* ... */ }
  }
}

Migration Strategies

Gradual Migration:

{
  "endpoints": {
    "get_user_v1": {
      "deprecated": true,
      "deprecation_message": "Use get_user_v2 instead",
      "path": "/v1/users/{id}",
      "response": "@user_v1"
    },
    "get_user_v2": {
      "path": "/v2/users/{id}",
      "response": "@user_v2"
    }
  }
}

Field Aliasing:

{
  "models": {
    "user": {
      "id": "int64",
      "full_name": "string",
      "name": {
        "type": "string",
        "alias": "full_name",
        "deprecated": true
      }
    }
  }
}

Best Practices

Schema Organization

Separate Concerns:

schemas/
├── models/
│   ├── user.json
│   ├── post.json
│   └── common.json
├── endpoints/
│   ├── user_endpoints.json
│   ├── post_endpoints.json
│   └── auth_endpoints.json
└── main.json  // Combines all schemas

Use Composition:

{
  "models": {
    "timestamped": {
      "created_at": "datetime",
      "updated_at": "datetime"
    },
    "user": {
      "id": "int64",
      "name": "string",
      "timestamps": "@timestamped"
    }
  }
}

Naming Conventions

Consistent Naming:

  • Models: PascalCase (User, UserProfile)
  • Fields: snake_case (first_name, created_at)
  • Endpoints: snake_case (get_user, create_post)
  • Enums: snake_case values (active, pending)

Descriptive Names:

{
  "models": {
    "user_registration_request": {  // Clear purpose
      "username": "string",
      "email": "string",
      "password": "string"
    }
  }
}

Performance Considerations

Minimize Nesting:

// Prefer flat structures
{
  "models": {
    "user": {
      "id": "int64",
      "profile_id": "int64"  // Reference instead of nesting
    },
    "user_profile": {
      "id": "int64",
      "bio": "string"
    }
  }
}

Use Appropriate Types:

{
  "models": {
    "metrics": {
      "count": "int32",        // Not int64 for small numbers
      "percentage": "float32", // Not float64 for precision
      "timestamp": "datetime", // Not string for dates
      "data": "bytes"         // Not string for binary data
    }
  }
}

What's Next

With a solid understanding of schema-driven development, you're ready to build sophisticated client applications. In the next chapter, we'll explore client development patterns, including request handling, streaming, connection management, and error handling across all transport protocols.