jfastframework
Docs Español PyPI GitHub

Workspaces and the API gateway

A single service needs no workspace. The moment there are two, three questions appear that per-service config cannot answer:

jfast.workspace.toml answers all three, and it is the only file that knows the whole system.


Resources: datastores with names

A workspace used to record datastores on a service by type:

datastores = ["database", "cache"]

Two things followed from that, and both were bugs wearing the costume of a design. The generated compose file created a billing-database container and nothing wrote the DSN that pointed at it -- the connection string stayed hand-maintained in a .env beside a container that was generated, which is exactly where drift lives. And a second PostgreSQL could not be expressed at all, because there was no name to hang the second instance on.

A resource has a name. A service binds to it under a variable.

[[workspace.resources]]
name = "core-db"
type = "postgres"
port = 8900
database = "core"

[[workspace.resources]]
name = "shared-redis"
type = "redis"
port = 8901

[[workspace.services]]
name = "billing"
kind = "api"
port = 8010
path = "billing"
uses = [
  { resource = "core-db", as = "JFAST_DB_DSN" },
  { resource = "shared-redis", as = "JFAST_CACHE_URL" },
]

as defaults from the type -- a postgres resource lands in JFAST_DB_DSN, which is what DatabaseSettings reads -- so the common case needs no configuration to be correct.

The commands

jfast workspace resource analytics-db --type postgres --database analytics
jfast link billing analytics-db --as JFAST_ANALYTICS_DSN
jfast link catalog shared-redis
jfast unlink catalog catalog-cache
jfast workspace resource catalog-cache --remove
jfast workspace validate
jfast workspace env          # writes every .env from the bindings
jfast workspace compose      # one container per resource
jfast workspace graph        # mermaid, edges labelled with the variable

Types are postgres, redis, mongo and qdrant. Resources take ports from a band starting at base_port + 900, above the base port and below the first service block, so a resource can never land inside a service's ten-port block.

Containers a plugin owns

Those four types are the datastores the workspace names and shares. Everything else a service needs comes from its plugins: events owns a Kafka broker, storage can own MinIO, queue on the RabbitMQ backend owns a broker of its own. None of them is a resource, and for a while jfast workspace compose emitted the same file whether the plugin was enabled or not.

It now reads each service's jfast.toml -- the plugin list lives there, not in the workspace file -- and emits what those plugins declare:

Two services enabling events get one broker, not two. A broker advertises its own container name as the address clients reconnect to, so a second copy under a different name would advertise an address that does not reach it.

A plugin the generator cannot read -- an extra missing from this environment, a settings block it rejects, a [plugins.paths] module that does not import from the workspace root -- is a warning on stderr, not silence and not a crash. Silence was the original defect: the container was simply not in the file, and nothing said so. The crash was the second: a dotted path took the whole workspace's compose file with it, including the services that were fine.

$ jfast workspace compose
UserWarning: social: cannot inspect plugin 'social_runtime' (Cannot import
plugin module 'social_plugins.runtime': No module named 'social_plugins'), so
any container it declares is missing from the generated compose file.
wrote docker-compose.yml

compose runs at the workspace root, so that is where it looks for a module a service names by dotted path. A plugin package kept inside one service resolves when you run that service and not when you generate the workspace file — which is what the warning is for.

A service with no jfast.toml (a Go service, a directory nothing has generated yet) is skipped, and a workspace held only in memory has nothing to read.

What is refused, and why

$ jfast link billing analytics-db
'billing' already binds 'core-db' to JFAST_DB_DSN. Two resources cannot share
one variable -- pass --as to choose another.

Two databases both defaulting to JFAST_DB_DSN is the ordinary way to get a service quietly talking to the wrong one. jfast workspace validate catches the rest: a port claimed twice, a binding to a resource that does not exist, a resource nobody uses.

Credentials

One password per resource, generated into the workspace .env by jfast workspace env and never overwritten once set -- rotating a password is a decision, and silently changing one locks a running container out of its own volume. jfast workspace init adds .env to .gitignore at the same time, so it cannot be committed by accident.

Each service's generated .env references the secret rather than repeating it:

