| name | rudder-provider-builder |
| description | Guide for creating new providers in the rudder-iac project using the latest BaseProvider and BaseHandler framework. Use when building providers for new RudderStack services (data-graph, profiles, etc.) or creating providers from scratch with custom resources. Implements type-safe handlers with generics, resource lifecycle management (CRUD), remote state sync, and import/export functionality. |
RudderStack Provider Builder
Create new providers for the rudder-iac project following the latest framework patterns.
When to Use This Skill
- Creating a provider for a new RudderStack service (e.g., data-graph, profiles)
- Building a provider from scratch with custom resources
- Adding resource type handlers to an existing provider
- Understanding the BaseProvider and BaseHandler framework
Quick Reference
- Framework Overview: See framework-overview.md for architecture, interfaces, and data flow
- Handler Implementation: See handler-guide.md for step-by-step handler creation
- Patterns & Best Practices: See patterns.md for common patterns, testing, and pitfalls
- Example Provider:
cli/internal/provider/testutils/example/ - complete working implementation
Prerequisites
Before starting, ensure:
- API Client Exists: The RudderStack API client should be implemented in
api/client/<service>/ with CRUD methods
- Spec Format Defined: YAML spec structure for resources is documented or understood
- Resource Model Clear: Understanding of resource data model, relationships, and remote API responses
Workflow: Create a New Provider
Step 1: Create Provider Directory Structure
Create the following structure under cli/internal/providers/:
<provider-name>/
├── provider.go # Provider composition
├── handlers/ # Resource type handlers
│ └── <resource>/
│ └── handler.go # Handler implementation
├── model/ # Shared data types (optional)
│ └── <resource>.go # Spec, Res, State, Remote types
└── README.md # Provider documentation
Step 2: Implement Handlers
For each resource type:
-
Define Data Types in model/<resource>.go:
Spec: YAML configuration structure
Res: Resource data (input for CRUD)
State: Output state (computed fields only)
Remote: API response wrapper implementing RemoteResource
CRITICAL: Mapstructure Tags Required for Spec Structs
All Spec structs must include mapstructure tags alongside json tags. This is required because providers use mapstructure.Decode() to parse YAML specs into Go structs (see provider.go line 55).
Without mapstructure tags, the decoder cannot map YAML field names (especially those with underscores like account_id) to struct fields, causing validation errors even when fields are present in the YAML.
type DataGraphSpec struct {
ID string `json:"id" mapstructure:"id"`
Name string `json:"name" mapstructure:"name"`
AccountID string `json:"account_id" mapstructure:"account_id"`
Models []ModelSpec `json:"models,omitempty" mapstructure:"models"`
}
type ModelSpec struct {
ID string `json:"id" mapstructure:"id"`
DisplayName string `json:"display_name" mapstructure:"display_name"`
Type string `json:"type" mapstructure:"type"`
}
type DataGraphSpec struct {
ID string `json:"id"`
Name string `json:"name"`
AccountID string `json:"account_id"`
}
Rule: Every field in a Spec struct needs both tags with matching field names. The mapstructure tag should match the YAML field name exactly (including snake_case).
CRITICAL: Mapstructure Tags Required for Resource Structs
All Res (Resource) structs must also include mapstructure tags using the same field names as the corresponding Spec struct. This is required for two reasons that work together:
Spec tags ensure YAML fields decode correctly into Go structs
Res tags ensure diff output shows the same field names users see in their YAML specs
The diff engine (cli/internal/syncer/differ/diff.go) calls mapstructure.Decode(resource.RawData(), &map) to build comparison maps. Without tags, Go field names (PascalCase) become the map keys — so users see DisplayName in a diff instead of the display_name they wrote in their spec.
type RelationshipSpec struct {
DisplayName string `json:"display_name" mapstructure:"display_name"`
Target string `json:"target" mapstructure:"target"`
}
type RelationshipResource struct {
DisplayName string `mapstructure:"display_name"`
TargetModelRef *resources.PropertyRef `mapstructure:"target"`
}
type ModelResource struct {
DisplayName string
DataGraphRef *resources.PropertyRef
}
Rule: Every field in a Res struct needs a mapstructure tag that matches the corresponding Spec field name exactly.
-
Implement Handler in handlers/<resource>/handler.go:
- Create
HandlerImpl struct with API client
- Implement all
HandlerImpl[Spec, Res, State, Remote] methods
- Choose export strategy (MultiSpec or SingleSpec)
- Add reference helper functions if resource is referenceable
Detailed guide: See handler-guide.md
Step 3: Compose Provider
Create provider.go:
package <provider>
import (
"github.com/rudderlabs/rudder-iac/cli/internal/provider"
)
type Provider struct {
provider.Provider
}
func NewProvider(apiClient *client.Client) *Provider {
handlers := []provider.Handler{
resource1.NewHandler(apiClient),
resource2.NewHandler(apiClient),
}
return &Provider{
Provider: provider.NewBaseProvider(handlers),
}
}
Important: Always create a concrete Provider struct that embeds provider.Provider. This allows the provider to be used as a specific type in the Providers struct in dependencies.go, enabling access to provider-specific methods if needed.
Step 4: Register Provider
Register the provider in cli/internal/app/dependencies.go:
- Add to Providers struct - Use pointer to concrete type, not interface:
type Providers struct {
MyProvider *myprovider.Provider
}
- Initialize in setupProviders() - Conditionally if using experimental flag:
func setupProviders(c *client.Client) (*Providers, error) {
cfg := config.GetConfig()
providers := &Providers{
}
if cfg.ExperimentalFlags.MyFeature {
myClient := myclient.New(c)
providers.MyProvider = myprovider.NewProvider(myClient)
}
return providers, nil
}
- Add to composite provider - In
NewDeps():
providers := map[string]provider.Provider{
"datacatalog": p.DataCatalog,
}
if cfg.ExperimentalFlags.MyFeature {
providers["myprovider"] = p.MyProvider
}
Important: The Providers struct uses concrete pointer types (*myprovider.Provider) instead of the interface (provider.Provider) to allow access to provider-specific methods if needed.
Step 5: Testing
- Unit Tests: Test all handler methods using testify
- Use struct comparisons, not field-by-field assertions
- Test validation, CRUD operations, state mapping, export
- E2E Tests: Add tests in
cli/tests/ if provider affects apply cycle
- Integration Tests: Based on scope and complexity
Run tests:
make test
make test-e2e
make test-all
Key Framework Concepts
BaseProvider
Aggregates handlers and routes operations:
- Discovers supported kinds/types from handlers
- Routes specs to appropriate handler by kind
- Merges results from all handlers
- Implements full Provider interface
BaseHandler (Generic)
Type-safe wrapper around HandlerImpl:
- Type parameters:
[Spec, Res, State, Remote]
- Handles type conversions (
any ↔ concrete types)
- Manages resource collection and graph building
- Delegates business logic to HandlerImpl
HandlerImpl Interface
Resource-specific implementation providing:
- Spec lifecycle (parse, validate, extract resources)
- Resource validation in graph context
- Remote operations (load managed/importable resources)
- State mapping (Remote → Res + State)
- CRUD operations (Create, Update, Import, Delete)
- Export formatting (resources → YAML specs)
Complete reference: See framework-overview.md
Common Patterns
Resource References
CRITICAL: Resources must NEVER reference other resources using direct ID strings. Always use *resources.PropertyRef for cross-resource references.
Why PropertyRef is Required
Remote IDs are not known until resources are created. Using direct ID strings would cause:
- Create operations to fail (referenced resource doesn't exist yet)
- Invalid references during the apply cycle
- Incorrect dependency tracking
How to Use PropertyRef
- In Resource struct - Use
*resources.PropertyRef, not string:
type ModelResource struct {
ID string
DisplayName string
DataGraphRef *resources.PropertyRef
}
- In Spec Parsing - Create PropertyRef from external ID using URN:
func (h *HandlerImpl) ExtractResourcesFromSpec(path string, spec *ModelSpec) (map[string]*ModelResource, error) {
dataGraphURN := resources.URN(spec.DataGraphID, datagraph.HandlerMetadata.ResourceType)
dataGraphRef := datagraph.CreateDataGraphReference(dataGraphURN)
resource := &ModelResource{
DataGraphRef: dataGraphRef,
}
return map[string]*ModelResource{spec.ID: resource}, nil
}
CRITICAL: Always use resources.URN() to construct URNs. Never use fmt.Sprintf or string concatenation. Always use HandlerMetadata.ResourceType instead of hardcoding resource type strings.
- Create Reference Helper - Provide in parent handler:
func CreateDataGraphReference(urn string) *resources.PropertyRef {
return handler.CreatePropertyRef[DataGraphState](
urn,
func(state *DataGraphState) (string, error) {
return state.ID, nil
},
)
}
- In CRUD Operations - Access resolved value:
func (h *HandlerImpl) Create(ctx context.Context, data *ModelResource) (*ModelState, error) {
dataGraphRemoteID := data.DataGraphRef.Value
req := &CreateModelRequest{
Name: data.DisplayName,
}
remote, err := h.client.CreateModel(ctx, dataGraphRemoteID, req)
}
- In MapRemoteToState - Convert remote ID to PropertyRef:
func (h *HandlerImpl) MapRemoteToState(remote *RemoteModel, urnResolver handler.URNResolver) (*ModelResource, *ModelState, error) {
parentURN, err := urnResolver.GetURNByID(datagraph.HandlerMetadata.ResourceType, remote.DataGraphID)
if err != nil {
return nil, nil, fmt.Errorf("resolving data graph URN: %w", err)
}
parentRef := datagraph.CreateDataGraphReference(parentURN)
resource := &ModelResource{
DataGraphRef: parentRef,
}
return resource, state, nil
}
CRITICAL RULES:
- ❌ NEVER parse URNs by splitting on
: or using string manipulation
- ❌ NEVER hardcode resource type strings (e.g.,
"data-graph")
- ✅ ALWAYS use
HandlerMetadata.ResourceType for resource types
- ✅ ALWAYS use
resources.URN(id, resourceType) to construct URNs
- ✅ ALWAYS pass URNs directly to Create*Reference functions
Complete Reference Example
Spec (YAML):
spec:
id: "my-dg"
name: "My Data Graph"
models:
- id: "user"
display_name: "User"
Parent Handler (datagraph/handler.go) - Provide reference helper:
func CreateDataGraphReference(urn string) *resources.PropertyRef {
return handler.CreatePropertyRef(
urn,
func(state *DataGraphState) (string, error) {
return state.ID, nil
},
)
}
Child Handler (model/handler.go) - Use references:
func (h *HandlerImpl) ExtractResourcesFromSpec(path string, spec *ModelSpec) (map[string]*ModelResource, error) {
dataGraphURN := resources.URN(spec.DataGraphID, datagraph.HandlerMetadata.ResourceType)
dataGraphRef := datagraph.CreateDataGraphReference(dataGraphURN)
resource := &ModelResource{DataGraphRef: dataGraphRef}
return map[string]*ModelResource{spec.ID: resource}, nil
}
func (h *HandlerImpl) ValidateResource(resource *ModelResource, graph *resources.Graph) error {
if resource.DataGraphRef == nil {
return fmt.Errorf("data_graph reference is required")
}
if _, exists := graph.GetResource(resource.DataGraphRef.URN); !exists {
return fmt.Errorf("referenced data graph does not exist")
}
return nil
}
func (h *HandlerImpl) Create(ctx context.Context, data *ModelResource) (*ModelState, error) {
dataGraphRemoteID := data.DataGraphRef.Value
remote, err := h.client.CreateModel(ctx, dataGraphRemoteID, req)
}
func (h *HandlerImpl) MapRemoteToState(remote *RemoteModel, urnResolver handler.URNResolver) (*ModelResource, *ModelState, error) {
parentURN, err := urnResolver.GetURNByID(datagraph.HandlerMetadata.ResourceType, remote.DataGraphID)
if err != nil {
return nil, nil, fmt.Errorf("resolving data graph URN: %w", err)
}
parentRef := datagraph.CreateDataGraphReference(parentURN)
resource := &ModelResource{DataGraphRef: parentRef}
return resource, state, nil
}
Export Strategies
- MultiSpecExportStrategy: One resource per file (independent resources)
- SingleSpecExportStrategy: Multiple resources in one file (grouped resources)
Validation
Two-phase validation:
- ValidateSpec: YAML structure, required fields (no graph access)
- ValidateResource: Business logic, cross-resource references (with graph)
More patterns: See patterns.md
Implementation Checklist
Provider Setup
Per Handler
Integration
Code Standards
Naming
- Use fully capitalized "ID" (not "Id"):
ExternalID, WorkspaceID
- Resource types: kebab-case
<provider>-<resource> (e.g., data-graph-model)
- Spec kinds: match YAML
kind field (e.g., data-graph)
URN Construction and Resource References
CRITICAL RULES - Follow these strictly:
-
Always use resources.URN() to construct URNs:
urn := resources.URN(externalID, HandlerMetadata.ResourceType)
urn := fmt.Sprintf("%s:%s", "data-graph", externalID)
urn := "data-graph:" + externalID
-
Always use HandlerMetadata.ResourceType, never hardcode:
urn := resources.URN(id, datagraph.HandlerMetadata.ResourceType)
dataGraphURN, err := urnResolver.GetURNByID(datagraph.HandlerMetadata.ResourceType, remoteID)
urn := resources.URN(id, "data-graph")
dataGraphURN, err := urnResolver.GetURNByID("data-graph", remoteID)
-
Never parse or split URNs:
externalID := urn[len("data-graph:"):]
parts := strings.Split(urn, ":")
ref := datagraph.CreateDataGraphReference(urn)
-
Create*Reference functions take URNs, not external IDs:
urn := resources.URN(externalID, datagraph.HandlerMetadata.ResourceType)
ref := datagraph.CreateDataGraphReference(urn)
ref := datagraph.CreateDataGraphReference(externalID)
Error Handling
- Wrap errors with context:
fmt.Errorf("creating resource: %w", err)
- Use sentinel errors with
Err prefix: ErrNotFound
- Log errors at top layer only
Testing
- Prefer struct comparisons over field-by-field
- Use testify (assert/require)
- Test validation, CRUD, state mapping, export
Logging
- Use
logger.New("packagename")
- Log actionable operations only, NOT hot paths
- Include structured attributes
Complete standards: See CLAUDE.md in project root
Troubleshooting
Common Issues
"cannot use RemoteWriter as type parameter"
- Ensure
Metadata() uses value receiver: func (r RemoteWriter) Metadata()
"nil pointer dereference in PropertyRef"
- Always check:
if data.Author != nil { ... }
"reference not found in graph"
- Validate references exist in
ValidateResource
"external ID not set on import"
- Set external ID in
Import() method
"tests comparing pointers fail"
- Compare struct values, not pointers:
assert.Equal(t, expected, *actual)
Additional Resources
- Example Provider:
cli/internal/provider/testutils/example/ (Writer and Book resources)
- Provider Interface:
cli/internal/provider/provider.go
- BaseProvider:
cli/internal/provider/baseprovider.go
- Handler Interfaces:
cli/internal/provider/handler/handler.go
- BaseHandler:
cli/internal/provider/handler/basehandler.go
- Project Guidelines:
CLAUDE.md in project root
Tips for Success
- Start with the example provider - Read and understand the Writer and Book handlers
- Define types first - Clear type definitions make implementation easier
- Implement incrementally - One handler method at a time, test as you go
- Use reference resolution - Don't hardcode relationships, use PropertyRefs
- Keep state minimal - Only store fields needed for Update/Delete
- Test thoroughly - Unit tests catch issues early
- Follow conventions - Consistency with existing code matters
Next Steps
After creating your provider:
- Documentation: Update provider README with resource types and examples
- CLI Integration: Add commands if needed (
cli/internal/cmd/)
- Spec Examples: Create example YAML specs for testing
- User Documentation: Document spec format and usage patterns
- Migration: If migrating from old provider, create migration plan