Edit Page

Data Constraints

RESTHeart Cloud

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

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 and the client is told to retry. Nothing invalid is committed either way; the difference is only that the message says "conflict" rather than naming the rule.

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.