Simple guides introducing Fabrica with hands-on examples.
Pluggable storage backends for flexible resource persistence.
Fabrica provides a pluggable storage system that allows you to persist resources in different backends without changing your application code.
Built-in:
Planned:
All storage backends implement the StorageBackend interface:
type StorageBackend interface {
LoadAll(ctx context.Context, resourceType string) ([]json.RawMessage, error)
Load(ctx context.Context, resourceType, uid string) (json.RawMessage, error)
Save(ctx context.Context, resourceType, uid string, data json.RawMessage) error
Delete(ctx context.Context, resourceType, uid string) error
Exists(ctx context.Context, resourceType, uid string) (bool, error)
List(ctx context.Context, resourceType string) ([]string, error)
Close() error
// Version support
LoadWithVersion(ctx context.Context, resourceType, uid, version string) (json.RawMessage, string, error)
LoadAllWithVersion(ctx context.Context, resourceType, version string) ([]json.RawMessage, error)
SaveWithVersion(ctx context.Context, resourceType, uid string, data json.RawMessage, version string) error
}
The default file-based storage backend stores resources as JSON files.
import "github.com/openchami/fabrica/pkg/storage"
// Create backend
backend := storage.NewFileBackend("./data")
defer backend.Close()
// Use with generated storage
storage := NewResourceStorage[*Device](backend, "Device")
data/
├── Device/
│ ├── dev-1a2b3c4d.json
│ ├── dev-2b3c4d5e.json
│ └── dev-3c4d5e6f.json
├── User/
│ ├── usr-a1b2c3d4.json
│ └── usr-b2c3d4e5.json
└── Product/
└── prd-1234abcd.json
Each resource is stored as a JSON file:
{
"apiVersion": "v1",
"kind": "Device",
"metadata": {
"uid": "dev-1a2b3c4d",
"name": "sensor-01",
"labels": {
"location": "warehouse-a"
},
"createdAt": "2024-10-03T10:00:00Z",
"updatedAt": "2024-10-03T10:00:00Z"
},
"spec": {
"name": "Temperature Sensor",
"type": "sensor"
},
"status": {
"online": true
}
}
// Custom data directory
backend := storage.NewFileBackend("/var/lib/myapp/data")
// Multiple backends for different resource types
deviceBackend := storage.NewFileBackend("./devices")
userBackend := storage.NewFileBackend("./users")
Create/Update:
ctx := context.Background()
device := &Device{
// ... populate fields
}
// Save (creates or updates)
err := backend.Save(ctx, "Device", device.GetUID(), deviceJSON)
Read:
// Load single resource
data, err := backend.Load(ctx, "Device", "dev-1a2b3c4d")
// Load all resources
allData, err := backend.LoadAll(ctx, "Device")
// Check existence
exists, err := backend.Exists(ctx, "Device", "dev-1a2b3c4d")
// List UIDs
uids, err := backend.List(ctx, "Device")
Delete:
err := backend.Delete(ctx, "Device", "dev-1a2b3c4d")
File backend is thread-safe and can be used concurrently:
// Safe to use from multiple goroutines
go func() {
backend.Save(ctx, "Device", "dev-1", data1)
}()
go func() {
backend.Save(ctx, "Device", "dev-2", data2)
}()
Implement the StorageBackend interface for custom storage.
package storage
import (
"context"
"database/sql"
"encoding/json"
_ "github.com/lib/pq"
)
type PostgresBackend struct {
db *sql.DB
}
func NewPostgresBackend(connectionString string) (*PostgresBackend, error) {
db, err := sql.Open("postgres", connectionString)
if err != nil {
return nil, err
}
// Create schema
_, err = db.Exec(`
CREATE TABLE IF NOT EXISTS resources (
resource_type VARCHAR(255) NOT NULL,
uid VARCHAR(255) NOT NULL,
data JSONB NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
PRIMARY KEY (resource_type, uid)
)
`)
if err != nil {
return nil, err
}
return &PostgresBackend{db: db}, nil
}
func (b *PostgresBackend) Load(ctx context.Context, resourceType, uid string) (json.RawMessage, error) {
var data json.RawMessage
err := b.db.QueryRowContext(ctx,
"SELECT data FROM resources WHERE resource_type = $1 AND uid = $2",
resourceType, uid,
).Scan(&data)
if err == sql.ErrNoRows {
return nil, storage.ErrNotFound
}
return data, err
}
func (b *PostgresBackend) Save(ctx context.Context, resourceType, uid string, data json.RawMessage) error {
_, err := b.db.ExecContext(ctx, `
INSERT INTO resources (resource_type, uid, data, created_at, updated_at)
VALUES ($1, $2, $3, NOW(), NOW())
ON CONFLICT (resource_type, uid)
DO UPDATE SET data = $3, updated_at = NOW()
`, resourceType, uid, data)
return err
}
func (b *PostgresBackend) Delete(ctx context.Context, resourceType, uid string) error {
result, err := b.db.ExecContext(ctx,
"DELETE FROM resources WHERE resource_type = $1 AND uid = $2",
resourceType, uid,
)
if err != nil {
return err
}
rows, err := result.RowsAffected()
if err != nil {
return err
}
if rows == 0 {
return storage.ErrNotFound
}
return nil
}
func (b *PostgresBackend) LoadAll(ctx context.Context, resourceType string) ([]json.RawMessage, error) {
rows, err := b.db.QueryContext(ctx,
"SELECT data FROM resources WHERE resource_type = $1 ORDER BY created_at",
resourceType,
)
if err != nil {
return nil, err
}
defer rows.Close()
var results []json.RawMessage
for rows.Next() {
var data json.RawMessage
if err := rows.Scan(&data); err != nil {
continue // Skip corrupted data
}
results = append(results, data)
}
return results, nil
}
func (b *PostgresBackend) Exists(ctx context.Context, resourceType, uid string) (bool, error) {
var exists bool
err := b.db.QueryRowContext(ctx,
"SELECT EXISTS(SELECT 1 FROM resources WHERE resource_type = $1 AND uid = $2)",
resourceType, uid,
).Scan(&exists)
return exists, err
}
func (b *PostgresBackend) List(ctx context.Context, resourceType string) ([]string, error) {
rows, err := b.db.QueryContext(ctx,
"SELECT uid FROM resources WHERE resource_type = $1 ORDER BY created_at",
resourceType,
)
if err != nil {
return nil, err
}
defer rows.Close()
var uids []string
for rows.Next() {
var uid string
if err := rows.Scan(&uid); err != nil {
continue
}
uids = append(uids, uid)
}
return uids, nil
}
func (b *PostgresBackend) Close() error {
return b.db.Close()
}
// Implement versioning methods...
func (b *PostgresBackend) LoadWithVersion(ctx context.Context, resourceType, uid, version string) (json.RawMessage, string, error) {
// Implementation
return nil, "", nil
}
func (b *PostgresBackend) LoadAllWithVersion(ctx context.Context, resourceType, version string) ([]json.RawMessage, error) {
// Implementation
return nil, nil
}
func (b *PostgresBackend) SaveWithVersion(ctx context.Context, resourceType, uid string, data json.RawMessage, version string) error {
// Implementation
return nil
}
// Use PostgreSQL backend
backend, err := storage.NewPostgresBackend("postgres://user:pass@localhost/mydb")
if err != nil {
log.Fatal(err)
}
defer backend.Close()
// Use with generated storage
deviceStorage := NewResourceStorage[*Device](backend, "Device")
✅ Check for ErrNotFound specifically
✅ Use context for timeouts
✅ Log storage errors
✅ Handle corrupted data gracefully
// Good
device, err := storage.Load(ctx, uid)
if errors.Is(err, storage.ErrNotFound) {
return http.StatusNotFound
}
if err != nil {
log.Error("storage error", "error", err)
return http.StatusInternalServerError
}
✅ Always use context
✅ Set reasonable timeouts
✅ Handle cancellation
// Good
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
devices, err := storage.LoadAll(ctx)
✅ Use List() instead of LoadAll() when you only need UIDs
✅ Implement caching for read-heavy workloads
✅ Batch operations when possible
✅ Add indexes for queries
// Good - Only need UIDs
uids, err := storage.List(ctx)
// Bad - Loading full resources
devices, err := storage.LoadAll(ctx)
for _, d := range devices {
uids = append(uids, d.GetUID())
}
✅ Use absolute paths for data directory
✅ Ensure write permissions
✅ Regular backups
✅ Monitor disk space
// Good
absPath, _ := filepath.Abs("./data")
backend := storage.NewFileBackend(absPath)
// Check permissions
if err := os.MkdirAll(absPath, 0755); err != nil {
log.Fatal("Cannot create data directory:", err)
}
Fabrica storage provides:
Next Steps:
Questions? GitHub Discussions