Skip to content

Quick start

This page builds two services, runs them in one process, then runs them in two without touching their code. It needs the project from Installation, and Litestar for the HTTP side:

$ uv add litestar

An interface

A service that others call publishes an interface. Its messages are frozen structs, since they may cross a process boundary. src/hello/api.py:

from typing import Protocol

import msgspec


class Name(msgspec.Struct, frozen=True):
    name: str


class Greeting(msgspec.Struct, frozen=True):
    text: str


class GreeterAPI(Protocol):
    async def greet(self, who: Name) -> Greeting: ...

A service

src/hello/greeter.py implements the interface. Invalid is one of the runtime's errors, which mean the same in every layout:

from hello.api import GreeterAPI, Greeting, Name
from myxo import Invalid, Service


class Greeter(Service):
    name = "greeter"
    api = GreeterAPI

    async def greet(self, who: Name) -> Greeting:
        if not who.name.strip():
            raise Invalid("a greeting needs a name")
        return Greeting(f"Hello, {who.name}!")

A service that calls it

src/hello/web.py serves HTTP with Litestar, and reaches the greeter through a client. Its stage of 2 starts it after the greeter, whose stage is 1 by default:

from collections.abc import Callable

import msgspec
from litestar import Litestar, Request, Response, get

from hello.api import GreeterAPI, Greeting, Name
from myxo import Runtime, Service, ServiceError
from myxo.transport import serve_asgi


class WebConfig(msgspec.Struct, forbid_unknown_fields=True):
    port: int = 8000


def error(_request: Request, exc: ServiceError) -> Response:
    return Response({"error": str(exc)}, status_code=exc.status)


class Web(Service):
    name = "web"
    stage = 2
    Config = WebConfig

    def __init__(self, config: WebConfig, runtime: Runtime) -> None:
        super().__init__(config, runtime)
        self.greeter = runtime.client(GreeterAPI)

    async def run(self, ready: Callable[[], None]) -> None:
        greeter = self.greeter

        @get("/hello/{name:str}")
        async def hello(name: str) -> Greeting:
            return await greeter.greet(Name(name))

        app = Litestar([hello], exception_handlers={ServiceError: error})
        address = ("127.0.0.1", self.config.port)
        await serve_asgi(app, address, ready, self.runtime.stopping.set)

Entry points

The runtime finds services through entry points, and imports only those a process runs. Add this to pyproject.toml:

[project.entry-points."myxo.services"]
web = "hello.web:Web"
greeter = "hello.greeter:Greeter"

[project.entry-points."myxo.apis"]
greeter = "hello.api:GreeterAPI"

The second table lets a process call the greeter without importing its code. Install the project again, since the runtime reads entry points from the installed package:

$ uv sync

One process

layouts/one.toml puts both services in one process. ${PORT:-8000} reads the environment, with a default:

[processes.all]
services = ["web", "greeter"]

[services.web]
port = "${PORT:-8000}"

Check it, then run it:

$ uv run myxo check layouts/one.toml
layouts/one.toml: ok
$ uv run myxo serve layouts/one.toml

From another terminal:

$ curl -s localhost:8000/hello/Ada
{"text":"Hello, Ada!"}
$ curl -s localhost:8000/hello/%20
{"error":"a greeting needs a name"}

The second answer has status 422, from Invalid. Stop the process with Ctrl-C.

Two processes

layouts/two.toml moves the greeter to its own process. A process that others call needs a url, here a Unix socket that only your user can open:

[processes.web]
services = ["web"]

[processes.greeter]
services = ["greeter"]
url = "unix:run/greeter.sock"

[services.web]
port = "${PORT:-8000}"

myxo up runs every process of a layout, and restarts one that exits:

$ uv run myxo up layouts/two.toml

The same requests give the same answers, with status 422 for the bad name. The call now crosses the socket. You can make it yourself, which shows what travels between processes:

$ curl -s --unix-socket run/greeter.sock -X POST localhost/rpc/greeter/greet -d '{"name":"Ada"}'
{"text":"Hello, Ada!"}
$ curl -s --unix-socket run/greeter.sock localhost/readyz
[]

An empty list from /readyz means that nothing keeps the process from serving.

What happened

No line of the services changed between the two runs, and neither service knows which layout runs it. The model explains why that holds. To add a database, events, and a deployment, follow the tutorial.