Every Caravela domain is a module that uses Caravela.Domain. The DSL declares entities, relations, lifecycle hooks, and authorization rules; the compiler builds an IR and validates it before generators run.

entity :<name> do ... end

Declares one entity (one database table). The name is plural (:books); the generator derives a singular module name (Book), a plural table name (library_books), and a path (lib/<app>/library/book.ex).

entity :books do
  field :title, :string, required: true
  field :isbn, :string
end

field :<name>, <type>, opts

optionapplies toeffect
requiredanynull: false + validate_required
defaultanycolumn default
min, maxnumericvalidate_number
min_length, max_lengthstring-likevalidate_length
formatstring-likevalidate_format (regex)
precision, scalenumericdecimal precision/scale

Recognised types: :string, :text, :integer, :bigint, :float, :decimal, :boolean, :date, :time, :naive_datetime, :utc_datetime, :binary, :binary_id, :uuid, :map, :json, :jsonb.

relation :<from>, :<to>, type: <t>

t is one of :has_many, :has_one, :belongs_to, :many_to_many. Declare either side of a relationship — Caravela infers the other.

relation :authors, :books,   type: :has_many
relation :books,   :publishers, type: :belongs_to, required: true

required: true on a :belongs_to produces null: false on the FK and on_delete: :delete_all in the migration.

version "v<n>" (optional)

Declares the domain's API version. When set, generated modules and routes are namespaced under the version segment. See versioning.

use Caravela.Domain
version "v1"

use Caravela.Domain, multi_tenant: true (optional)

Opts the domain into row-level multi-tenancy: a :tenant_id (:binary_id, null: false) field is auto-injected into every entity, and the generated context scopes reads/writes by context.tenant.id. See multi-tenancy.

Hooks: on_create, on_update, on_delete

Hooks run inside the generated context, between authorization and the final Repo call:

on_create :books, fn changeset, context -> ... end     # → changeset
on_update :books, fn changeset, context -> ... end     # → changeset
on_delete :authors, fn author, context -> ... end      # → :ok | {:error, reason}

context is whatever map you pass to the context function. In the generated controllers it defaults to %{current_user: …, conn: conn} (plus tenant: when multi_tenant: true).

If {:error, reason} is returned from on_delete, the delete is aborted and the tuple propagates back to the caller.

Permissions: can_read, can_create, can_update, can_delete

can_read   :books, fn query, context -> query end        # → Ecto.Query
can_create :books, fn context -> true end                # → boolean
can_update :books, fn book, context -> true end          # → boolean
can_delete :books, fn _book, context -> true end         # → boolean

can_read is applied as a query filter before Repo.all/Repo.get so restricted users never see forbidden rows. The other three return booleans; false short-circuits the context function with {:error, :unauthorized}.

To use query macros like where / from inside can_read, add import Ecto.Query at the top of your domain module.

Compile-time validations

Every rule raises a CompileError pointing at the offending line:

  1. Unknown field types (:widget etc.)
  2. Numeric constraints on non-numeric fields (and vice versa)
  3. Duplicate entity names
  4. Relations referencing undeclared entities
  5. Incompatible cardinality (e.g. both sides :has_many)
  6. Circular chains of required belongs_to (unsatisfiable inserts)
  7. Hooks / permissions with the wrong function arity
  8. Hooks / permissions referring to unknown entities
  9. Duplicate hook / permission for the same (action, entity)
  10. Version strings that don't match ~r/^v\d+$/
  11. Manual tenant_id fields in a multi_tenant: true domain