Validations & Callbacks
Ensure data integrity with model validations and hook into the lifecycle of your records with callbacks.
Defining Validations
Use validates inside your model class to declare rules for each field.
Validations run automatically before create and save operations.
class User < Model
validates("email", { "presence": true, "uniqueness": true })
validates("name", { "presence": true, "min_length": 2 })
validates("age", { "numericality": true, "min": 0, "max": 150 })
validates("password", { "min_length": 8 })
end
Multiple rules per field: You can combine any number of validation options in a single validates call. All rules for the field are checked together, and every failing rule produces its own error message.
Validation Options
Each option controls a specific check on the field value:
| Option | Description |
|---|---|
presence: true |
Field must be present and not empty |
uniqueness: true |
Value must be unique in collection |
min_length: n |
String must be at least n characters |
max_length: n |
String must be at most n characters |
format: "regex" |
String must match regex pattern |
numericality: true |
Value must be a number |
min: n |
Number must be >= n |
max: n |
Number must be <= n |
custom: "method" |
Call custom validation method |
Validation Results
Model.create() always returns an instance of the class. On validation or database
failure, the returned instance is not persisted and its
_errors field holds an array of { "field", "message" } entries.
On success, _errors is nil.
user = User.create({ "email": "" })
if user._errors
for error in user._errors
print(error["field"] + ": " + error["message"])
end
else
print("Created user: " + user._key)
end
Tip: Always check user._errors before treating the record as persisted. When validations fail, the record is not persisted to the database.
Atomic Uniqueness
uniqueness: true issues a SELECT … LIMIT 1 before the write, but that check is best-effort: two concurrent User.create({ "email": "x" }) calls can both pass the SELECT and both insert. To make uniqueness atomic, declare a unique index on the column at deploy time and let the database enforce it. Soli detects the resulting 409 from Model.create, instance.save, instance.update, Model.upsert, and Model.find_or_create_by, and turns it into the same _errors entry the SELECT path produces (field: "has already been taken"), so callers handle the race identically.
# Run once at deploy time (e.g. in a migration):
solidb.create_index("users", "users_email_unique", ["email"], { "unique": true })
Without the index, the SELECT is the only line of defense and the race is silently lost — two parallel writers can both produce duplicate rows.
Custom Methods on Models
Define instance methods directly in your model class to encapsulate business logic.
Use this to access the current instance's fields.
class User < Model
def is_admin -> Bool
this.role == "admin"
end
def full_name -> String
this.first_name + " " + this.last_name
end
end
user = User.find("user123")
if user.is_admin
print("Welcome, admin " + user.full_name())
end
Zero-arg methods: Methods like is_admin that take no arguments can be called without parentheses (e.g. user.is_admin). Methods that return a value from a computation should use parentheses when called (e.g. user.full_name()).
Callbacks
Callbacks let you hook into the lifecycle of a model record. Declare them at the class level with the name of the instance method to call.
class User < Model
before_save("normalize_email")
after_create("send_welcome_email")
before_update("log_changes")
after_delete("cleanup_related")
def normalize_email
this.email = this.email.downcase
end
def send_welcome_email
# Send email logic
end
end
Execution order: before_save runs before both create and update operations, making it ideal for data normalization. Specific callbacks like before_create or before_update run only for their respective operation.
Available Callbacks
before_save
Before create or update
after_save
After create or update
before_create
Before inserting new record
after_create
After inserting new record
before_update
Before updating record
after_update
After updating record
before_delete
Before deleting record
after_delete
After deleting record
Firing order per persistence method
Both class-level methods (Model.create, Model.update) and instance-level mutators run the matching callbacks. Rails-style: _save callbacks fire on every persistence path, plus the more specific event for the operation. After-callbacks only fire when the persist call succeeds — if the native method returns false (validation or DB error) the after-callbacks are skipped and the instance carries _errors.
| Method | Before-callbacks (in order) | DB write | After-callbacks (in order) |
|---|---|---|---|
Model.create(attrs) | before_save → before_create | INSERT | after_create → after_save |
Model.update(id, attrs) | before_save → before_update | UPDATE | after_update → after_save |
instance.save([attrs]) — new record | before_save → before_create | INSERT | after_create → after_save |
instance.save([attrs]) — persisted | before_save → before_update | UPDATE | after_update → after_save |
instance.update(attrs) | before_save → before_update | UPDATE | after_update → after_save |
instance.restore() | before_save → before_update | UPDATE | after_update → after_save |
instance.increment(field, n?) | before_save → before_update | UPDATE | after_update → after_save |
instance.decrement(field, n?) | before_save → before_update | UPDATE | after_update → after_save |
instance.touch() | before_save → before_update | UPDATE | after_update → after_save |
instance.delete() (soft + hard) | before_delete | UPDATE / DELETE | after_delete |
Vetoing persistence from a before_* callback
Returning false from any before_* callback aborts the operation. The native DB write is skipped, after-callbacks don't run, and the instance picks up an _errors entry of the form [{ "message": "before_<event> callback returned false; persistence aborted" }]. Callers receive false (or, for Model.create / Model.update, the instance with _errors populated) so they can branch on the result identically to a validation failure.
class Audited < Model
before_save("can_save")
def can_save
return false if User.current.is_nil? # returns false → save() / update() aborts
end
end
The veto fires at the first false — subsequent before-callbacks in the chain don't run. Use this for authorization gates, integrity checks, or any "deny by default" pattern. Mutating-only callbacks (the common case) just don't return false and run end-to-end as before.
Complete Example
class User < Model
validates("email", { "presence": true, "uniqueness": true, "format": "^[^@]+@[^@]+$" })
validates("name", { "presence": true, "min_length": 2, "max_length": 100 })
validates("age", { "numericality": true, "min": 0, "max": 150 })
validates("password", { "min_length": 8 })
validates("role", { "custom": "validate_role" })
before_save("normalize_email")
after_create("send_welcome_email")
before_delete("cleanup_related")
def normalize_email
this.email = this.email.downcase
end
def send_welcome_email
# Send welcome email to new users
end
def cleanup_related
# Remove associated data before deletion
end
def validate_role
valid_roles = ["admin", "user", "moderator"]
if !valid_roles.includes?(this.role)
return "must be one of: admin, user, moderator"
end
end
def is_admin -> Bool
this.role == "admin"
end
def full_name -> String
this.first_name + " " + this.last_name
end
end
# Creating a user with validation
user = User.create({
"email": "[email protected]",
"name": "Alice",
"age": 30,
"password": "securepass",
"role": "admin"
})
if !user._errors
print(user.email) # => "[email protected]" (normalized by before_save)
print(user.is_admin) # => true
print(user.full_name()) # => "Alice "
end
Best Practices
- Validate early - Add validations to catch bad data before it reaches the database
- Use presence checks - Required fields should always have
presence: true - Keep callbacks focused - Each callback method should do one thing well
- Avoid heavy logic in callbacks - Don't put slow operations in
before_saveorbefore_create - Use custom validations - For complex rules that can't be expressed with built-in options
- Check validation results - Always inspect
user._errorsaftercreate