Edit Page

Data Constraints

RESTHeart Cloud
Prefer not to run it yourself? RESTHeart Cloud is this, hosted, with sign-up, payments and an MCP server already on. Free to start. Get started →

πŸ”§ Configuration

β–Ό
Sets localhost:8080 with admin:secret
Values are saved in your browser

⚑ 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:

  1. Sign up at cloud.restheart.com

  2. Create a free API service

  3. Set up your root user following the Root User Setup guide

  4. 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

name

yes

Unique within the collection. Identifies the rule in the error body.

stages

yes

An aggregation pipeline over the collection.

holdsWhen

no, default empty

empty: the rule holds when stages returns no documents. notEmpty: it holds when it returns at least one.

message

no

Returned on violation.

enabled

no, default true

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

"retryable": true

The write lost a race with a concurrent one. Nothing was applied, no rule was broken.

Send the same request again.

"constraint": "<name>"

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.