Data Constraints
RESTHeart Cloudπ§ Configuration
β‘ Setup Guide
To run the examples on this page, you need a RESTHeart instance.
Option 1: Use RESTHeart Cloud (Recommended)
The fastest way to get started is with RESTHeart Cloud. Create a free service in minutes:
-
Sign up at cloud.restheart.com
-
Create a free API service
-
Set up your root user following the Root User Setup guide
-
Use the configuration panel above to set your service URL and credentials
|
Tip
|
All code examples on this page will automatically use your configured RESTHeart Cloud credentials. |
Option 2: Run RESTHeart Locally
If you prefer local development, follow the Setup Guide to install RESTHeart on your machine.
|
Note
|
Local instances run at http://localhost:8080 with default credentials admin:secret
|
A collection can declare data constraints that span documents, and the server enforces them on every write. jsonSchema validates one document in isolation; a constraint validates the collection: no balance goes negative, at least one admin remains, no booking overlaps another.
The two are independent and a collection can carry both.
A constraint is a property of the data, not of the caller. It applies whatever permission authorized the write, whatever the role, admin included.
|
Note
|
Available from RESTHeart v9.9. Requires MongoDB configured as a replica set. |
Declaring one
Collection metadata, alongside jsonSchema, aggrs, streams and mcp:
cURL
curl -i -X PUT "[RESTHEART-URL]/accounts" \
-H "Authorization: Basic [BASIC-AUTH]" \
-H "Content-Type: application/json" \
-d '{
"constraints": [
{
"name": "noNegativeBalance",
"message": "an account balance cannot be negative",
"stages": [ { "$match": { "balance": { "$lt": 0 } } } ]
}
]
}'
HTTPie
http PUT "[RESTHEART-URL]/accounts" \
Authorization:"Basic [BASIC-AUTH]" \
constraints:='[ { "name": "noNegativeBalance", "message": "an account balance cannot be negative", "stages": [ { "$match": { "balance": { "$lt": 0 } } } ] } ]'
JavaScript
fetch('[RESTHEART-URL]/accounts', {
method: 'PUT',
headers: {
'Authorization': 'Basic [BASIC-AUTH]',
'Content-Type': 'application/json'
},
body: JSON.stringify({
constraints: [
{
name: 'noNegativeBalance',
message: 'an account balance cannot be negative',
stages: [ { $match: { balance: { $lt: 0 } } } ]
}
]
})
})
.then(response => console.log('Constraint declared:', response.status))
.catch(error => console.error('Error:', error));
| Field | Required | Meaning |
|---|---|---|
|
yes |
Unique within the collection. Identifies the rule in the error body. |
|
yes |
An aggregation pipeline over the collection. |
|
no, default |
|
|
no |
Returned on violation. |
|
no, default |
Turns a rule off without deleting it. |
The metadata is validated when you write it β a duplicate name, an empty stages, an unknown holdsWhen, a blacklisted stage β and refused with 400. Not at the first write.
The two directions
empty β no bad rows exist. Write the pipeline that finds the offenders; finding any means the rule is broken, and the documents it returns are what the error body reports.
{ "name": "noNegativeBalance",
"stages": [ { "$match": { "balance": { "$lt": 0 } } } ] }
notEmpty β something must exist. The same shape read the other way round, for rules that would otherwise have to be written inside out.
{ "name": "atLeastOneAdmin",
"holdsWhen": "notEmpty",
"stages": [ { "$match": { "role": "admin" } }, { "$limit": 1 } ] }
Every condition over a collection fits one of the two, so there is no third form and no boolean: an aggregation returns documents, not a verdict.
What a violation looks like
cURL
curl -i -X PATCH "[RESTHEART-URL]/accounts/alice" \
-H "Authorization: Basic [BASIC-AUTH]" \
-H "Content-Type: application/json" \
-d '{ "$inc": { "balance": -100 } }'
HTTPie
http PATCH "[RESTHEART-URL]/accounts/alice" \
Authorization:"Basic [BASIC-AUTH]" \
'$inc':='{ "balance": -100 }'
JavaScript
fetch('[RESTHEART-URL]/accounts/alice', {
method: 'PATCH',
headers: {
'Authorization': 'Basic [BASIC-AUTH]',
'Content-Type': 'application/json'
},
body: JSON.stringify({ $inc: { balance: -100 } })
})
.then(response => response.json())
.then(body => console.log(body))
.catch(error => console.error('Error:', error));
HTTP/1.1 409 Conflict
{
"http status code": 409,
"message": "an account balance cannot be negative",
"constraint": "noNegativeBalance",
"violations": [ { "_id": "alice", "balance": -30 } ]
}
409, not 400: the request is well formed, the resulting state is not admissible. Nothing was written β alice still holds what she held. violations carries the documents the pipeline returned, capped at 10; a notEmpty rule has none by definition, and the field is omitted.
Handling the two kinds of 409
A write to a guarded collection can be refused with 409 for two different reasons, and the client must treat them differently:
| Error body | What happened | What to do |
|---|---|---|
|
The write lost a race with a concurrent one. Nothing was applied, no rule was broken. |
Send the same request again. |
|
The write would have broken that rule. |
Report it. Retrying repeats the same answer. |
Check retryable and nothing else: it is set only on failures that a retry can clear, so its absence always means "do not retry".
HTTP/1.1 409 Conflict
{
"http status code": 409,
"message": "the write conflicted with a concurrent one and was not applied, retry the request",
"retryable": true
}
Retry with a backoff, and give up after a few attempts β a collection under contention serializes its writes, so retrying forever only makes the queue longer.
async function write(url, options, attempts = 4) {
for (let attempt = 0; ; attempt++) {
const response = await fetch(url, options);
if (response.status !== 409 || attempt === attempts - 1) {
return response;
}
const body = await response.clone().json();
if (!body.retryable) {
return response; // a violated constraint, or any other conflict
}
await new Promise(r => setTimeout(r, 50 * 2 ** attempt));
}
}
retryable is not specific to constraints: any write in a transaction that aborts on a transient conflict carries it. Every other 409 β a violated constraint, an ETag mismatch, a duplicate key β does not.
When it runs
Every shape of document write is covered: insert, update, bulk update, and delete. A delete breaks "this parent exists" as readily as an insert breaks "no balance is negative" β the rule is a property of the state, not of the operation.
A bulk write is judged once, after the whole request is applied, and refused as a whole: either every document it touched satisfies the rules or none of them is written.
What it costs
The write runs in a transaction and every rule is one aggregation, so a collection declaring constraints pays a transaction and N aggregations per write.
Writes to that collection also serialize. That is not incidental, it is what makes the rule a guarantee. MongoDB transactions give snapshot isolation: the engine detects two transactions writing the same document, and nothing else. Two writes each valid against their own snapshot and invalid together would both commit β which is precisely the case a constraint exists to prevent. Every write to the collection therefore touches one guard document, turning that undetectable conflict into one the engine does detect: one of the two aborts.
Constraints are opt-in per collection for this reason. A collection that needs write throughput does not declare them.
mongo:
constraints-guard-collection: _constraints # one document per guarded collection
The guard collection lives in the same database as the guarded collection and is created on first use. You never write to it.
Requirements and limits
A replica set is required. Without transactions a write that breaks a rule could not be undone, so a write to a collection declaring constraints is refused with 501 rather than let through unchecked. Constraints live in collection metadata, so this cannot be reported at startup β it is reported on the write.
Concurrent writes may be refused with 409 instead of a violation. When two writes to the same collection collide on the guard document, one is aborted with "retryable": true and the client is expected to send it again. Nothing invalid is committed either way. See Handling the two kinds of 409.
Pipelines are held to aggregationSecurity, unchanged and in full β the same settings that constrain aggrs. $lookup, $merge, $out, $graphLookup and $unionWith are blacklisted by default, so a constraint reads only its own collection unless the deployment has already decided otherwise for its aggregations.