Resource Development Guide

Resources are the building blocks of stacks in DRFT. This guide explains how to create and use resources for managing any type of declarative resource.

Resource Types

Base Resource

All resources extend the Resource base class:

abstract class Resource {
  final String id;
  final String type;
  final Map<String, dynamic> properties;
  final List<String> dependencies;
  
  Resource({
    required this.id,
    required this.type,
    required this.properties,
    this.dependencies = const [],
  });
  
  void validate();
  Map<String, dynamic> toJson();
}

Concrete Resources

Resources should be concrete classes with typed properties:

class VirtualMachine extends Resource {
  final String name;
  final String image;
  final String size;
  final String? subnetId;
  final List<String>? securityGroupIds;
  final Map<String, String>? tags;
  
  VirtualMachine({
    required this.name,
    required this.image,
    required this.size,
    this.subnetId,
    this.securityGroupIds,
    this.tags,
  }) : super(
    id: 'vm.$name',
    type: 'aws.ec2.instance',
    properties: {
      'name': name,
      'image': image,
      'size': size,
      'subnetId': subnetId,
      'securityGroupIds': securityGroupIds,
      'tags': tags,
    },
  );
  
  @override
  void validate() {
    if (name.isEmpty) {
      throw ValidationException('VM name cannot be empty');
    }
    if (!['small', 'medium', 'large'].contains(size)) {
      throw ValidationException('Invalid VM size: $size');
    }
  }
}

Using Dart Syntax Features

Immutable Resources with Constructor Parameters

Resources use final properties set via constructor parameters, similar to Flutter widgets:

final vm = VirtualMachine(
  name: 'web-server',
  image: 'ubuntu-22.04',
  size: 'medium',
  subnetId: subnet.id,
  securityGroupIds: [sg1.id, sg2.id],
  tags: {
    'Environment': 'production',
    'Role': 'web-server',
  },
);

Complex Resources

For resources with many optional parameters, use named parameters with sensible defaults:

final database = Database(
  name: 'app-db',
  engine: 'postgresql',
  version: '14',
  instanceClass: 'db.t3.medium',
  storage: 100,
  backupRetention: 7,
  multiAz: true,
  publiclyAccessible: false,
  subnetGroup: dbSubnetGroup.id,
  securityGroups: [dbSecurityGroup.id],
  tags: {
    'Environment': 'production',
    'Backup': 'enabled',
  },
);

Named Constructors for Common Patterns

class VirtualMachine extends Resource {
  // Standard constructor
  VirtualMachine({/* ... */});
  
  // Named constructor for common configurations
  VirtualMachine.production({
    required String name,
    required String image,
  }) : this(
    name: name,
    image: image,
    size: 'large',
    tags: {'Environment': 'production'},
  );
  
  VirtualMachine.development({
    required String name,
    required String image,
  }) : this(
    name: name,
    image: image,
    size: 'small',
    tags: {'Environment': 'development'},
  );
}

Resource Dependencies

Explicit Dependencies

final network = Network(name: 'main-network');
final subnet = Subnet(
  name: 'public-subnet',
  networkId: network.id,
  cidr: '10.0.1.0/24',
  dependsOn: [network.id], // Explicit dependency (if needed)
);

Implicit Dependencies

DRFT automatically detects dependencies when resources reference each other:

final network = Network(name: 'main-network');
final subnet = Subnet(
  name: 'public-subnet',
  networkId: network.id, // Implicit dependency detected
);

Dependency Resolution

class Resource {
  List<String> get implicitDependencies {
    final deps = <String>[];
    
    // Scan properties for resource references
    for (final value in properties.values) {
      if (value is ResourceReference) {
        deps.add(value.resourceId);
      } else if (value is List) {
        for (final item in value) {
          if (item is ResourceReference) {
            deps.add(item.resourceId);
          }
        }
      }
    }
    
    return deps;
  }
  
  List<String> get allDependencies {
    return [...dependencies, ...implicitDependencies];
  }
}

Common Resource Patterns

Compute Resources

class VirtualMachine extends Resource {
  final String name;
  final String image;
  final String size;
  final String? subnetId;
  final List<String>? securityGroupIds;
  final String? userData;
  final Map<String, String>? tags;
  
  VirtualMachine({
    required this.name,
    required this.image,
    required this.size,
    this.subnetId,
    this.securityGroupIds,
    this.userData,
    this.tags,
  }) : super(/* ... */);
}

class Container extends Resource {
  final String name;
  final String image;
  final int? replicas;
  final Map<String, String>? environment;
  final List<Port>? ports;
  
  Container({
    required this.name,
    required this.image,
    this.replicas,
    this.environment,
    this.ports,
  }) : super(/* ... */);
}

Storage Resources

class Database extends Resource {
  final String name;
  final String engine;
  final String version;
  final String instanceClass;
  final int storage;
  final int? backupRetention;
  final bool multiAz;
  final String? subnetGroup;
  final List<String>? securityGroups;
  
