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.
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');
}
}
}
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'},
);
}
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];
}
}
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
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
- Planning: Data sources are never included in create, update, or delete operations
- Reading: Data sources are read to verify they exist and get current state
- Dependencies: Other resources can depend on data sources normally
- 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
- Immutability: Resources should be immutable with final properties
- Type Safety: Use strong typing for all properties
- Validation: Validate all inputs in the
validate()method - Defaults: Provide sensible defaults where appropriate
- Documentation: Document all properties and their purposes
- Serialization: Ensure proper JSON serialization/deserialization
- Dependencies: Make dependencies explicit and clear
- Flutter-style: Follow Flutter widget patterns for familiarity
- Data Sources: Use
ReadOnlyResourceor theReadOnlymixin for read-only external resources