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
},
"endpoints": {
// API endpoint definitions
},
"enums": {
// Enumeration definitions
},
"config": {
// Schema configuration
}
}
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"
}
}
}
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';
}
}
}
Validation Constraints
Add validation rules directly in your schema:
{
"models": {
"user": {
"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
}
}
}
}
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:
{
"models": {
"base_entity": {
"id": "int64",
"created_at": "datetime",
"updated_at": "datetime"
},
"user": {
"extends": "@base_entity",
"username": "string",
"email": "string"
},
"post": {
"extends": "@base_entity",
"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?"
}
}
}
Endpoint Definitions
Endpoints define your API operations with full type safety.
Basic Endpoint Structure
{
"endpoints": {
"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", "tcp"]
}
}
}
HTTP Method Mapping
{
"endpoints": {
"create_user": {
"method": "POST",
"path": "/users",
"request": {
"username": "string",
"email": "string",
"password": "string"
},
"response": "@user"
},
"update_user": {
"method": "PUT",
"path": "/users/{id}",
"request": {
"id": "int64",
"username": "string?",
"email": "string?"
},
"response": "@user"
},
"delete_user": {
"method": "DELETE",
"path": "/users/{id}",
"request": {
"id": "int64"
},
"response": {
"success": "bool"
}
}
}
}
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
- Schema Parsing: Parse and validate JSON schemas
- Dependency Resolution: Resolve model references and inheritance
- Type Analysis: Analyze types and generate type mappings
- Code Generation: Generate Dart code for models, clients, and servers
- 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:
{
"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
{
"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_casevalues (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.