  Database({
    required this.name,
    required this.engine,
    required this.version,
    required this.instanceClass,
    required this.storage,
    this.backupRetention,
    this.multiAz = false,
    this.subnetGroup,
    this.securityGroups,
  }) : super(/* ... */);
}

class ObjectStorage extends Resource {
  final String name;
  final String? region;
  final bool? versioning;
  final String? encryption;
  
  ObjectStorage({
    required this.name,
    this.region,
    this.versioning,
    this.encryption,
  }) : super(/* ... */);
}

Network Resources

class Network extends Resource {
  final String name;
  final String cidr;
  final Map<String, String>? tags;
  
  Network({
    required this.name,
    required this.cidr,
    this.tags,
  }) : super(/* ... */);
}

class Subnet extends Resource {
  final String name;
  final String networkId;
  final String cidr;
  final String? availabilityZone;
  
  Subnet({
    required this.name,
    required this.networkId,
    required this.cidr,
    this.availabilityZone,
  }) : super(/* ... */);
}

class SecurityGroup extends Resource {
  final String name;
  final String networkId;
  final List<IngressRule>? ingressRules;
  final List<EgressRule>? egressRules;
  
  SecurityGroup({
    required this.name,
    required this.networkId,
    this.ingressRules,
    this.egressRules,
  }) : super(/* ... */);
}

Resource Validation

Resources should validate their properties:

class VirtualMachine extends Resource {
  @override
  void validate() {
    if (name.isEmpty) {
      throw ValidationException('VM name cannot be empty');
    }
    
    if (!RegExp(r'^[a-z0-9-]+$').hasMatch(name)) {
      throw ValidationException(
        'VM name must contain only lowercase letters, numbers, and hyphens',
      );
    }
    
    if (!['small', 'medium', 'large', 'xlarge'].contains(size)) {
      throw ValidationException('Invalid VM size: $size');
    }
    
    if (subnetId != null && !subnetId!.startsWith('subnet.')) {
      throw ValidationException('Invalid subnet ID format');
    }
  }
}

Resource Serialization

Resources must be serializable for state management:

class VirtualMachine extends Resource {
  @override
  Map<String, dynamic> toJson() {
    return {
      'id': id,
      'type': type,
      'properties': {
        'name': name,
        'image': image,
        'size': size,
        'subnetId': subnetId,
        'securityGroupIds': securityGroupIds,
        'tags': tags,
      },
      'dependencies': dependencies,
    };
  }
  
  factory VirtualMachine.fromJson(Map<String, dynamic> json) {
    final props = json['properties'] as Map<String, dynamic>;
    return VirtualMachine(
      name: props['name'] as String,
      image: props['image'] as String,
      size: props['size'] as String,
      subnetId: props['subnetId'] as String?,
      securityGroupIds: (props['securityGroupIds'] as List?)?.cast<String>(),
      tags: (props['tags'] as Map?)?.cast<String, String>(),
    );
  }
}

Resource References

Use resource references to link resources:

class ResourceReference {
  final String resourceId;
  final String? attribute;
  
  ResourceReference(this.resourceId, [this.attribute]);
  
  String get value {
    // Resolve reference at runtime
    return _resolveReference();
  }
}

// Usage
final network = Network(name: 'main-network');
final subnet = Subnet(
  name: 'public-subnet',
  networkId: network.id, // Direct reference
);

Example: Complete Stack

void main() {
  final stack = DrftStack(
    name: 'web-application',
    providers: [
      AwsProvider(region: 'us-east-1'),
    ],
    resources: [
      // Network
      Vpc(
        name: 'main-vpc',
        cidr: '10.0.0.0/16',
        tags: {'Environment': 'production'},
      ),
      
      // Subnets
      Subnet(
        name: 'public-1',
        vpcId: vpc.id,
        cidr: '10.0.1.0/24',
        availabilityZone: 'us-east-1a',
      ),
      Subnet(
        name: 'public-2',
        vpcId: vpc.id,
        cidr: '10.0.2.0/24',
        availabilityZone: 'us-east-1b',
      ),
      
      // Security Groups
      SecurityGroup(
        name: 'web-sg',
        vpcId: vpc.id,
        ingressRules: [
          IngressRule(port: 80, protocol: 'tcp', source: '0.0.0.0/0'),
          IngressRule(port: 443, protocol: 'tcp', source: '0.0.0.0/0'),
        ],
      ),
      
      SecurityGroup(
        name: 'db-sg',
        vpcId: vpc.id,
        ingressRules: [
          IngressRule(port: 5432, protocol: 'tcp', source: '10.0.0.0/16'),
        ],
      ),
      
      // Load Balancer
      LoadBalancer(
        name: 'web-lb',
        subnets: [subnet1.id, subnet2.id],
        securityGroups: [webSg.id],
        listeners: [
          Listener(port: 80, protocol: 'http'),
          Listener(port: 443, protocol: 'https'),
        ],
      ),
      
      // Virtual Machines
      VirtualMachine(
        name: 'web-1',
        image: 'ubuntu-22.04',
        size: 'medium',
        subnetId: subnet1.id,
        securityGroupIds: [webSg.id],
        tags: {'Role': 'web-server'},
      ),
      
      VirtualMachine(
        name: 'web-2',
        image: 'ubuntu-22.04',
        size: 'medium',
        subnetId: subnet2.id,
        securityGroupIds: [webSg.id],
        tags: {'Role': 'web-server'},
      ),
      
      // Database
      Database(
        name: 'app-db',
        engine: 'postgresql',
        version: '14',
        instanceClass: 'db.t3.medium',
        storage: 100,
        backupRetention: 7,
        multiAz: true,
        subnetGroup: dbSubnetGroup.id,
        securityGroups: [dbSg.id],
        tags: {'Environment': 'production'},
      ),
    ],
  );
}

