A production-ready Firestore ODM for Go with built-in validation, type-safe queries, and effortless data modelling.
Working with Firestore's Go SDK can be verbose and error-prone. Firevault provides a clean, type-safe abstraction layer that makes Firestore feel natural in Go, without sacrificing flexibility or performance.
// Before: Verbose Firestore SDK
doc, err := client.Collection("users").Doc(id).Get(ctx)
if err != nil { /* handle */ }
var user User
doc.DataTo(&user)
// Manual validation...
// Manual transformation...
client.Collection("users").Doc(id).Set(ctx, map[string]interface{}{
"email": strings.ToLower(user.Email),
"age": user.Age,
// ...
})
// After: Clean Firevault API
collection := firevault.Collection[User](connection, "users")
user, err := collection.FindOne(ctx, NewQuery().ID(id))
// Validation + transformation handled automatically ✨- 🎯 Type-Safe Queries - Leverage Go generics for compile-time type safety
- ✅ Built-in Validation - Extensive validation rules (required, email, min/max, custom validators)
- 🔄 Automatic Transformations - Transform data before storage (lowercase, trim, hash passwords, etc.)
- 🔐 Transaction Support - First-class transaction support with clean API
- 📦 Single Struct Design - Use the same model for Create, Read, Update operations
- 🚀 Performance - Validation caching for zero-overhead repeated operations
- 🛠 Extensible - Register custom validators and transformers easily
- 📖 Rich Error Messages - Detailed validation errors with custom formatting support
go get github.com/bobch27/firevault_goRequirements: Go 1.24+ (uses generics)
import (
"context"
"log"
"github.com/bobch27/firevault_go"
)
// Sets your Google Cloud Platform project ID.
projectID := "YOUR_PROJECT_ID"
ctx := context.Background()
connection, err := firevault.Connect(ctx, projectID)
if err != nil {
log.Fatalln("firevault initialisation failed:", err)
}
defer connection.Close()💡 Pro Tip: A Firevault Connection is thread-safe and designed to be used as a singleton. It caches validation metadata for optimal performance.
type User struct {
Name string `firevault:"name,required,omitempty"`
Email string `firevault:"email,required,email,is_unique,omitempty"`
Password string `firevault:"password,required,min=6,transform:hash_pass,omitempty"`
Address *Address `firevault:"address,omitempty"`
Age int `firevault:"age,required,min=18,omitempty"`
}
type Address struct {
Line1 string `firevault:",omitempty"`
City string `firevault:"-"` // Ignored field
}collection := firevault.Collection[User](connection, "users")
// Create
user := User{
Name: "Bobby Donev",
Email: "hello@bobbydonev.com",
Password: "secure123",
Age: 26,
}
id, err := collection.Create(ctx, &user)
// Read
user, err := collection.FindOne(ctx, NewQuery().ID(id))
users, err := collection.Find(ctx, NewQuery().Where("age", ">=", 26))
// Update
updates := User{Password: "newpassword"}
err = collection.Update(ctx, NewQuery().ID(id), &updates)
// Delete
err = collection.Delete(ctx, NewQuery().ID(id))- Installation
- Quick Start
- Defining Models
- CRUD Operations
- Querying
- Options
- Transactions
- Error Handling
- Performance
- Examples
- Contributing
- License
- Acknowledgments
Models are Go structs with firevault tags that define field behaviour, validation, and transformation rules.
`firevault:"fieldName,rule1,rule2=param,transform:rule3,omitempty"`Rules are executed in order (except omitempty variants, which can be placed anywhere).
By default, Firevault uses the firevault struct tag. You can customise this:
// Use "db" instead of "firevault" as the struct tag
connection, err := firevault.Connect(ctx, projectID, "db")
// Now in your structs:
type User struct {
Name string `db:"name,required,omitempty"` // ✅
// Instead of:
// Name string `firevault:"name,required,omitempty"`
}| Rule | Description |
|---|---|
omitempty |
Omits field if set to default value (e.g., 0 for int, "" for string) |
omitempty_create |
Like omitempty, but only for Create method |
omitempty_update |
Like omitempty, but only for Update method |
omitempty_validate |
Like omitempty, but only for Validate method |
dive |
Recursively validate nested structs in slices/maps |
- |
Ignores the field completely |
| Rule | Description | Example |
|---|---|---|
required |
Field must not be default value | required |
required_create |
Required only on Create | required_create |
required_update |
Required only on Update | required_update |
required_validate |
Required only on Validate | required_validate |
min |
Minimum value/length | min=18 |
max |
Maximum value/length | max=100 |
email |
Valid email address | email |
Register your own validation logic:
connection.RegisterValidation(
"is_upper",
ValidationFunc(func(fs FieldScope) (bool, error) {
if fs.Kind() != reflect.String {
return false, nil
}
s := fs.Value().String()
return s == strings.ToUpper(s), nil
}),
)
// Use it in your model
type User struct {
Name string `firevault:"name,required,is_upper,omitempty"`
}Context-aware validators for database checks:
connection.RegisterValidation(
"is_unique",
ValidationFuncCtxTx(func(ctx context.Context, tx *Transaction, fs FieldScope) (bool, error) {
doc, err := Collection[User](connection, fs.Collection()).FindOne(
ctx,
NewQuery().Where(fs.Field(), "==", fs.Value().Interface()),
NewOptions().Transaction(tx),
)
if err != nil {
return false, err
}
return doc.ID == "", nil
}),
)Transform field values before storage with the transform: prefix:
Built-in transformations:
uppercase- Convert to uppercaselowercase- Convert to lowercasetrim_space- Remove leading/trailing whitespace
Custom transformations:
connection.RegisterTransformation(
"hash_pass",
TransformationFunc(func(fs FieldScope) (interface{}, error) {
password := fs.Value().String()
hashed, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
return string(hashed), err
}),
)
type User struct {
// Password gets hashed before storage
Email string `firevault:"email,required,transform:lowercase,email,omitempty"`
Password string `firevault:"password,required,min=6,transform:hash_pass,omitempty"`
}⚡ Order Matters: Place transformations before validation rules if you want to validate the transformed value.
Adds a new document to Firestore after validation.
collection := firevault.Collection[User](connection, "users")
user := User{
Name: "Bobby Donev",
Email: "hello@bobbydonev.com",
Age: 26,
}
// Auto-generated ID
id, err := collection.Create(ctx, &user)
// Custom ID
id, err := collection.Create(
ctx,
&user,
NewOptions().CustomID("custom-id"),
)
// Allow zero values for specific fields
user := User{Age: 0} // Normally omitted with omitempty
id, err := collection.Create(
ctx,
&user,
NewOptions().AllowEmptyFields("age"),
)Updates documents matching a query.
updates := User{Password: "newpassword123"}
// Update by ID
err := collection.Update(
ctx,
NewQuery().ID("user-id"),
&updates,
)
// Update multiple documents
err := collection.Update(
ctx,
NewQuery().Where("age", "<", 18),
&updates,
)
// Update boolean to false (explicitly allow zero value)
updates := User{IsActive: false}
err := collection.Update(
ctx,
NewQuery().ID("user-id"),
&updates,
NewOptions().AllowEmptyFields("is_active"),
)
// Replace entire document
err := collection.Update(
ctx,
NewQuery().ID("user-id"),
&user,
NewOptions().ReplaceAll(),
)
// Replace specific nested field
updates := User{Address: &Address{Line1: "New Street"}}
err := collection.Update(
ctx,
NewQuery().ID("user-id"),
&updates,
NewOptions().ReplaceFields("address.Line1"),
)
⚠️ Important: Withoutomitemptyoromitempty_update, non-specified fields will be set to Go's default values. Use these rules to prevent unintended updates.
Validates data without touching the database.
user := User{Email: "HELLO@EXAMPLE.COM"}
err := collection.Validate(ctx, &user)
// Email is now "hello@example.com" if transform:lowercase is used
// Validate as if creating
err := collection.Validate(ctx, &user, NewOptions().AsCreate())
// Validate as if updating
err := collection.Validate(ctx, &user, NewOptions().AsUpdate())Deletes documents matching a query.
// Delete by ID
err := collection.Delete(ctx, NewQuery().ID("user-id"))
// Delete with precondition
err := collection.Delete(
ctx,
NewQuery().ID("user-id"),
NewOptions().RequireExists(),
)
// Delete if last updated at specific time
err := collection.Delete(
ctx,
NewQuery().ID("user-id"),
NewOptions().RequireLastUpdateTime(timestamp),
)Retrieves multiple documents.
users, err := collection.Find(
ctx,
NewQuery().
Where("age", ">=", 18).
OrderBy("name", Asc).
Limit(10),
)
// Access results
for _, doc := range users {
fmt.Println(doc.ID) // Document ID
fmt.Println(doc.Data.Name) // User data
fmt.Println(doc.Metadata.CreateTime) // Metadata
}Retrieves a single document.
user, err := collection.FindOne(
ctx,
NewQuery().ID("user-id"),
)
if user.ID == "" {
// Document not found
}Counts documents matching a query.
count, err := collection.Count(
ctx,
NewQuery().Where("age", ">=", 18),
)
fmt.Printf("Found %d adults\n", count)Build complex queries with a fluent API:
query := NewQuery().
Where("status", "==", "active").
Where("age", ">=", 18).
OrderBy("createdAt", Desc).
Limit(20).
Offset(10)
users, err := collection.Find(ctx, query)| Method | Description | Example |
|---|---|---|
ID(ids...) |
Filter by document IDs | ID("id1", "id2") |
Where(path, op, value) |
Filter by field | Where("age", ">=", 18) |
OrderBy(path, direction) |
Sort results | OrderBy("name", Asc) |
Limit(n) |
Limit results | Limit(10) |
LimitToLast(n) |
Last N results | LimitToLast(5) |
Offset(n) |
Skip N results | Offset(20) |
StartAt(values...) |
Cursor start (inclusive) | StartAt(25) |
StartAfter(values...) |
Cursor start (exclusive) | StartAfter(25) |
EndBefore(values...) |
Cursor end (exclusive) | EndBefore(50) |
EndAt(values...) |
Cursor end (inclusive) | EndAt(50) |
==, !=, <, <=, >, >=, array-contains, array-contains-any, in, not-in
Asc, Desc
Options provide fine-grained control over operations:
options := NewOptions().
AllowEmptyFields("is_active", "score").
SkipValidation("password").
ModifyOriginal().
Transaction(tx)| Option | Applies To | Description |
|---|---|---|
AllowEmptyFields(paths...) |
Create, Update, Validate | Ignore omitempty for specified fields |
SkipValidation(paths...) |
Create, Update, Validate | Skip validation for specified fields |
ModifyOriginal() |
Create, Update, Validate | Update original struct with transformed values |
CustomID(id) |
Create | Use custom document ID |
ReplaceAll() |
Update | Replace entire document |
ReplaceFields(paths...) |
Update | Replace specific fields completely |
RequireExists() |
Delete | Only delete if document exists |
RequireLastUpdateTime(t) |
Update, Delete | Optimistic locking with timestamp |
Transaction(tx) |
All | Execute within transaction |
AsCreate() |
Validate | Validate as if creating |
AsUpdate() |
Validate | Validate as if updating |
Firevault provides first-class transaction support for atomic operations:
err := connection.RunTransaction(ctx, func(ctx context.Context, tx *Transaction) error {
opts := NewOptions().Transaction(tx)
// Read documents
docs, err := collection.Find(ctx, NewQuery().Where("age", "==", 18), opts)
if err != nil {
return err
}
// Build update query from doc IDs
updateQuery := NewQuery()
for _, doc := range docs {
updateQuery = updateQuery.ID(doc.ID)
}
// Update within transaction
updates := &User{Age: 19}
return collection.Update(ctx, updateQuery, updates, opts)
})
⚠️ Transaction Limits:
- Only
IDclause is considered for Update/Delete in transactions- Use
Findfirst to get IDs, then pass them to Update/Delete- Maximum ~500 operations per transaction (Firestore limit)
Firevault provides detailed validation errors through the FieldError interface:
connection.RegisterErrorFormatter(func(fe FieldError) error {
switch fe.Rule() {
case "min":
return fmt.Errorf("%s must be at least %s characters",
fe.DisplayField(), fe.Param())
case "email":
return fmt.Errorf("%s must be a valid email address",
fe.DisplayField())
default:
return nil // Let default error through
}
})id, err := collection.Create(ctx, &user)
if err != nil {
var fErr FieldError
if errors.As(err, &fErr) {
fmt.Printf("Field: %s\n", fErr.Field()) // "email"
fmt.Printf("Rule: %s\n", fErr.Rule()) // "email"
fmt.Printf("Value: %v\n", fErr.Value()) // "invalid@"
fmt.Printf("Param: %s\n", fErr.Param()) // ""
fmt.Printf("Message: %s\n", fErr.Error()) // Default message
} else {
// Non-validation error (network, permissions, etc.)
fmt.Println(err)
}
}Firevault is designed for production use with performance in mind:
- Validation Caching: Struct validation metadata is parsed once and cached
- Zero Allocation Queries: Query builders reuse allocations where possible
- Efficient Reflection: Validation uses cached type information
- Benchmarked: Comparable to industry-leading validators like
go-playground/validator(both with and without caching)
See BENCHMARKS.md for detailed performance comparisons.
- Use singleton Connection: One connection per application
- Reuse CollectionRef instances: Lightweight, but avoid unnecessary creation
- Batch operations: Use queries for bulk updates instead of loops
- Index your queries: Follow Firestore indexing guidelines
package main
import (
"context"
"fmt"
"log"
"github.com/bobch27/firevault_go"
)
type User struct {
Name string `firevault:"name,required,transform:trim_space,omitempty"`
Email string `firevault:"email,required,email,transform:lowercase,omitempty"`
Password string `firevault:"password,required,min=8,transform:hash_pass,omitempty"`
Age int `firevault:"age,required,min=13,omitempty"`
IsActive bool `firevault:"is_active,omitempty"`
}
func main() {
ctx := context.Background()
connection, err := firevault.Connect(ctx, "your-project-id")
if err != nil {
log.Fatal(err)
}
defer connection.Close()
collection := firevault.Collection[User](connection, "users")
// Create user
user := User{
Name: " Bobby Donev ", // Will be trimmed
Email: "HELLO@EXAMPLE.COM", // Will be lowercased
Password: "secure123", // Will be hashed
Age: 26,
IsActive: true,
}
id, err := collection.Create(ctx, &user)
if err != nil {
log.Fatal(err)
}
fmt.Printf("Created user: %s\n", id)
// Find active users
activeUsers, err := collection.Find(
ctx,
NewQuery().
Where("is_active", "==", true).
Where("age", ">=", 18).
OrderBy("name", Asc),
)
for _, doc := range activeUsers {
fmt.Printf("User: %s (%s)\n", doc.Data.Name, doc.Data.Email)
}
// Deactivate user
err = collection.Update(
ctx,
NewQuery().ID(id),
&User{IsActive: false},
NewOptions().AllowEmptyFields("is_active"),
)
}Check out the /examples directory (coming soon) for:
- Transaction examples
- Custom validators and transformers
- Batch operations
- Advanced querying patterns
- Error handling strategies
Pull requests are welcome. For major changes, please open an issue first to discuss what you would like to change.
MIT License - see LICENSE for details.
Built with ❤️ for the Go and Firebase communities.
Special thanks to the Firestore team for the excellent SDK, and to Dean Karn for the incredible go-playground/validator package, which inspired Firevault's validation system, particularly:
- The
FieldScopepattern for passing field context to validation functions - The
FieldErrorinterface for detailed validation error reporting - The validation function signatures and registration conventions
Firevault expands on these ideas with a fully custom validation engine and a complete Firestore ODM layer - including field transformations, method-specific validations, transaction support, and CRUD operations tailored for Firestore.
⭐ If you find Firevault useful, please consider giving it a star on GitHub! ⭐
Made with 🔥 by Bobby Donev