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.
| Contract | Value |
|---|---|
| Covers | Placing changes, contracts, changesets, migrations, escaping, CSRF, jobs, tests, catalogs and lint rules |
| You will have | An application whose inputs, writes, HTML, jobs and text follow the ten rules below, checked by frappe check, frappe lint and frappe corretto |
| Proved by | frappe check · frappe lint · frappe corretto |
The guide in six stages
12 sections · read in order the first time
- 1 · Start
- 2 · Data
- 3 · Browser
- 4 · Work
- 5 · Style
- 6 · Limits
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.
contract do
field title : String, min: 1, max: 200
field author : String, min: 1, max: 200
field rating : Int32?, min: 1, max: 5
endStage 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.
| Condition | Outcome |
|---|---|
Duplicate on a unique index, with unique_constraint declared | adds an error on the field |
| The same duplicate without it | raises a database exception |
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
endPreload, 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.
| Condition | Outcome |
|---|---|
| Association a page reads without a preload | fails compilation, reported as N_PLUS_ONE |
A migration with CREATE INDEX on an existing table, which blocks its writes while the index builds | refuses; frappe db diff writes CREATE INDEX CONCURRENTLY in a migration of its own |
field rating : Int32?frappe db diff --name add_rating_to_booksExpect: 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.
frappe migrateExpect: Prints Applied N migrations., then The database matches the declared schema.
frappe db branch create rating_trialExpect: Prints the branch’s database URL. frappe dev --branch rating_trial then runs the application against the copy; the development database is untouched.
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.
| Condition | Outcome |
|---|---|
ingress sets csrf: false and names no authenticator | fails compilation |
redirect_to given a destination that is not a path on your site | refuses |
Store a user-supplied destination through a changeset that calls validate_url, so redirect_external can follow it.
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.
struct App::SendReceipt < Caramel::ColdBrew::Job
param book_id : Int64
def perform
# Load the book by book_id and send its receipt.
end
enddef 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}
endTest 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
| Condition | Outcome |
|---|---|
| A route parameter with no matching contract field | fails compilation |
| An association a page reads without a preload | fails compilation, reported as N_PLUS_ONE |
ingress with csrf: false and no authenticator | fails compilation |
| A mocking library compiled beside Corretto | fails compilation |
| A method whose cyclomatic complexity passes 10 | fails 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 understorage/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.
frappe check
frappe lint
frappe correttoPassing: frappe check exits 0, frappe lint exits 0 and frappe corretto ends with Corretto: N of N workers passed.