Architecture

A small core that runs business applications described in configuration. No products table, no customers table, no hardwired processes.

One sheet, whole system

The core doesn't know what a product or a customer is. The definition tells it. What the core knows is how business software behaves.

Layered architecture: consumers on top, one front door, the core with registry, API, identity, process runner, functions and hooks, extensions to the right, PostgreSQL and a message queue at the bottom. React UIuser accounts Partner systemscustomers, suppliers, API keys Custom appsdashboards and tools on the API Workers and webhookssubscribe to events Front door: authenticate, rate limit, authorize, validate, pre hooks, audit Lean core Model registryloads and compiles definitionsentities, traits, processes Generic APICRUD, actions, processesOpenAPI generated Identity and accessaccounts, groups, rolesscopes, rate limits Process runnersteps, branches, waitsretries, compensation Function registryPDF, mail, numbers, HTTPplus your own Types, traits, strategiesbase types, generic traitspre and post hooks Extensions order-fulfillmentdropshippingtrade package, standard typesslack-notify (later)low-stock-alert (later)processes, functions,hooks, entity fields PostgreSQLrecords as JSONB for every configured entitysystem tables: numbers, stock ledger, reservations, processesaudit log and outbox in the same transaction Message queueevents in CloudEvents formatpost hooks, webhooks, external workersat least once, consumers idempotent
Fig. 4 Everything enters through the front door, every change leaves as an event. Business processes live in extensions, not in the core.

Guiding principles

Quality by design

Acceptance criteria, invariants and tests are part of every spec before code exists.

API first

Every capability is an API before it is anything else. UI, partners and agents share one contract.

Open and closed API

The API grows by adding, never by changing. No breaking changes within a version; breaking changes only in a new major version with a deprecation period.

All logic behind the API

The React frontend has no business logic, so every end to end test is an API test.

One front door

Authentication, authorization, rate limits and audit in one place, for every client.

Event driven

Every change emits an event in the same transaction. Nothing gets lost.

Configuration over code

Entities, workflows and processes are data, versioned in Git.

Open for extension

Customize through configuration, API, webhooks, workers and extensions. Never by patching the core.

CI/CD from day one

Every merge is linted, tested, contract checked and deployed.

AI ready

The docs are designed so customers and partners can build dashboards and extensions with AI tools by prompting.

Simplicity over performance

On purpose, and documented. See the note to architects below.

Traits: capabilities you switch on

No table is hardwired. A plain entity becomes a document, a numbered record or a stock item because its definition says so. Generic traits live in the core, trade traits such as stock and prices ship in the trade package.

TraitAdds
auditableCreated and updated stamps, full change history
statefulStatus field, state machine, actions as API endpoints
numberedGapless numbers from a number range
documentHeader and lines, totals, tax, immutable once posted
effectiveDatedValidity, no overlaps, queries as of a date
stockable tradeStock ledger, available vs physical stock
reservable tradeReservations with expiry against stockable items
priceSource tradeDeclares an entity a price source with kind and precedence
entity: quote
traits:
  - auditable
  - numbered: { range: QT, pattern: "QT-{yyyy}-{seq:5}" }
  - document:
      lines: { item: { type: ref, to: product }, qty: quantity, price: money }
      tax: perLine
  - stateful: { machine: quoteStatus }
fields:
  customer:    { type: ref, to: customer, required: true }
  validUntil: { type: date }
  source:      { type: ref, to: orderRequest }

More definitions in the deep dive

The front door

User accounts log in to the UI, system accounts use API keys. Both map to the same groups, roles and scopes. Rate limits belong to groups.

Nine steps per request: request id, authenticate, rate limit, authorize, validate, pre hooks, execute, outbox event, audit.1Request ID2Authenticate3Rate limit4Authorize5Validate6Pre hooks7Execute8Outbox event9Audit Every request, from every client, in this order
Fig. 5 The front door pipeline. Execution and outbox event share one transaction, so a change without an event cannot happen.
GET /api/v1/supplier-orders?filter[status]=new
Authorization: Bearer sk_live_supplier_acme_...

