Что такое Urich

Каркас для бэкендов в стиле DDD и CQRS: приложение как кирпичики, модуль = bounded context, внутри — команды, запросы, handler'ы. На Python: объявил модуль и handler-классы — склеил одной цепочкой. Один endpoint — один класс и одна строка в модуле.

В Rust хотел то же самое. Плюс вынести общую часть в ядро и иметь одну реализацию слоя приложения — чтобы правки в ядре расходились и на Rust, и на Python.

  • urich-core (Rust) — не только протокол (scope/receive/send), роутинг и HTTP, но и слой приложения: Application, Container, Module, Handler, middlewares, discovery, outbox. Всё это в одном крейте: меняешь контракт или поведение — обновляются оба фасада.

  • urich-rs — конкретные фичи поверх ядра: DomainModule, Command/Query, CommandHandler/QueryHandler, RPC, discovery, events. Регистрация маршрутов и handler'ов уходит в core; в urich-rs остаётся только «как описать модуль на Расте».

  • Python — биндинги к тому же ядру (PyO3). Использует тот же Application: add_command_route/add_query_route, один set_external_callback(python_callable), run_from_env. Бэкенд на растовом core, без Starlette.

Итого: одно ядро с общим слоем приложения, две обёртки. Одна ментальная модель, одна кодовая база для движка.


В чём была боль (и что сделал)

Без прослойки один endpoint в Rust — это десятки строк: метод, путь, замыкание с lock/resolve/deserialize на каждый раз. Вынес рутину в ядро и обёртку. Ниже — было/стало и как устроено.


Было / Стало (один endpoint)

Было — на каждый endpoint ты пишешь что-то в таком духе:

// Роут вручную
router.add("POST", "employees/commands/create_employee", route_id);

// Handler — замыкание с полной рутиной
let container = Arc::clone(&container);
core.set_callback(Box::new(move |rid, body, _ctx| {
    let container = Arc::clone(&container);
    Box::pin(async move {
        let body: Value = serde_json::from_slice(body).map_err(|e| ...)?;
        let mut guard = container.lock().unwrap();
        let handler = guard.resolve::<CreateEmployeeHandler>().map_err(...)?.clone();
        drop(guard);
        let cmd: CreateEmployee = serde_json::from_value(body).map_err(...)?;
        handler.handle(cmd).await.map_err(...)
    })
}));
// + то же для следующего endpoint, и ещё, и ещё...

Стало — в модуле одна строка, логика в типе и в handle:

// Модуль
DomainModule::new("employees")
    .command_with_handler::<CreateEmployee, CreateEmployeeHandler>()
    // ...

// Handler — только зависимость и handle
struct CreateEmployeeHandler { db: Db }
impl CommandHandler<CreateEmployee> for CreateEmployeeHandler {
    async fn handle(&self, cmd: CreateEmployee) -> Result<Value, CoreError> {
        create_employee(cmd, &self.db)  // твоя логика
    }
}

// main — один раз фабрики
app.with_container_mut(|c| {
    c.register_factory(|c| CreateEmployeeHandler { db: c.resolve::<Db>()?.clone() });
});
app.register(&mut employees_module())?;

Маршрут, lock, resolve, deserialize и вызов handle делает фреймворк.


Ядро: что там есть (псевдокод)

// Протокол: один контракт для HTTP / WebSocket / lifespan
Scope = Http { method, path, headers } | WebSocket { ... } | Lifespan
сервер: scope + receive → приложение → send(ответ)

// Низкий уровень: роутер + один callback
router: (method, path) → RouteId
core.set_callback(fn(route_id, body, context) → Response)

// Слой приложения (тот же крейт): таблица handler'ов + контейнер
Application {
    core: App,
    handlers: Map<RouteId, (Value, Container) -> Future<Response>>,
    container: Arc<Mutex<Container>>,
}

install_callback() — вешает на core ОДИН closure:
  (route_id, body, ctx) →
    middlewares(ctx)?;
    value = from_slice(body);
    result = handlers[route_id](value, container).await;
    Ok(Response(result))

Python: тот же Application, но set_external_callback(one_callable) — один callback на все маршруты, по route_id диспетчеризует уже Python.