JFAST_DB_DSN=postgresql+asyncpg://app:${CORE_DB_PASSWORD}@core-db:5432/core

Coming from a 0.1 workspace

Nothing breaks. A service with the old datastores list still generates the same containers on the same ports; the resources are simply implied rather than named. When you want the explicit form:

jfast workspace migrate-resources

Ports are preserved, so the compose file it produces afterwards is the one it produced before. Running it twice changes nothing.

Start one

jfast workspace init cometax

From then on, every service created at or below that directory registers itself, takes the next free port block, and appears in jfast workspace list.

jfast new service billing --with database,cache
jfast new service catalog --with qdrant,rag
jfast new service admin --kind spa --frontend vue
jfast workspace list
workspace : cometax
api url   : http://localhost:8030

billing          api      :8010    billing
catalog          api      :8020    catalog
gateway          gateway  :8030    gateway
admin            spa      :8040    admin  (vue)

Port blocks, not ports

Each service owns ten consecutive ports. That is not tidiness: the plugins inside a service claim offsets in that block — PostgreSQL at +1, Redis at +3, Qdrant at +7 and +8. Handing the next service a consecutive port would make two services fight over the same database container port.

--port overrides the allocation; taking one already in use is refused with the next free block in the message.


The gateway appears on its own

jfast new service catalog --with qdrant,rag
The workspace now has 2 backends and no gateway.
Generating one so clients need a single hostname:
  created  gateway/jfast.toml
  ...
Gateway on port 8030, routing 2 backend(s):
  /billing         -> http://billing:8010
  /catalog         -> http://catalog:8020

One backend does not get a gateway. It would add a network hop and an outage surface for nothing. Two is where clients start having to know too many hostnames, so that is the threshold.

Frontends do not count as backends: an API and a Vue app are still one thing to call.

Refresh it after adding a service

jfast workspace gateway --force

The routes in gateway/jfast.toml are derived from the workspace file. Do not hand-edit them — regenerate, the same way you regenerate compose.

What the gateway does per request

What it deliberately does not do

No catch-all route. Only configured prefixes are proxied, so /health, /ready and /metrics still belong to the gateway itself. A catch-all would proxy the gateway's own liveness probe to whichever service sorted first.

No upstream probing in /ready. A gateway whose upstream is restarting is still doing its job. Probing upstreams there turns one service's restart into a whole-system readiness failure.

No business logic. Auth, rate limiting and caching are plugins you enable on the gateway. Domain code is not — put it there and every team ships through your gateway's deploy queue.


What the frontend calls

jfast workspace env

Rewrites every frontend's .env:

VITE_API_URL=http://localhost:8030

The value is the gateway when there is one and the single backend when there is not. That is exactly the string that goes stale by hand the day a gateway appears, which is why it is generated.


The file

[workspace]
name = "cometax"
base_port = 8000

[[workspace.services]]
name = "billing"
kind = "api"
port = 8010
path = "billing"

[[workspace.services]]
name = "admin"
kind = "spa"
port = 8040
path = "admin"
frontend = "vue"

Commit it. It holds no secrets, and it is what makes port allocation and gateway generation reproducible on someone else's machine.

Commands

Command Does
jfast workspace init <name> Create the file
jfast workspace list [--json] Services, kinds, ports, API base URL
jfast workspace gateway [--force] Generate or refresh the gateway
jfast workspace env Rewrite every frontend's .env

--json on list returns the whole workspace, including needs_gateway and api_base_url — the shape an agent should read instead of parsing the TOML.


Deploying the workspace

One file, one docker compose up:

jfast workspace compose     # docker-compose.yml: every service, one network
jfast workspace caddy       # Caddyfile: one hostname in front of all of them
jfast workspace env         # every service's .env, DSNs included

A service can still generate its own, from its own plugin graph:

cd billing && jfast deploy compose -o docker-compose.yml

The two do not name datastores the same way, on purpose — read deploy.md, Two generators before moving a service from one to the other, because the password variable and the volume change with the name.

Neither pins a container_name, so two workspaces — or an old copy of one beside a new one — run at the same time under different compose project names (docker compose -p old, docker compose -p new).