
Get Started in Five Minutes: Query Across Two APIs Using Orbital
When data lives across multiple services, assembling a single response normally means writing glue code: call service A, call service B, join the results, handle failures. Orbital removes that step. We describe how the services relate once, in Taxi, and query across them as if they were one.
In this guide, we add Orbital to a small example repo of two REST APIs (orders and customers). By the end, a single TaxiQL query returns orders with customer names without writing any join logic.
What we'll build:
- Taxi schemas describing how the two APIs relate
- An Orbital container running alongside the APIs in Docker
- A single query that joins data across both services on demand
Prerequisites:
- Docker and Docker Compose installed and running
- Basic familiarity with REST APIs
1. Clone and Run the Example APIs
The example repo contains two Express services with hardcoded data: orders-api on port 4001 and customers-api on port 4002. Clone it and bring both services up:
git clone https://github.com/ritza-co/orbital-quickstart-hello-world-apis.git
cd orbital-quickstart-hello-world-apis
docker compose up -d --build
Verify both services are answering:
curl http://localhost:4001/orders
curl http://localhost:4002/customers/cust-1
Each service answers independently. An order has a customerId but no customer details. The customers service returns a name and email but knows nothing about orders. To return both in one response, a client has to call orders, loop over the results, call customers for each unique ID, and stitch the responses together. We replace that loop with a single Orbital query in the rest of this guide.
2. Describe the Services in Taxi
Orbital uses Taxi to model how services relate. When two fields across different APIs share the same Taxi type, Orbital can use one to look up the other. That shared type is what makes automatic joins possible.
We add four files inside a new workspace/ directory in the repo: three Taxi sources for the project and one workspace.conf that registers the project with Orbital.
hello-world-apis/
├── orders-api/
├── customers-api/
├── docker-compose.yml
└── workspace/
├── workspace.conf
└── projects/
└── hello-world/
├── taxi.conf
└── src/
├── types.taxi
└── services.taxi
Each file plays a different role: taxi.conf identifies the project, types.taxi declares the shared semantic types, services.taxi tells Orbital which service exposes which type, and workspace.conf tells the running Orbital instance where to find the project.
2.1 Create the Project Config
Create workspace/projects/hello-world/taxi.conf:
name: com.example/hello-world
version: 0.1.0
sourceRoot: src/
The name and version fields together identify the project. Orbital uses them to track schemas and surface the project in its UI. sourceRoot tells the compiler where to find the .taxi files relative to this config.
2.2 Define Types and Models
Create workspace/projects/hello-world/src/types.taxi:
namespace com.example
type OrderId inherits String
type CustomerId inherits String
type OrderAmount inherits Decimal
type CustomerName inherits String
type CustomerEmail inherits String
model Order {
id: OrderId
customerId: CustomerId
amount: OrderAmount
}
model Customer {
id: CustomerId
name: CustomerName
email: CustomerEmail
}
The five type declarations are the semantic layer. The declaration CustomerId inherits String means the value is a string, but Orbital treats it as a distinct type, so it never confuses a CustomerId with an OrderId even though both are strings underneath. The two model blocks describe the JSON shapes returned by each API.
2.3 Map Services to Types
Create workspace/projects/hello-world/src/services.taxi:
namespace com.example
service OrdersApi {
@HttpOperation(method = "GET", url = "http://orders-api:4001/orders")
operation getOrders(): Order[]
}
service CustomersApi {
@HttpOperation(method = "GET", url = "http://customers-api:4002/customers/{id}")
operation getCustomer(@PathVariable("id") id: CustomerId): Customer
}
The link between the two services is CustomerId. Both Order.customerId and the getCustomer parameter are typed as CustomerId, so Orbital knows it can call getCustomer using the customerId from any Order.
2.4 Register the Project With Orbital
Create workspace/workspace.conf:
file {
changeDetectionMethod=WATCH
pollFrequency=PT5S
recompilationFrequencyMillis=PT3S
projects=[
{
isEditable=true
path="/opt/service/workspace/projects/hello-world"
}
]
}
The path is the container-side path, not the host path. With WATCH and the recompilation frequency, edits to the Taxi files take effect within a few seconds without a restart, which is useful while iterating.
3. Add Orbital to Docker Compose
Open the existing docker-compose.yml (which currently only defines the two APIs) and add two more services: a Postgres instance to store query history, and Orbital itself.
3.1 Add Postgres
Append Postgres with a healthcheck so Orbital waits until the database is ready before connecting:
postgres:
image: postgres:15
environment:
POSTGRES_DB: orbital
POSTGRES_USER: orbital
POSTGRES_PASSWORD: changeme
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U orbital"]
interval: 5s
retries: 10
The healthcheck is what makes depends_on: condition: service_healthy work in the next block. Without it, Orbital would try to connect before Postgres was ready and fail to start.
3.2 Add Orbital
Two volumes are critical: the config mount is where Orbital writes runtime data such as its license file and query cache, and the workspace mount is where it reads the Taxi schemas from step 2:
orbital:
image: orbitalhq/orbital:next-jammy
user: "${UID}:${GID}"
ports:
- "9022:9022"
volumes:
- ./config:/opt/service/config
- ./workspace:/opt/service/workspace
environment:
OPTIONS: >-
--vyne.app.data.path=/opt/service/config
--vyne.db.username=orbital
--vyne.db.password=changeme
--vyne.db.host=postgres
--vyne.workspace.config-file=/opt/service/workspace/workspace.conf
--vyne.analytics.persistRemoteCallResponses=true
depends_on:
postgres:
condition: service_healthy
volumes:
postgres_data:
The user field runs Orbital as your local user, so it can write to ./config without permission errors.
3.3 Create the .env File
Create a .env file alongside docker-compose.yml for Compose to substitute ${UID} and ${GID}:
echo "UID=$(id -u)" > .env && echo "GID=$(id -g)" >> .env
Docker Compose reads .env automatically on the next up.
3.4 Start the Stack
Bring the full stack up:
docker compose up -d --build
The first run pulls the Orbital image (around 1 GB) and Postgres, so it can take a few minutes; subsequent runs start in seconds.
3.5 Verify Orbital Is Ready
Once the pull is done, Orbital takes about 30 seconds to initialize. Check it's up before continuing:
curl http://localhost:9022/api/actuator/health
The response should include "status":"UP". Then confirm the Taxi project loaded:
curl http://localhost:9022/api/packages
The response should include an entry with "id": "com.example/hello-world/0.1.0" and "status": "Healthy". If the status is anything else, run docker logs orbital. Schema syntax errors show up there with line numbers.
4. Run a Query
With everything running, we can now query across both services with a single TaxiQL request:
curl -X POST http://localhost:9022/api/taxiql \
-H "Content-Type: application/taxiql" \
-H "Accept: application/json" \
-d 'find { Order[] } as {
id: OrderId
amount: OrderAmount
customerName: CustomerName
}[]'
Orbital returns:
[
{ "id": "ord-1", "amount": 42.5, "customerName": "Alice Smith" },
{ "id": "ord-2", "amount": 18.0, "customerName": "Bob Jones" },
{ "id": "ord-3", "amount": 95.2, "customerName": "Alice Smith" }
]
We asked for CustomerName, a field that doesn't exist on Order. Orbital resolved it by:
- Calling
GET /ordersto get the list of orders - Noticing each order has a
CustomerId - Recognizing that
CustomersApi.getCustomeraccepts aCustomerIdand returns aCustomerName - Calling
GET /customers/{id}for each order and assembling the result
The query ran across two services without any join code. The shared CustomerId type in the Taxi schemas made this possible.
How Orbital Resolves the Query
Orbital treats every Taxi type as a node in a graph. When a query asks for a type that isn't directly on the requested model, Orbital walks the graph to find a path, calling whichever services it needs along the way:
Query:
find { Order[] } as {
id: OrderId
amount: OrderAmount
customerName: CustomerName ← not a field on Order
}[]
│
▼
Orbital
│
├─ GET /orders
│ ↓
│ id:"ord-1" customerId:"cust-1" amount:42.50
│ id:"ord-2" customerId:"cust-2" amount:18.00
│ id:"ord-3" customerId:"cust-1" amount:95.20
│
│ CustomerName not on Order, but Order has CustomerId,
│ and getCustomer(CustomerId) returns CustomerName
│
├─ GET /customers/cust-1 → "Alice Smith"
├─ GET /customers/cust-2 → "Bob Jones"
└─ GET /customers/cust-1 → "Alice Smith" (cached)
│
▼
Response:
[{ "id": "ord-1", "amount": 42.5, "customerName": "Alice Smith" },
{ "id": "ord-2", "amount": 18.0, "customerName": "Bob Jones" },
{ "id": "ord-3", "amount": 95.2, "customerName": "Alice Smith" }]
To add a third service (say, a restaurants-api returning the restaurant a customer last ordered from), declare its types and operations in services.taxi alongside the other two. Any existing query that requests those types automatically uses the new service, with no changes required to the query or to the other services.