HTTP/1.1 200
X-RateLimit-Remaining: 4987
{ "items": [ { "id": "019a2f3c-7b41-7e8a-9c1d-5f2e8a4b6c10",
               "number": "SO-2026-00017", "status": "new", "lines": 3 } ],
  "page": { "number": 1, "size": 50, "hasNext": false },
  "links": { "self": "/api/v1/supplier-orders?filter[status]=new&page=1" } }
POST /api/v1/supplier-orders/019a2f3c-7b41-7e8a-9c1d-5f2e8a4b6c10/actions/ship
Authorization: Bearer sk_live_supplier_acme_...
Idempotency-Key: 7f9c0d1e
{ "trackingNumber": "1Z999AA10123456784" }

HTTP/1.1 200
{ "id": "019a2f3c-7b41-7e8a-9c1d-5f2e8a4b6c10", "number": "SO-2026-00017", "status": "shipped" }
# emits supplierOrder.shipped, the dropshipping extension informs the customer

API evolution without breaking anyone

Partners, extensions and AI agents build on the API. So the API follows the open closed principle: open for extension, closed for modification.

  • Additive only within a major version: new endpoints, new optional fields, new events
  • Never rename, remove or change the meaning of a field in place
  • Breaking changes only in a new major version, both versions run side by side
  • Deprecation with a published sunset date and a Sunset header
  • CI compares the generated OpenAPI with the last release and fails on breaking changes
GET /api/v1/sales-orders/019a2f3b-1d22-7c40-8a77-3e9b0c5d2f18
HTTP/1.1 200
Deprecation: true
Sunset: Wed, 30 Jun 2027 00:00:00 GMT
Link: </api/v2/sales-orders/019a2f3b-1d22-7c40-8a77-3e9b0c5d2f18>; rel="successor-version"

# v2 splits shipTo into address objects. v1 keeps answering
# unchanged until the sunset date; clients migrate on their schedule.

Hooks and events

Timeline of a change: pre hooks run before the commit and may reject; after the commit an event goes to the queue, which triggers post hooks, webhooks and workers. commit PATCH /api/v1/sales-orders/{id}validated, authorized pre.update.ordersync, may enrich or rejectstrict timeout order.updatedevent to the queue post.update.orderasync extension handlers webhooks, workerssigned, retried, logged before commit: can still say noafter commit: can only react
Fig. 6 Pre hooks guard, post hooks react. A synchronous webhook is allowed as a pre hook, with a timeout and a configured fallback.

Five ways to customize, in this order

  1. Configuration in the application definition
  2. API and webhooks, no code in the system
  3. External workers triggered by queue events
  4. Extensions with functions, hooks, traits and processes
  5. Changing the core: not intended. If it seems necessary, an extension point is missing.
hooks:
  pre.update.customer:
    handler: extensions/credit-check#validateLimit
    timeoutMs: 800
    onFailure: reject          # or: allow
  post.transition.order.status:
    webhook: https://erp-bridge.example.com/order-status
    secret: ${WEBHOOK_SECRET}       # HMAC SHA-256 signature
    retry: { max: 8, backoff: exponential }

A core without trade knowledge

The core does not know what a price, a delivery or a warehouse is. It knows three building blocks and how to run them. Everything a trading company needs ships as a package on top, and can be replaced.

Building block 1

Types

Rules for one value. Base types like string, decimal and date are in the core. Money, percent, quantity, address, IBAN and VAT id are type extensions with code, shipped as standard and versioned.

Building block 2

Traits

Behavior of a record and its siblings: numbered, stateful, document, valid over time. Traits also declare meaning: a priceSource tells the pricing logic that this entity holds prices.

Building block 3

Strategies

Replaceable decisions with a typed contract: which price applies, which warehouse ships, which box to use, what shipping costs.

