App Model Concept

Understand how revenexx Apps work and the declarative app model

This page is for backend developers about to write their first revenexx App. It explains how the declarative model works, why it's set up this way, and walks through a small but complete app (an order-tracking app) so the rest of the App Marketplace documentation has a concrete reference.

If you've ever built a multi-tenant SaaS service from scratch, you know what the platform is doing for you here: provisioning tables, adding tenant-isolation columns, scoping data to the tenant, generating REST endpoints, registering routes, wiring auth. In a typical hand-rolled service that's a substantial amount of code — most of it the same across services. In revenexx that work happens because of declarations in your app's JSON files, and you write only the part that's genuinely unique to your domain.

Every revenexx App is a self-contained unit that runs on the platform runtime. An app is defined by up to five declarative JSON files — no API boilerplate, no tenant logic, no infrastructure wiring. The platform reads these files and provisions everything.

The Five Files

text
my-app/
├── manifest.json     ← identity, dependencies, permissions, policies, events
├── schema.json       ← database table definitions (slim, Git = history)
├── cockpit.json      ← Cockpit UI extension points (navigation, views, widgets)
├── settings.json     ← per-tenant and per-market configuration declarations
└── billing.json      ← marketplace billing model (free, paid, included)

Why declarative?

It helps to understand what this model is reacting against. On a typical platform, standing up a single app means writing — and then maintaining — a familiar stack of boilerplate: REST endpoints for every table, schema migrations with their own version tracking, a tenant_id filter on every query that you must never once forget, permission checks scattered through the request handlers, and a separate dance to register the app in a marketplace and wire up billing. None of that is the app's actual job, all of it is error-prone and slow, and most of it looks nearly identical from one app to the next.

The declarative model inverts that. Instead of writing the plumbing, you declare what you need and the platform builds it. Each of the five files hands one concern to the platform: manifest.json states what you depend on, what data you touch, and what capabilities you need; schema.json defines your data model, and the platform applies the migrations and scopes every row to the tenant; cockpit.json declares your UI extension points, which Cockpit renders; settings.json describes your tenant- and market-scoped configuration, for which Cockpit auto-generates a form; and billing.json declares your pricing, which the customer console turns into marketplace billing. Everything downstream of those declarations — API endpoints, authentication, tenant isolation, UI composition — happens for you.

How it works: an order app example

The fastest way to see the model is to watch one small app come together file by file. The app below tracks orders; it's deliberately minimal, but every file it touches is one you'll write in real apps. For each file, look at what you declare and then at what the platform does with it — that gap is the whole point of the declarative model.

1. Declare the app (manifest.json)

JSON
{
  "name": "orders",
  "vendor": "revenexx",
  "version": "1.0.0",
  "title": "Orders",
  "dependencies": {
    "customers": "^1.0",
    "pricing": "^1.0"
  },
  "permissions": [
    { "entity": "orders", "access": ["read", "create", "update"] },
    { "entity": "order_items", "access": ["read", "create"] }
  ],
  "policies": [
    { "name": "outbound-access", "attrs": { "host": "{{tenant_domain}}", "path": "/api/erp/*" } },
    { "name": "event-publish", "attrs": { "events": ["order.created", "order.status_changed"] } }
  ],
  "events": {
    "emits": ["order.created", "order.status_changed"],
    "listens": ["inventory.stock_changed", "app.installed"]
  }
}

From this one file the platform does a surprising amount. It checks that the customers and pricing apps exist and auto-installs them as dependencies, so your app can rely on them being present. It grants your app read/create/update access to the orders and order_items entities — and to nothing else. It confines your outbound HTTP calls to your tenant's ERP domain, so the app physically cannot reach arbitrary hosts. It registers your events on the platform event bus. And at install time it turns these declarations into a plain-language security summary the customer reviews before saying yes.

2. Define the data (schema.json)

JSON
{
  "tables": {
    "orders": {
      "columns": {
        "id": { "type": "uuid", "primary": true },
        "customer_id": { "type": "uuid", "references": "customers.id" },
        "status": { "type": "string", "enum": ["draft", "confirmed", "shipped", "delivered"] },
        "total_amount": { "type": "decimal" },
        "created_at": { "type": "timestamp", "default": "now()" }
      }
    },
    "order_items": {
      "columns": {
        "id": { "type": "uuid", "primary": true },
        "order_id": { "type": "uuid", "references": "orders.id" },
        "product_id": { "type": "uuid" },
        "quantity": { "type": "integer" },
        "unit_price": { "type": "decimal" }
      }
    }
  }
}

Notice what's not in that schema: there's no tenant_id column, no migration script, no API definition. The platform supplies all of it. It adds a tenant_id column to every table automatically — you never write it and can't accidentally mangle it — applies the migration, generates REST and GraphQL APIs from the tables, and scopes each tenant so it sees only its own rows. You described the shape of your data; everything that makes that data safe and reachable is the platform's job.

3. Extend Cockpit (cockpit.json)