Data Sources (Read-Only External Resources)

Some resources represent external infrastructure that cannot be created, updated, or deleted through DRFT. These are called read-only resources and can be marked using either the ReadOnlyResource base class or the ReadOnly mixin.

When to Use Data Sources

Data sources are appropriate for:

  • Resources managed outside DRFT (e.g., Firebase projects created via Console)
  • External APIs that provide reference data
  • Existing infrastructure that should not be modified
  • Resources that other resources depend on for information

Implementing a Data Source

Option 1: Extend ReadOnlyResource

Extend ReadOnlyResource instead of Resource:

class FirebaseProjectState extends ResourceState {
  FirebaseProjectState({required super.resource});
}

class FirebaseProject extends ReadOnlyResource<FirebaseProjectState> {
  final String projectId;
  final String displayName;

  const FirebaseProject({
    required String id,
    required this.projectId,
    required this.displayName,
    List<Resource> dependencies = const [],
  }) : super(
          id: id,
          dependencies: dependencies,
        );
}

Option 2: Use ReadOnly Mixin

If your resource extends a base class (e.g., for typed provider support), use the ReadOnly mixin:

/// Base class for Firebase resources (for typed provider)
abstract class FirebaseResource extends Resource<ResourceState> {
  const FirebaseResource({
    required super.id,
    super.dependencies = const [],
  });
}

/// Firebase Project with ReadOnly mixin
class FirebaseProject extends FirebaseResource with ReadOnly {
  final String projectId;
  final String displayName;
  
  const FirebaseProject({
    required String id,
    required this.projectId,
    required this.displayName,
    List<Resource> dependencies = const [],
  }) : super(id: id);
}

Both approaches are automatically detected by the planner and executor, so no additional configuration is needed.

How Data Sources Work

  1. Planning: Data sources are never included in create, update, or delete operations
  2. Reading: Data sources are read to verify they exist and get current state
  3. Dependencies: Other resources can depend on data sources normally
  4. Refresh: Data sources are refreshed during state refresh operations

Example Usage

final stack = DrftStack(
  name: 'my-stack',
  providers: [FirebaseProvider()],
  resources: [
    // Data source - cannot be created/updated/deleted
    FirebaseProject(
      id: 'project',
      projectId: 'my-project-id',  // Must exist externally
      displayName: 'My Project',
    ),
    
    // Regular resource - depends on data source
    FirebaseApp(
      id: 'ios-app',
      projectId: 'my-project-id',  // References the project
      platform: FirebaseAppPlatform.ios,
      displayName: 'iOS App',
      bundleId: 'com.example.app',
    ),
  ],
);

// Plan will only include operations for FirebaseApp
// FirebaseProject will be read to verify it exists
final plan = await stack.plan();

Provider Implementation

Providers must implement readResource for read-only resources to verify they exist externally:

@override
Future<ResourceState> readResource(Resource resource) async {
  if (resource is FirebaseProject) {
    // Verify the project exists in Firebase
    final project = await _firebaseManagement.projects.getProject(
      resource.projectId,
    );
    
    if (project == null) {
      throw ResourceNotFoundException(resource.projectId);
    }
    
    return FirebaseProjectState(
      resource: resource,
    );
  }
  // ... handle other resources
}

Note: Providers do not need to handle createResource, updateResource, or deleteResource for read-only resources, as the planner and executor guarantee these methods will never be called for resources with the ReadOnly mixin or ReadOnlyResource instances.

Best Practices

  1. Immutability: Resources should be immutable with final properties
  2. Type Safety: Use strong typing for all properties
  3. Validation: Validate all inputs in the validate() method
  4. Defaults: Provide sensible defaults where appropriate
  5. Documentation: Document all properties and their purposes
  6. Serialization: Ensure proper JSON serialization/deserialization
  7. Dependencies: Make dependencies explicit and clear
  8. Flutter-style: Follow Flutter widget patterns for familiarity
  9. Data Sources: Use ReadOnlyResource or the ReadOnly mixin for read-only external resources