export const money = defineType({
  name: "money", version: 1,
  schema: { amount: "decimal", currency: "currency" },
  validate: (v, ctx) => ctx.currencies.has(v.currency)
                  && decimals(v.amount) <= ctx.minorUnits(v.currency),
  normalize: (v, ctx) => ({ ...v, amount: round(v.amount, ctx.rounding(v.currency)) }),
  add: (a, b) => a.currency === b.currency
                  ? { amount: a.amount + b.amount, currency: a.currency }
                  : fail("different currencies"),
  compare: sameCurrencyOnly,
  operators: ["eq", "ne", "lt", "lte", "gt", "gte"],
  format: (v, locale) => formatCurrency(v, locale),
});
entity: promotionPrice
traits:
  - effectiveDated: { key: [product, channel, price.currency] }
  - priceSource: { kind: sales, amount: price, for: product,
                    precedence: 10 }   # wins over the list price
fields:
  product: { type: ref, to: product, required: true }
  channel: { type: ref, to: salesChannel, required: true }
  price:   { type: money, required: true }

Types are versioned. Configuring and extending is always allowed; replacing a type creates a new version, stored values keep theirs, and posted documents are never recalculated.

AI ready by design

Once installed, customers and partners should be able to build on the platform by prompting. That is mostly a question of documentation and access, so both are product features.

App builders

"Create a dashboard showing daily sales stats"

A partner hands the API docs to a tool like Lovable. Complete OpenAPI, examples and recipes make the first try work.

Coding agents

"Create a returns handling process"

A customer prompts Claude Code. The extension guide, a context file template, a scaffold and a validator keep the result inside the extension points.

# ERP Self-Test API
> Business API for customers, products, orders, stock and processes.
> REST and JSON. API keys for systems, tokens for users.

## Start here
- Authentication: /docs/auth.md
- Entities of this installation: /docs/entities.md   # includes custom fields
- Actions and processes: /docs/processes.md
- Recipes: daily sales, top customers, open orders: /docs/recipes.md

## Machine readable
- OpenAPI 3.1: /api/v1/_meta/openapi.json
- AsyncAPI 3.0 (events, webhooks): /api/v1/_meta/asyncapi.json
- Arazzo 1.0 (multi step flows): /api/v1/_meta/arazzo.json
- Events: /docs/events.md
# in a project created with: npx core extension new returns
Read AGENTS.md and docs/extensions.md.
Create a returns handling process: a customer requests a return
for an invoiced order, sales approves above 500 EUR, stock is booked
back on receipt, a credit note is created and the customer is informed.
Use only extension points. Run core validate and the tests before you finish.

Docs are tested like code: a fixed set of reference prompts must produce working results with every release.

Data model, honestly

A word to the architects reading this

You would not build a production ERP on this data model. All configured entities live in one generic JSONB table. It is slower, harder to optimize and less strict than a fully modeled schema.

That's a deliberate trade: minimal code, a domain that is pure configuration, and model changes without migrations. This experiment asks how far agents get, not how fast the database is.

Neutral system tables cover what needs hard guarantees: number ranges, stock ledger, reservations, process state, audit and outbox.

The documented path to scale

Not built. All behind one storage adapter, so API, definitions and extensions don't change.

  1. Generated physical tables per entity, JSONB only for custom fields
  2. Partitioning of documents, lines and movements by time
  3. Read models from events for lists, search and reporting
  4. Read replicas, pooling, caching of master data
  5. Horizontal workers, outbox onto a high volume broker
  6. Load tests with target values as a pipeline step

Multi tenancy: deliberately out

It shapes data model, security, cost and operations more than any other choice. For the question behind this experiment it isn't needed.

OptionEffort nowVerdict
Ignorenonefits the question, risky without a plan
Build nowhigh, isolation in every querycontradicts lean core
Prepare with tenantId everywheremedium, touches every querya half solution that still adds complexity
Ignore and document the pathlowproposed one database per tenant, routed by the front door

Decision record ADR-006