JSON
{
  "navigation": [
    {
      "label": "Orders",
      "icon": "shopping-cart",
      "target": "/orders"
    }
  ],
  "views": [
    {
      "route": "/orders",
      "label": "Orders List",
      "component": "OrdersList"
    }
  ]
}

With that, Cockpit adds an "Orders" entry to its sidebar, routes /orders to your OrdersList component, and — because Cockpit owns the shell — handles authentication and hands your component the tenant context it needs. You ship the Vue component; the JSON just tells Cockpit where it belongs.

4. Declare settings (settings.json)

JSON
{
  "settings": {
    "default_payment_terms": {
      "title": "Default Payment Terms",
      "type": "enum",
      "enum_values": ["net-14", "net-30", "net-60"],
      "default": "net-30",
      "scope": "market"
    }
  }
}

The platform renders a settings form for this in Cockpit automatically, stores each value at the scope you declared — here market, so the choice can differ per market — and lets your app read the resolved value back through an API. You never build a settings screen or a place to persist its values.

5. Define pricing (billing.json)

JSON
{
  "type": "paid",
  "plans": [
    {
      "id": "orders-starter",
      "name": "Starter",
      "price": { "monthly": 49 },
      "limits": { "orders_per_month": 500 }
    }
  ]
}

That makes the app a paid listing in the App Marketplace, with billing wired up to handle the subscription, and gives the platform the numbers it needs to enforce the plan's limits — like the 500-orders-per-month ceiling declared here. The pricing model is a declaration; the billing machinery behind it is not yours to build.

Core concepts

The order app touched several ideas in passing that are worth pinning down on their own, because they recur in every app you'll write.

Dependencies vs. peer dependencies

Apps can depend on other apps, and there are two flavours of that relationship. The difference is entirely about who decides to install the dependency — the platform, or the customer.

TypeInstalled HowUse Case
dependenciesAutomatically when your app is activatedRequired apps (e.g., customers for Orders)
peerDependenciesCustomer installs manually after seeing a promptPaid dependencies or optional features

So when a customer installs Orders, Console silently auto-installs customers and pricing because they're hard dependencies, but for a peer dependency it asks first — "This app requires Markets. Do you want to install it?" You use a plain dependency for things your app genuinely can't run without, and a peer dependency when the dependency costs money or is genuinely optional, so the choice (and the cost) stays with the customer.

Policies: the allowlist model

The security stance is worth stating bluntly: an app starts with zero access to everything. It can't call out to the network, publish or receive events, or touch file storage until it asks. Each capability is granted by a policy you declare, and nothing is implicit.

PolicyGrantsExample
outbound-accessHTTP calls to external hostsConnect to ERP system
event-publishPublishing eventsEmit order.created
event-subscribeSubscribing to eventsListen to inventory.stock_changed
storage-read / storage-writeFile bucket accessStore order PDF attachments

Because every capability is declared up front, the platform can turn the whole list into something a non-technical customer can actually evaluate at install time — "This app will connect to your ERP system and publish order events." The allowlist isn't just a runtime guardrail; it's also the source of that consent screen.

Events: loose coupling

Apps communicate by publishing and subscribing to events rather than by calling each other directly:

JavaScript
// When an order is created, the Orders app publishes:
await platform.events.publish('order.created', {
  order_id: '123',
  customer_id: '456',
  total: 1500.00
})

// The Inventory app subscribes and updates stock:
platform.events.on('order.created', async (event) => {
  await inventory.update(event.order_id)
})

This ensures loose coupling — apps don't need to know each other's URLs or internals.

Lifecycle Events

The platform emits events for app lifecycle:

EventWhenApps Use For
app.installedApp activated for a TenantInitialize data, set defaults
app.uninstalledApp deactivated for a TenantClean up resources
app.upgradedNew version deployedRun data migrations
tenant.provisionedNew Tenant createdInitial setup if app is included

Automatic multi-tenancy

Your data is automatically scoped to your tenant. Every query an app makes returns only the current tenant's rows:

SQL
-- Developer writes:
SELECT * FROM orders

-- Returns only the current tenant's orders, automatically scoped.

No manual tenant filtering needed. The scoping is enforced by the platform, not by your app code — so there's no application-level mistake that can bypass it.

The Developer Experience

If you've built multi-tenant SaaS services before, here's what you'd typically write by hand that the declarative model handles for you:

  • REST endpoints for CRUD operations on your tables (generated automatically from your schema.json)
  • Tenant filtering on every query (your data is scoped to the tenant automatically)
  • Migration scripts with up/down operations (replaced by git history of schema.json — the platform computes the diff)
  • Permission checks for which roles can call which endpoints (declared in manifest.json permissions, enforced by the platform)
  • Cockpit UI route registration (declared in cockpit.json, woven in at runtime)
  • Marketplace registration and version tracking (handled by the App Registry when you publish)
  • Billing integration for paid apps (declared in billing.json)

The cumulative amount of plumbing varies by project, but it's typically the largest chunk of work in a new commerce service. With revenexx, the JSON declarations are a few dozen lines per app and your code stays focused on the business logic that makes the app unique.

Next Steps

Was this page helpful?