Architecture & concepts
Design principles
- One object = one building block — DomainModule, EventBusModule, DiscoveryModule, etc. Each is configured via a fluent API and attached with
app.register(module). No scattered decorators or global state. - Protocols over implementations — EventBus, ServiceDiscovery, RpcTransport, OutboxStorage, OutboxPublisher are protocols. The framework and your code depend on abstractions; you plug in in-memory, Redis, Consul, HTTP+JSON, or your own impl.
- Bounded context as DomainModule — One module per context (e.g. orders, payments). It declares aggregate, repository, commands, queries and event handlers. Routes and DI are wired from this single description.
- CQRS by convention — Commands (write) and queries (read) are separate types and handlers. HTTP routes are generated:
POST /{context}/commands/{name},GET|POST /{context}/queries/{name}.
Module protocol
Every building block implements Module:
When you call app.register(module), the framework calls module.register_into(app). The module then:
- Adds routes via
app.add_route()orapp.mount(), - Registers types in
app.container(repositories, EventBus, etc.), - Or both.
This keeps composition explicit and testable: you can register a subset of modules or replace one with a test double.
Dependency injection
The container is a registry keyed by type (or string). Resolution is by type: container.resolve(IOrderRepository) returns whatever was registered for that interface (typically the implementation class, registered so that the interface resolves to it).
- register_instance(key, instance) — Singleton instance.
- register(key, factory, singleton=True) — Factory; first resolve runs the factory and caches if singleton.
- register_class(cls, singleton=True) — Constructor injection: when resolving
cls, the container instantiates it and fills constructor parameters by resolving their types from the container. So a handler that needsIOrderRepositoryandEventBusgets them automatically.
String annotations (e.g. from from __future__ import annotations) are resolved to the actual class in the same module when possible.
Request flow (DomainModule)
- HTTP request hits
POST /orders/commands/create_order. - Starlette dispatches to the endpoint registered by DomainModule.
- The endpoint parses JSON into the command dataclass (
CreateOrder). - Handler is resolved from the container (if it’s a class) or used as-is (if it’s a function).
- Handler is called with the command; it may load/save aggregates and publish domain events via EventBus.
- Response is JSON:
{"ok": true, "result": ...}for commands, or the query result directly for queries.
Package layout (high level)
| Package | Role |
|---|---|
| urich | Application, Container, Module, HttpModule, Config. |
| urich.domain | Entity, ValueObject, AggregateRoot, DomainEvent, Repository, EventBus, InProcessEventDispatcher. |
| urich.ddd | DomainModule, Command, Query. |
| urich.events | EventBusModule, EventBusAdapter, OutboxModule, OutboxStorage, OutboxPublisher. |
| urich.discovery | DiscoveryModule, ServiceDiscovery, static_discovery. |
| urich.rpc | RpcModule, RpcTransport, RpcServerHandler, JsonHttpRpcTransport. |
| urich.core | App, container, module, config, openapi, routing (HttpModule). |
| urich.cli | Typer CLI: create-app, add-context, add-aggregate. |