Обёртка: откуда берётся handler в таблице

Путь из типа: CreateEmployeeC::name()"create_employee" → путь employees/commands/create_employee.

#[derive(Command)]
struct CreateEmployee { employee_id: String, name: String, role: String }
// путь = "{context}/commands/create_employee"

Кто строит замыкание для handlers[route_id]: при вызове .command_with_handler::<CreateEmployee, CreateEmployeeHandler>() обёртка создаёт одно замыкание и передаёт его в app.add_command(...):

// Внутри command_with_handler (urich-rs), упрощённо:
handler = |body: Value, container: Arc<Mutex<Container>>| async move {
    let h = container.lock().unwrap().resolve::<CreateEmployeeHandler>()?.clone();
    let cmd: CreateEmployee = serde_json::from_value(body)?;
    h.handle(cmd).await
};
app.add_command("employees", "create_employee", schema, handler);
// в ядре: handlers[route_id] = handler

Ты не пишешь это замыкание — его строит фреймворк по типам C и H.


Цепочка: регистрация и запрос

Регистрация (старт):

app.register(employees_module())
  → module.register_into(app)
  → для каждой команды: app.add_command(ctx, name, schema, handler)
     handler уже построен в command_with_handler (lock, resolve, from_value, handle)
  → в core: route_id = core.add_command(...); handlers[route_id] = handler
install_callback() → на core один closure: (rid, body, ctx) → handlers[rid](body, container)

Запрос:

POST /employees/commands/create_employee
  → router.match → route_id
  → core вызывает единственный callback(route_id, body, ctx)
  → callback: handlers[route_id](body, container)
  → внутри handler: lock → resolve CreateEmployeeHandler → from_value(body) → handle(cmd)
  → твой код: только handle(cmd)

Итог по слоям:

Где

Что

urich-core

Роутер, протокол (scope/receive/send), Application, Container, Module, один callback → handlers[route_id](body, container), run_from_env. Общее для Rust и Python.

urich-rs

DomainModule, Command/Query, CommandHandler/QueryHandler; command_with_handler строит замыкание и передаёт в core. Один dev-бинарник, cargo watch.

Python

Тот же Application, add_command_route, set_external_callback(callable), run_from_env.


Как это выглядит в итоге?

Rust, модуль employees:

pub fn employees_module() -> DomainModule {
    DomainModule::new("employees")
        .command_with_handler::<CreateEmployee, CreateEmployeeHandler>()
        .query_with_handler::<GetEmployee, GetEmployeeHandler>()
        .query_with_handler::<ListEmployees, ListEmployeesHandler>()
}

Типы команд/запросов и handler'ы (CreateEmployeeHandler { db: Db }, impl CommandHandler/QueryHandler) — отдельно. В main один раз регистрируешь фабрики. Никаких lock/resolve в модуле.

Python, тот же модуль:

employees_module = (
    DomainModule("employees")
    .command(CreateEmployee, CreateEmployeeHandler)
    .query(GetEmployee, GetEmployeeHandler)
    .query(ListEmployees, ListEmployeesHandler)
)

Идея одна: модуль как кирпичик, регистрация — несколько строк. В Расте без фреймворка на это ушла бы куча замыканий и lock'ов; здесь — те же концепты, что и на Python. Когда открываешь оба файла рядом, видно: один и тот же дизайн, разный синтаксис. Для меня это и была цель.


Что дальше

В задумках:

  • Схема из типа — генерация JSON Schema из Command/Query (schemars и т.п.), чтобы не дублировать описание.

  • Документация и примеры — как резать сервис на модули, регистрировать фабрики, RPC и middleware.

  • Макросы — при желании ещё сократить шаблон, не теряя явности.

  • Тесты и жизненный цикл — удобные точки для интеграционных тестов, при необходимости — события и outbox.

Главное — не усложнять, держать контракт модуля стабильным.


Ссылки

Буду рад обратной связи и контрибьюциям — пиши в комментариях, в issues или в личку.


По состоянию ветки multi. WIP. Итог: одно ядро с общим слоем приложения (Application, Container, Module) для Rust и Python.