Add your first connector
Your first service transformed data in process; real services talk to databases. This tutorial connects Orion to PostgreSQL and builds a service that writes an order and reads the customer’s history back.
What you will learn
- What a connector is, and why credentials never enter the definition.
- How the portable data dialect keeps request data out of SQL text.
- What an operation gate does, and where it is enforced.
- How a workflow inserts a row and reads related rows back in one request.
Before you start
Tested with Orion 1.8.1. You need:
- an Orion server on
http://localhost:8080, and Build your first service behind you - Git, and Docker with Compose
curland a POSIX shell (on Windows, WSL)
A connector is a named, reusable connection to an external system. You configure it once through the admin API and reference it by name from any workflow. The runtime holds the credentials, the pool, the retries and the circuit breaker.
Every file below ships in the repository, so clone it first:
git clone https://github.com/GoPlasmatic/Orion.git
cd Orion/examples/packages/postgres-orders
Tip
docker compose up -d && cd ../.. && ./deploy.sh postgres-ordersbuilds the whole thing in two commands. The steps below do it one piece at a time, so you can see what each piece is for.
1. Start a database
The directory ships a compose file and seed data, with two customers and three orders:
docker compose up -d postgres
Then start Orion, telling it where the database is:
ORDERS_DB_URL=postgres://orion:orion@localhost:5432/orion_orders orion-server
2. Create the connector
This is the connector definition:
{
"id": "orders-db",
"name": "orders-db",
"connector_type": "db",
"tags": ["pkg:postgres-orders"],
"config": {
"type": "db",
"connection_string": "${ORDERS_DB_URL:-postgres://orion:orion@postgres:5432/orion_orders}",
"max_connections": 5,
"operations": { "delete": false },
"allow_private_urls": true
}
}
Post it. Connectors are live on creation; there is no activation step, and the registry reloads on every connector change:
curl -s -X POST http://localhost:8080/api/v1/admin/connectors \
-H 'Content-Type: application/json' --data @connector.json
Three details are worth copying into every real deployment:
- The connection string is an environment reference.
${ORDERS_DB_URL:-…}is substituted from the server’s environment when the connector loads. The saved config carries no credentials, and the same JSON works in every environment. "operations": { "delete": false }makes the connector delete-proof. Operation gates are enforced at the connector, whatever a workflow asks for.allow_private_urlsis required for a private address. Orion blocks connections to private ranges by default. A database onlocalhostor a container network is the normal case for saying so explicitly.
3. Create the workflow
Three tasks: parse the request, insert the order, read back the customer with their order history:
{
"workflow_id": "record-order",
"name": "Record Order (Postgres)",
"description": "Insert an order with data_write, then return the customer with their order history via data_query + include.",
"tags": ["pkg:postgres-orders"],
"condition": true,
"tasks": [
{
"id": "parse",
"name": "Parse payload",
"function": { "name": "parse_json", "input": { "source": "payload", "target": "req" } }
},
{
"id": "validate",
"name": "Validate order",
"halt_on": "failure",
"function": {
"name": "validation",
"input": {
"rules": [
{
"logic": { "!!": [{ "var": "data.req.customer_id" }] },
"message": "customer_id is required"
},
{ "logic": { "!!": [{ "var": "data.req.item" }] }, "message": "item is required" },
{
"logic": { ">": [{ "var": "data.req.total" }, 0] },
"message": "total must be positive"
}
]
}
}
},
{
"id": "record",
"name": "Insert the order",
"function": {
"name": "data_write",
"input": {
"connector": "orders-db",
"write": {
"op": "insert",
"target": "orders",
"values": {
"customer_id": { "param": "customer_id" },
"item": { "param": "item" },
"total": { "param": "total" }
},
"returning": ["id"]
},
"params": {
"customer_id": { "var": "data.req.customer_id" },
"item": { "var": "data.req.item" },
"total": { "var": "data.req.total" }
},
"schema": {
"entities": {
"orders": {
"columns": {
"id": { "type": "int", "writable": false },
"customer_id": { "type": "int" },
"item": { "type": "text" },
"total": { "type": "float" }
}
}
}
},
"output": "data.created"
}
}
},
{
"id": "history",
"name": "Fetch customer with order history",
"function": {
"name": "data_query",
"input": {
"connector": "orders-db",
"query": {
"source": "customers",
"filter": { "==": [{ "field": "id" }, { "param": "customer_id" }] },
"include": {
"orders": {
"fields": ["id", "item", "total"],
"sort": [{ "id": "desc" }],
"limit": 10
}
}
},
"params": { "customer_id": { "var": "data.req.customer_id" } },
"schema": {
"entities": {
"customers": {
"columns": {
"id": { "type": "int" },
"name": { "type": "text" },
"email": { "type": "text" }
},
"relations": {
"orders": {
"to": "orders",
"kind": "has_many",
"local": "id",
"foreign": "customer_id"
}
}
},
"orders": {
"columns": {
"id": { "type": "int" },
"customer_id": { "type": "int" },
"item": { "type": "text" },
"total": { "type": "float" }
}
}
}
},
"output": "data.customer"
}
}
}
]
}
Create and activate it:
curl -s -X POST http://localhost:8080/api/v1/admin/workflows \
-H 'Content-Type: application/json' --data @workflow.json
curl -s -X PATCH http://localhost:8080/api/v1/admin/workflows/record-order/status \
-H 'Content-Type: application/json' -d '{"status":"active"}'
How the pieces fit:
{ "param": "total" }marks a value slot. Theparamsmap is the only place request data enters a query, and every resolved value is a bound parameter, never interpolated text. The dialect is injection-safe by construction.- The inline
schemadeclarescustomers has_many orders. That relation is what powers"include": { "orders": … }. The schema also permits the query: the dialect rejects undeclared entities and columns, so a task without one reaches nothing. - An
includestates its ownsort. The per-customer page is cut inside the database, so “the latest 10 orders” needs an order key. "returning": ["id"]captures the generated key from the insert.
4. Expose it as a service
The channel binds POST /record-order to the workflow:
{
"channel_id": "record-order",
"name": "record-order",
"tags": ["pkg:postgres-orders"],
"channel_type": "sync",
"protocol": "rest",
"methods": ["POST"],
"route_pattern": "/record-order",
"workflow_id": "record-order"
}
Create and activate it:
curl -s -X POST http://localhost:8080/api/v1/admin/channels \
-H 'Content-Type: application/json' --data @channel.json
curl -s -X PATCH http://localhost:8080/api/v1/admin/channels/record-order/status \
-H 'Content-Type: application/json' -d '{"status":"active"}'
Both calls answer a successful admin envelope, and orion-cli channels list shows record-order as active.
5. Call it
Send the sample request that ships beside the definitions:
curl -s -X POST http://localhost:8080/api/v1/data/record-order \
-H 'Content-Type: application/json' --data @request.json
Output:
{
"status": "ok",
"data": {
"created": { "status": "ok", "rows_affected": 1, "returning": [{ "id": 4 }] },
"customer": [{
"id": 1, "name": "Ada Lovelace", "email": "ada@example.com",
"orders": [
{ "id": 1, "item": "Analytical Engine Manual", "total": 120.0 },
{ "id": 2, "item": "Punch Card Set", "total": 35.5 },
{ "id": 4, "item": "Difference Engine Blueprint", "total": 4200.0 }
]
}]
}
}
One request did a parameterized insert and then a relation-hydrated read.
Switching backends
Nothing in that workflow is PostgreSQL-specific. Point orders-db at MySQL or SQLite and it renders different SQL. Point it at MongoDB or Elasticsearch and the same envelope renders a find filter or a Query DSL search. Portable data dialect has the vocabulary and the per-backend notes.
Clean up or run it again
The deployment script skips definitions that already exist, but every request inserts another order. Stop the local database and remove its data volume from the package directory:
docker compose down -v
If Orion was running outside that Compose project, delete record-order as a channel and as a workflow, then delete the orders-db connector, with orion-cli … delete --yes. Deletion is permanent; keep the definitions if you are continuing to the next tutorial.
Recap
- A connector is configured once and referenced by name. Four things came with it, and none of them are in the workflow: a connection pool capped at
max_connections, a circuit breaker, a delete gate enforced below the logic, and credentials that never entered the database. - Request data enters a query only through
params, as bound values. - The inline schema both permits the query and declares the relations an
includecan hydrate. - The same envelope renders SQL, a MongoDB filter or an Elasticsearch query, depending only on the connector it names.
Next steps
- Test and promote a service: dry-run this workflow with the database stubbed out, then ship it to another instance.
- Connectors: the idea, and the other six types.
- Connector types: every field of every type, with gates, retries and secret handling.
- Portable data dialect: operators, the schema registry, relations, write envelopes and safety guards.
Last verified 14 September 2026