Skip to content
carameldocs · 0.16.0
Browse documentation
Guides / Best practices

Build it the Caramel way.

The ten rules a Caramel application keeps for its inputs, writes, HTML, jobs, tests and text, and the checks that hold it to them.

ContractValue
CoversPlacing changes, contracts, changesets, migrations, escaping, CSRF, jobs, tests, catalogs and lint rules
You will haveAn application whose inputs, writes, HTML, jobs and text follow the ten rules below, checked by frappe check, frappe lint and frappe corretto
Proved byfrappe check · frappe lint · frappe corretto

The code on this page extends the bookshelf of Your first application: a Book with title and author, to which this page adds a rating and a receipt job.

Stage 1 · Start

Give every change its home

Each kind of change has one place, and Project structure lists them. Your application’s files are the extension point. When the public API cannot do what a task needs, name the gap, in an issue or in AGENTS.md, instead of patching lib/caramel: the next shards install would undo the patch.

Declare every input

Read request data only through the action’s contract. Give each field its type, and bounds or a default where they apply. The outcomes of an undeclared field and of a route parameter without a field are in the contract reference; frappe check prints the line that adds a missing field.

app/actions/books/create.cr · contract, with the rating this page adds
contract do
  field title : String, min: 1, max: 200
  field author : String, min: 1, max: 200
  field rating : Int32?, min: 1, max: 5
end

Stage 2 · Data

Write through changesets

Every insert and update goes through a changeset, so its rules run for every caller. Put validations in validate(cs). Declare unique_constraint for each unique index. Show the errors with the form, or with render_errors.

ConditionOutcome
Duplicate on a unique index, with unique_constraint declaredadds an error on the field
The same duplicate without itraises a database exception
app/changesets/book.cr · for a Book generated with title:string:unique
class Book::Changeset < SugarORM::Changeset(App::Book)
  param title : String
  param author : String
  param rating : Int32?

  def validate(cs)
    cs.validate_presence(:title)
    cs.validate_length(:title, max: 200)
    cs.unique_constraint(:title)
  end
end

Preload, and migrate in reviewed steps

Preload every association a page reads. To change stored data, edit the schema first, derive the migration, read its SQL, then apply it. Rehearse a change you cannot undo, such as dropping a column, on a database branch.

ConditionOutcome
Association a page reads without a preloadfails compilation, reported as N_PLUS_ONE
A migration with CREATE INDEX on an existing table, which blocks its writes while the index buildsrefuses; frappe db diff writes CREATE INDEX CONCURRENTLY in a migration of its own
app/models/book.cr · the schema edit, inside schema "books"
field rating : Int32?
Terminal · inside the application, after the schema edit
frappe db diff --name add_rating_to_books

Expect: Writes the migration to db/migrations/ and prints Wrote PATH. Without the schema edit it prints already matches the declared schema; no migration was written, and a second run after migrating prints the same.

Terminal · inside the application
frappe migrate

Expect: Prints Applied N migrations., then The database matches the declared schema.

Terminal · inside the application
frappe db branch create rating_trial

Expect: Prints the branch’s database URL. frappe dev --branch rating_trial then runs the application against the copy; the development database is untouched.

Read the SugarORM reference →

Stage 3 · Browser

Keep HTML escaped

Views escape every text value and attribute. Mark a value as Caramel::HTML::Safe only when your own code built it, such as the result of markup { … }; never wrap user input. Update part of a page with an htmx fragment, morph, or partials instead of client-side state, and use an island only where a widget needs state of its own.

Keep the browser protections on

Every write a browser sends carries the CSRF token: forms include the hidden _csrf field, and the generated layout sends X-CSRF-Token with htmx requests. Turn CSRF off only for a route whose ingress names an authenticator for a credential a browser never attaches on its own, such as a signature or a bearer token. Verify a webhook’s signature over its raw_body with a constant-time comparison before reading it.

ConditionOutcome
ingress sets csrf: false and names no authenticatorfails compilation
redirect_to given a destination that is not a path on your siterefuses

Store a user-supplied destination through a changeset that calls validate_url, so redirect_external can follow it.

Review the security defaults →

Stage 4 · Work

Make background work safe to repeat

Enqueue a job in the same transaction as the write it follows, so both commit or neither does. Pass identifiers, not records, and load fresh data in perform. A job runs at least once: give each external call a stable identifier the receiving service can deduplicate. A retry or failure hook only records or enqueues; it does no work of its own. A recurring schedule runs once per period however many processes run it, so its block should only enqueue the real work.

app/jobs/send_receipt.cr · the job this page adds
struct App::SendReceipt < Caramel::ColdBrew::Job
  param book_id : Int64

  def perform
    # Load the book by book_id and send its receipt.
  end
end
app/actions/books/create.cr · handle, rewritten to enqueue the job beside the write
def handle(contract : Contract)
  changes = App::Book::Changeset.new(title: contract.title, author: contract.author)
  SugarORM::Repo.transaction do
    SugarORM::Repo.insert(changes)
    App::SendReceipt.enqueue(book_id: changes.record.id) if changes.saved?
  end
  return render_errors(changes.errors) unless changes.saved?
  {record: changes.record}
end

Test what users observe

Write request specs that drive the application through Corretto against real PostgreSQL. Assert the rendered HTML with have_html, stored rows with have_row, and statuses and headers, with at least one failure case for each rule. Fake a third-party service at the wire with Corretto.stub_wire. Drain queues with Caramel::ColdBrew.drain_queue!, then assert what the job changed. Corretto refuses a mocking library; the Corretto reference says how.

Stage 5 · Style

Keep user-facing text in catalogs

Once an application has locales, write user-facing text as t. messages. Use %{name} placeholders instead of Crystal interpolation, so every locale has to keep them. Run frappe translations in CI to see what each locale still takes from the default, and pass Caramel.language to jobs that produce text.

Write code that reads like short sentences

frappe lint holds the application to Caramel’s rule set: descriptive names, no service nouns such as InvitationService, positive conditions, a cyclomatic complexity of at most 10 per method, and no dump left behind. Every other rule is Ameba 1.7.0’s default. Put the verb on its subject instead of a service: a method on the model, a changeset, or a job. frappe format applies the pinned formatter.

Limits

What does not compile

ConditionOutcome
A route parameter with no matching contract fieldfails compilation
An association a page reads without a preloadfails compilation, reported as N_PLUS_ONE
ingress with csrf: false and no authenticatorfails compilation
A mocking library compiled beside Correttofails compilation
A method whose cyclomatic complexity passes 10fails frappe lint, not compilation

Not covered yet

  • No rule here is checked by a page fixture; the snippets extend the bookshelf by hand and no check builds them.
  • Browser tests under spec/system/ and file storage under storage/ have no conventions yet.

Prove it

Prove every change

Run the same checks before every commit. An agent can read frappe check --agent: its diagnostics include the line that fixes a contract mismatch or a missing preload. Add frappe translations once the application has locales.

Terminal · before every commit
frappe check
frappe lint
frappe corretto

Passing: frappe check exits 0, frappe lint exits 0 and frappe corretto ends with Corretto: N of N workers passed.