Build Apps · Start here
Technical Guide: Build on Fuuz Like You Built It
fuuz.com/help/fuuz-technical-guide
- Audience
- Solution engineers, developers and partner builders
- Applies to Versions
- 2026.10+
Overview
Every screen, data flow, integration and data model you create in Fuuz runs on the same stack: MongoDB, GraphQL APIs, the Data Flow Engine, Fuuz JSONata bindings and a deploy-anywhere infrastructure, with real-time updates over WebSockets. Knowing how each layer works, and how the layers talk to each other, is how you write better screens, faster flows and cleaner integrations, and how you find problems in minutes instead of hours.
This guide is written for engineers coming from SQL-backed, relational or legacy ERP and MES environments.
The stack
| Layer | What it is |
|---|---|
| Client layer | Screen Designer screens, mobile, external apps and IoT devices |
| Data Flow Engine | The low-code and pro-code workflow engine: web, backend and gateway flows |
| Transformation layer | Fuuz JSONata bindings and JavaScript: $state, $components, $metadata |
| GraphQL APIs | Multiple typed, schema-validated APIs |
| MongoDB | A JSON-native, multi-tenant document store with indexed collections |
| Infrastructure | Deploy-anywhere: auto-scaling, CDN, identity and access management, high availability and managed operations |
How the layers talk to each other: MongoDB reads and writes BSON documents; GraphQL serves typed queries, mutations and predicates; flows and screens reshape results with JSONata or JavaScript; and real-time events arrive over WebSockets.
- MongoDB is your data layer. Documents, not rows. The schema lives in the Fuuz data model designer, and the shape of your documents decides how simple or complex your queries, flows and bindings will be.
- GraphQL APIs are your data contract. Filtering and aggregation happen here, with predicates and
_aggregatequeries, not in JSONata transforms. - The Data Flow Engine is your workflow layer. It orchestrates logic, API calls, transforms, conditions, error handling and integrations, and sits above GraphQL in the stack.
- Fuuz JSONata is your expression language. Fuuz extends standard JSONata with platform bindings such as
$components,$metadata,$stateand join functions.
Tip: The stack gives you a clear way to isolate problems. Data wrong? Check the document shape with a GraphQL query. Query returning unexpected results? Check the predicates and filters. Transform producing bad output? Check the JSONata or JavaScript expression in the transform console. Screen not rendering? Inspect the element binding.
Real time: WebSockets, not GraphQL subscriptions
Live screen refresh, push events and shop-floor alerts are delivered by a dedicated WebSocket subscription service, separate from the GraphQL APIs. The result is the same, live data without polling, but it is a distinct service, not a GraphQL subscription.
MongoDB: think in documents, not tables
This is the biggest shift for engineers from SQL-backed environments. MongoDB stores JSON documents, not rows, and how you design documents affects query performance, screen bindings and flow logic.
| Concept | SQL world | MongoDB and Fuuz |
|---|---|---|
| Data unit | Row in a table | JSON document in a collection |
| Relationships | Foreign keys and joins | Nested sub-documents or ID references |
| Schema changes | ALTER TABLE, a migration and risk | Add the field in the schema designer; it is live with near-zero downtime |
| Optional data | A nullable column on every row | The field is simply absent when it does not apply |
| Variable shapes | EAV tables or wide nullable rows | Each document carries its own fields |
| Filtering and aggregation | SQL WHERE and GROUP BY | GraphQL predicates and _aggregate queries at the API layer |
| Record ID | Primary key (varies) | Always id in Fuuz GraphQL, with no underscore |
In a relational MES or ERP, reconstructing one work order can take four tables and three joins, and adding a field means ALTER TABLE, a migration and a deployment window:
SELECT wo.id, wo.number, wo.status, p.name, p.revision, op.seq, op.description
FROM work_orders wo
JOIN parts p ON p.id = wo.part_id
JOIN wo_operations op ON op.wo_id = wo.id
WHERE wo.id = 'WO-1042';
In Fuuz, the whole object comes back in one read, and a new field such as customer_po is added in the schema designer:
{
"id": "WO-1042",
"number": "WO-1042",
"status": "In Progress",
"part": { "id": "part-ref-id", "name": "Bracket Assy", "revision": "C" },
"operations": [],
"customer_po": "PO-8812"
}
Because adding a field takes near-zero downtime, you can extend your applications without losing valuable production time.
Embedding: the right mental model, arriving in early 2027
The embed-versus-reference pattern is the right way to think about a document database. Today, Fuuz supports JSON schema and JSON objects within data models, and enumerations are handled by joining models, which keeps them clean and queryable. Full embedded document support is on the roadmap for early 2027. Apply the principles in your data models now, and they will be well placed as that support arrives.
Important: The most common mistake from SQL backgrounds is over-normalizing: creating a separate model for every sub-entity. Before you split, ask whether the data will ever be queried on its own, or whether it always travels with its parent. Fewer models often mean simpler queries, cleaner flows and easier screen bindings.
GraphQL APIs: where filtering and fetching live
Fuuz exposes multiple GraphQL APIs. This is where you declare the data you want, apply filters and predicates, and run aggregations. When you create or extend a data model, its GraphQL API is generated and updated automatically.
Conventions to know before you build
- Connection pattern: results are wrapped in
edges { node { ... } }. Your data lives onnode. - Sorting: use
orderBy: [{ field: "fieldName", direction: "asc" }], notsort. - Aggregation: put the group field and
_aggregate { id { count } }together insideedges.node. There is no separategroupByargument. - Record ID: always
id, never_id. - Filtering: use
wherevariables. Never fetch everything and filter in JSONata.
Good habits:
- Declare exactly what you need. A table row that shows three fields should request three fields.
- Filter with predicates. Predicates are your WHERE clause: field values, date ranges and relationships, applied before data reaches the screen or flow.
- Traverse relationships in one call. Walk from work center to work orders to operations in a single query.
- Aggregate at the query layer.
_aggregateruns on the server, which is far more efficient than counting in JSONata. - Always return
idfrom mutations. It confirms the write and gives you the ID for chained operations.
The GraphQL editor autocompletes and validates against the schema as you type, so field-name errors and type mismatches are caught before runtime.
Query with predicates
query GetOpenWOs($where: WorkOrderWhereInput, $orderBy: [WorkOrderOrderByInput!], $first: Int, $after: String) {
workOrder(where: $where, orderBy: $orderBy, first: $first, after: $after) {
pageInfo { startCursor endCursor hasNextPage hasPreviousPage }
total
edges {
node {
id
number
status
scheduledStart
part { name revision }
}
}
}
}
{
"where": { "workCenterId": { "_eq": "wc-123" }, "status": { "_eq": "Open" } },
"orderBy": [{ "field": "scheduledStart", "direction": "asc" }]
}
Mutation
mutation UpdateWOStatus($id: ID!, $status: String!, $completedAt: DateTime) {
updateWorkOrder(id: $id, data: { status: $status, completedAt: $completedAt }) {
id
status
completedAt
}
}
Aggregate query
The group field goes on the node, with _aggregate beside it. Each edge.node in the result contains the group field and its count.
query WOStatusCounts($where: WorkOrderWhereInput, $orderBy: [WorkOrderOrderByInput!]) {
workOrder(where: $where, orderBy: $orderBy) {
total
edges {
node {
workOrderStatusId
_aggregate { id { count } }
}
}
}
}
See also Aggregate Query API.
Anti-pattern: filtering in the wrong layer
Do not fetch every record and filter in a JSONata transform such as $[status = "Open"]. Filter in the query instead:
query {
workOrders(where: { status: { _eq: "Open" } }) {
id
status
number
}
}
API Explorer and Query Builder
The fastest way to learn Fuuz GraphQL is to let the built-in tools write it for you. Go to System > Business Intelligence > API Explorer in any tenant. The Query Builder is under Tools at the top of the editor, and everything runs against your tenant's schema in real time.
- Visual query building. Pick the model, check the fields you want, and the Query Builder writes the GraphQL.
- Relationships one click away. Related models appear as expandable nodes. Click one to include its fields, with no join syntax.
- Field descriptions. Click the pencil icon next to a field to see its description, data type and arguments.
- Live code view. Click ** GraphQL Code** to see the query as you build it, then copy it into a screen binding or flow node.
- Variable explorer. Click $ to see the available variables, such as
$where,$orderBy,$firstand$after, with their exact types. - Field arguments reduce scripting. Duration fields can return ISO 8601 text or milliseconds, and float fields can round up, to nearest or down. Check for an argument before writing a transform to format a value.
- Saved queries become APIs. Save a query or mutation from the Explorer and it appears in your external API list, with no server code to write.
A learning path
- Open API Explorer > Tools > Query Builder.
- Select your model and check the fields you need.
- Click the pencil on a field to read its description and arguments.
- Click $ to see the variables and their types.
- Click ** GraphQL Code** to view the generated query.
- Copy the query into your screen binding or flow node, and test it in the Explorer first.
Do this a dozen times across different models and Fuuz GraphQL will feel natural.
Data Flow Engine
The Data Flow Engine sits above the GraphQL layer. It is your application's business logic layer: built visually, with the option to drop into JavaScript for complex operations. Flows call GraphQL APIs to read and write data, then use the transformation layer to process the results. Keep that order in mind when you trace what triggered what.
| Flow type | What it does |
|---|---|
| Web flows | Triggered by user interactions on screens. They also act as remote function calls (RFC) that any external system can call. Web flows with screen context can use $components and screen elements directly. |
| Backend flows | Server-side logic triggered by events, schedules or API calls: integrations, data processing, ERP write-backs and anything that should not run on the client. |
| Gateway flows | Expose Fuuz logic to external systems as callable endpoints, so they can push data in or trigger operations. |
- Integrate without rip and replace. Connect external systems and work with their data where it lives, orchestrated through Fuuz flows, GraphQL queries and MCP tools.
- Flows are MCP tools. Every data flow is also a Model Context Protocol tool, so AI agents can orchestrate flows, and integration nodes can build deterministic agentic workflows.
- Pub/sub, topics and webhooks. Create your own topics and webhooks for real-time, event-driven integrations without polling.
- Debug with the console and logs. Every transform node shows its input and output in the Flow Console, so you can inspect the data at each step without adding debug nodes. Check the console of the relevant transform before assuming the problem is upstream.
Fuuz JSONata bindings
Standard JSONata is the foundation; Fuuz extends it with more than 200 platform bindings for screen state, user identity, flow context, joins and more. For the standard language, see the JSONata tutorial and the custom Fuuz-only JSONata library.
Two execution contexts
- Screen expressions run in the browser. You can use
$components,$metadata,$card,dataand$$. - Data flow expressions run on the server. You can use
$state,$state.contextand$state.claims. Web flows that run with screen context can also use$componentsand related screen bindings.
$components: work with screen elements
| Expression | What it does |
|---|---|
$components.Form1.fn.save() |
Save the form |
$components.Form1.fn.setValue("field", val) |
Set one field value |
$components.Form1.formState.dirty |
True when there are unsaved changes |
$components.Form1.formState.valid |
True when all validations pass |
$components.Form1.data.fieldName |
Read a field value |
$components.Table1.fn.search() |
Refresh the table |
$components.Table1.selectedRows |
All selected rows |
$components.Table1.selectedRows[0].id |
The first selected row's ID |
$components.Screen.fn.setContextValue("k", v) |
Set one screen context key |
$components.Screen.fn.mergeContext({...}) |
Shallow-merge values into the screen context |
$components.Screen.context.myKey |
Read a screen context value |
$metadata: URL, user and tenant
| Expression | What it does |
|---|---|
$metadata.urlParameters.id |
URL path parameter, such as /record/:id |
$metadata.querystring.tab |
Query string parameter, such as ?tab=details |
$metadata.user.id |
Signed-in user's ID |
$metadata.user.email |
Signed-in user's email |
$metadata.user.roles |
The user's roles |
$metadata.tenant.id |
Current tenant ID |
$metadata.tenant.name |
Current tenant name |
$metadata.settings.timezone |
The user's time zone |
$metadata.settings.dateFormat |
The user's date format |
$state: data flow context
| Expression | What it does |
|---|---|
$ |
The current node payload, shorthand for $state.payload |
$state.payload |
The full input payload of the current node |
$state.context |
Shared flow context, set with Set Context or Merge Context nodes |
$state.context.myKey |
Read one context value |
$state.claims.userId |
The triggering user's ID |
$state.claims.tenantId |
Tenant ID from the auth claims |
$state.claims.roles |
The user's roles from the auth claims |
$state.metadata.flowId |
The current flow's ID |
$state.lastError |
The error from the last caught error node |
data, $card and $$: element-level bindings
| Expression | What it does |
|---|---|
data.fieldName |
Read a field value inside a form element |
data.active ? "Yes" : "No" |
A condition on a boolean form field |
$card.data.id |
The card record's ID inside a cards element (not data.id) |
$card.data.status |
A field value on the card record |
$$.newValue |
The new value in an onChange handler |
$$.oldValue |
The previous value in an onChange handler |
$isNotNilOrEmpty($$.newValue) and $not($$.newValue = $$.oldValue) |
Fire only when the value is not empty and actually changed |
Join functions and utility bindings
/* $innerJoin: match records from both sides */
$innerJoin(workOrders, parts, "partId", "id")
/* $leftOuterJoin: keep every left record, match the right */
$leftOuterJoin(workOrders, scheduledShifts, "shiftId", "id")
/* $query: run a GraphQL query inline */
$query({ "api": "system", "statement": "query { ... }", "variables": { "id": "123" } })
/* $numeral: number formatting */
$numeral(12345).format("0,0") /* "12,345" */
$numeral(0.75).format("0%") /* "75%" */
$numeral(1234.5).format("$0,0.00") /* "$1,234.50" */
$now /* ISO 8601 timestamp */
$appConfig.someKey /* tenant app configuration */
Syntax gotchas
| Gotcha | Correct usage |
|---|---|
No ! operator |
Use $not(expr) |
String concatenation is & |
"a" & " " & "b", not + |
Equality is = |
x = "val", not == or === |
| Membership | x in [1,2,3], not .includes() |
No ?? operator |
$exists(val) ? val : "default" |
| Cards versus forms | Inside cards, use $card.data.x, not data.x |
Context is $ |
$ is the whole current input; confirm its shape in the console before writing transforms |
Coming from SQL? Reframe these habits
These are the closest useful parallels, not a one-to-one mapping.
| SQL and relational | Fuuz |
|---|---|
| Normalize everything and rebuild the object with joins at query time. | Design for your access pattern. Data that always travels together belongs together. |
| Schema changes are risky: ALTER TABLE, migrations, deployment windows and table locks. | Add a field in the schema designer with near-zero downtime. Field changes are low-risk, fast tasks. |
| Full embedded documents are expected in document databases. | Fuuz supports JSON schema and JSON objects in data models today, with full embedded document support on the roadmap for early 2027. Enumerations are joins between models. |
| You need a database diagram and documentation just to start a query. | Go straight to the Query Builder, where every relationship is a click away, and use the Application Graph to see how models, screens and flows connect. |
| Filter and aggregate with WHERE and GROUP BY. | Filter with where variables, aggregate with edges.node.fieldName and _aggregate, and sort with orderBy. JSONata reshapes; it does not filter. |
| Combine tables with INNER JOIN and LEFT JOIN in the database. | Combine results from several GraphQL queries in a flow transform with $innerJoin and $leftOuterJoin. |
| Backups, upgrades, scaling and disaster recovery are your team's job. | In Fuuz-hosted environments, infrastructure is managed and your focus is application delivery. Application performance on large datasets is still a design responsibility. |
Application Graph
The Application Designer includes the Application Graph: a live, visual knowledge graph of your application that shows every relationship between data models, screens and data flows in one interactive view. It reflects your actual application, not a static diagram.
- Data models as nodes. Relationships between models are edges, the same relationships the Query Builder and GraphQL schema expose.
- Screens and flows connected. See which screens and flows use each model, so you know the impact of a schema change before you make it.
- Your starting point. Open it at the start of every new engagement, troubleshooting session or impact analysis. Combined with the Query Builder, it takes you from no context to correct GraphQL in minutes.
Build patterns
- Design the query before the model. Start with what the screen needs to show, and shape the document to make that query simple.
- Filter in GraphQL, reshape in JSONata. Filter with
where, count and sum with_aggregate, sort withorderBy, and use JSONata only to reshape the filtered response. - Always return
idin mutations. It confirms the write and feeds chained operations. - Check the console before assuming the problem is upstream. Every transform shows its input and output.
- Request only what the screen uses. Over-fetching adds payload and makes bindings harder to read.
- Know your JSONata context. Screens use
$components,$metadata,dataand$card; flows use$state. Mixing them up fails silently. - Map ERP writes to mutations early. When you scope an ERP integration, such as NetSuite, Plex or Business Central, identify the target mutations before writing flow logic.
- Treat field additions as low-risk. Adding a field is fast and has near-zero downtime. The real effort is in flow logic, behavior changes and performance tuning on large collections.
The build loop: confirm the document shape in the schema designer, write the GraphQL query with predicates, reshape the result with JSONata, bind it to the screen element or flow output, then inspect each layer in the console and logs before calling it done.
Troubleshooting: debug by layer
Start at the data layer and work up, every time.
| Symptom | Suspect layer | Where to look |
|---|---|---|
| Screen shows no data | GraphQL query or predicate | Run the query on its own. Does the predicate return records? Is the field name right? |
| Wrong data on screen | JSONata binding or wrong field | Check the raw GraphQL response first. If it is right, check the JSONata expression in the transform console. |
| Screen loads slowly | Dynamic expressions or missing indexes | Usually dynamic bindings, such as visibility expressions, running too often, or complex filters on large collections without an index. |
| Flow not triggering | Trigger configuration | Confirm the trigger event matches the action, and check the flow execution logs. |
| Flow fails mid-run | A specific node | Open the flow execution history to find the failing node, and inspect the input it received in the console. |
| Mutation fails | Field name or type mismatch | Read the error; GraphQL errors are explicit. Remember id, not _id. |
| Integration writes missing | Mutation not running or wrong tenant | Confirm the mutation ran in the execution logs, check the tenant context and check the id returned. |
| JSONata returns null | Wrong context or field path | Are you in a screen or a flow? $state works only in flows, $components and $metadata only in screens, and cards use $card.data.x. |
The debug order:
- Does the data exist? Run a GraphQL query to confirm.
- Does the query return it correctly? Run the exact binding query, with its predicates, on its own.
- Does the JSONata transform it correctly? Inspect the input and output in the transform console.
- Does the screen element bind correctly? Check the element's property mapping.
Skipping steps leads to false diagnoses.
What managed infrastructure means for your scope
When Fuuz hosts your environment, it changes what belongs in your delivery scope.
- Schema changes are near-instant. Add fields with near-zero downtime and iterate on the data model across sprints, without upgrade events or deployment windows.
- Security is inherited. Encryption at rest and in transit, identity and access management and audit logging are part of the platform. Do not scope custom security plumbing.
- Infrastructure is not in your scope. In Fuuz-hosted environments there are no servers, database setup, backups or failover planning for you to run. In customer-hosted environments, the customer is responsible for applying updates.
- Know the release cadence. Fuuz-hosted production environments are updated monthly, and the latest changes reach build environments first. Confirm feature availability for your release when you scope.
- Platform scale is not app performance. The platform scales, but how your application handles large datasets is a design concern you own. Complex flow logic, unindexed queries on large collections and overly dynamic bindings all affect performance. Load test with realistic data before go-live.
- Multi-site is configuration. Adding a plant or region to an existing deployment is a configuration task, not new infrastructure.
At a glance: adding a field to a model takes near-zero downtime, and one GraphQL call returns a fully hydrated object graph.
Related articles
Need more help?
Ask the Fuuz community, or open a ticket with Fuuz support.