Ousia

A graph-relational ORM with built-in double-entry ledger for Rust. Zero migrations, compile-time safety, and atomic payment splits — all in one framework.
Table of Contents
- Why Ousia?
- Architecture Overview
- Installation
- Quickstart
- Objects
- Edges (Graph Relationships)
- Graph Traversal:
preload_object - Ledger (Money)
- Design Philosophy
- Production Status
- Roadmap
Why Ousia?
Most Rust ORMs give you tables and rows. Ousia gives you a typed graph with money semantics baked in.
| Ousia | SeaORM / Diesel | SQLx | |
|---|---|---|---|
| Graph edges with properties | ✅ First-class | ❌ Manual joins | ❌ Raw SQL |
| No migrations | ✅ Struct IS schema | ❌ Required | ❌ Required |
| Compile-time query validation | ✅ const FIELDS |
Partial | ❌ |
| Owner-based multitenancy | ✅ Built-in | ❌ Manual | ❌ Manual |
| Atomic payment splits | ✅ Built-in ledger | ❌ External | ❌ External |
| View system | ✅ Derive macro | ❌ | ❌ |
Architecture Overview
┌─────────────────────────────────────────────┐
│ Engine │
│ (type-safe interface for all operations) │
├─────────────────┬───────────────────────────┤
│ Object Store │ Edge Store │
│ (msgpack data │ (typed graph with │
│ + GIN index) │ index meta) │
├─────────────────┴───────────────────────────┤
│ Adapter (Postgres / Memory) │
├─────────────────────────────────────────────┤
│ Ledger (optional feature) │
│ (double-entry, two-phase, value objects) │
└─────────────────────────────────────────────┘
Objects hold structured data. Each has a Meta (id, owner, created_at, updated_at) plus your fields serialized as binary MessagePack (bytea). Index metadata is stored as a separate JSONB column to enable GIN-powered index queries. Indexes are declared with #[ousia(...)] and validated at compile time.
Edges are first-class typed relationships between objects. They carry their own data fields and indexes, and support both forward and reverse traversal.
The Ledger handles money as immutable ValueObject fragments. Transfers are two-phase: a pure-memory planning stage followed by a single atomic execution with microsecond locks.
Installation
[]
// ousia = "1" -- enables "derive", "postgres" and "ledger"
= { = "1", = ["derive", "ledger"] }
The derive feature enables #[derive(OusiaObject, OusiaEdge)]. The ledger feature re-exports the ledger crate under ousia::ledger.
Quickstart
use ;
use PostgresAdapter;
// 1. Define your type
async
Objects
Defining Objects
Every object has a Meta field (by convention _meta) that holds id, owner, created_at, and updated_at. All other fields are yours.
use ;
// Implement ToIndexValue for PostStatus to enable indexing for custom types
The OusiaObject derive generates:
impl Object— type name, meta accessors, index metadataimpl Unique— uniqueness hash derivationconst FIELDS— aPostFieldsstruct with oneIndexFieldper indexed field, used in query builder calls- Custom
Serialize/Deserializethat respects private fields and views
The OusiaDefault derive generates impl Default with a fresh Meta.
Reserved field names (used by Meta — don't declare these yourself): id, owner, type, created_at, updated_at.
CRUD Operations
// Create
engine.create_object.await?;
// Fetch by ID
let post: = engine.fetch_object.await?;
// Fetch multiple by IDs
let posts: = engine.fetch_objects.await?;
// Update (sets updated_at automatically)
post.title = "New Title".to_string;
engine.update_object.await?;
// Delete (owner must match)
let deleted: = engine.delete_object.await?;
// Transfer ownership
let post: Post = engine.transfer_object.await?;
Type-Safe Queries
Queries are built using const FIELDS references — the field names are validated at compile time.
use Query;
// All users named "alice"
let users: = engine
.query_objects
.await?;
// Posts by owner, filtered and paginated
let posts: = engine
.query_objects
.await?;
// Contains query on array field
let tagged: = engine
.query_objects
.await?;
// Count
let total: u64 = engine..await?;
let published: u64 = engine
.
.await?;
Available comparisons: where_eq, where_ne, where_gt, where_gte, where_lt, where_lte, where_contains, where_begins_with. Each has an or_ variant for OR conditions. Sort with sort_asc / sort_desc.
Uniqueness Constraints
// Single field unique globally
// Composite unique (both fields together must be unique)
// Singleton per owner (e.g., one profile per user)
On violation, create_object or update_object returns Err(Error::UniqueConstraintViolation(field_name)). Updates are handled cleanly: old hashes are removed, new ones checked, and rollback happens if the new hash is already taken.
View System
Views let you generate multiple serialization shapes from one struct without duplicating types. Ideal for public vs. admin API responses.
// Usage — auto-generated structs and methods:
let public_view: UserPublicView = user._public; // { id, created_at, username }
let admin_view: UserAdminView = user._admin; // { id, owner, created_at, username, email }
Private fields are excluded from all serialization (including the default Serialize impl) but are included in the internal database representation via __serialize_internal.
Owner-Based Multitenancy
Every object has an owner UUID in its Meta. The SYSTEM_OWNER constant (00000000-0000-7000-8000-000000000001) is the default for unowned objects.
use ;
// Set owner at creation
post.set_owner;
// Check ownership
assert!;
assert!;
// Fetch everything owned by a user
let posts: = engine.fetch_owned_objects.await?;
// Fetch single owned object (useful for one-to-one, e.g., user profile)
let profile: = engine.fetch_owned_object.await?;
Delete and transfer operations require the correct owner — mismatched owner returns Err(Error::NotFound).
Edges (Graph Relationships)
Defining Edges
use ;
EdgeMeta stores the from and to object IDs. The OusiaEdge derive generates impl Edge, const FIELDS, and custom serde that keeps _meta out of the serialized data payload.
from and to are always available as indexed fields (no need to declare them).
Creating and Querying Edges
// Create
let follow = Follow ;
engine.create_edge.await?;
// Query forward edges (Alice's follows)
let follows: = engine
.query_edges
.await?;
// Update (optionally change the `to` target)
engine.update_edge.await?;
// Delete
engine..await?;
// Delete all edges from a node
engine..await?;
// Count
let count: u64 = engine..await?;
Reverse Edges
// Who follows Bob? (reverse direction)
let followers: = engine
.query_reverse_edges
.await?;
let follower_count: u64 = engine
.
.await?;
Edge Filtering
use EdgeQuery;
let accepted: = engine
.query_edges
.await?;
Graph Traversal: preload_object
For complex multi-hop traversals, preload_object provides a fluent builder that can filter both the edge properties and the target object's properties in a single query:
// Users that Alice follows, created after last month, where the Follow edge is accepted
let users: = engine
.
.
.where_gt // filter target objects
.edge_eq // filter edges
.collect
.await?;
Ledger (Money)
Ousia includes a full double-entry ledger. See ledger/README.md for the complete API. Here's the shape:
Installation
or
= { = "1", = ["derive", "postgres"] }
use ;
// Setup
let system = new;
let ctx = new;
// Create an asset
let usd = new; // unit = $100, 2 decimals
system.adapter.create_asset.await?;
// Atomic payment split: buyer pays $100, splits to seller/platform/charity
atomic.await?;
// Check balance
let balance = get.await?;
println!;
Design Philosophy
What Ousia does:
- Type safety enforced at compile time via
const FIELDSand derive macros - Typed graph edges with indexed properties
- Atomic money transfers with double-entry guarantees
- Owner-based multitenancy as a first-class concept
- Automatic change handling — over-selection in payments returns the diff
Idempotency: Keys stored permanently. Only used for external deposit/withdrawal webhooks — not every internal transaction needs a key.
What Ousia deliberately rejects:
- Explicit transactions — the two-phase ledger handles it; locks held for microseconds only
- ORM-layer validation — belongs in your service layer, not your ORM
- Soft deletes — application-specific; implement in your domain if needed
- Schema migrations — the struct is the schema; add and remove fields freely
- Early locking — planning phase is pure memory; execution phase is atomic
Benchmarks
Median latency · 10–20 samples per group · MacBook M1 Pro 32 GB · PostgreSQL 17 in Docker (localhost)
Datasets: ousia_edges — 10k users, 100k follows, N+1 bench over 1k pivots; ousia_queries — 50k users, 2k posts; ousia_vs_raw — 10k users, 2k posts, N+1 bench over 200 owners.
Storage: data BYTEA (MessagePack via rmp-serde) + index_meta JSONB (GIN-indexed).
Disclaimer
These results may not accurately reflect performance due to the structure of bench functions and are expected to change when better bench functions are implemented.
BENCH_PG_BASE=postgres://user:pass@host
N+1 Elimination — the headline result
| Suite | Benchmark | ousia batch (2q) | raw N+1 | raw batch (2q) | N+1 speedup |
|---|---|---|---|---|---|
| ousia_edges (1k pivots) | preload_multi_pivot_forward | 472 µs | 435 ms | 119 ms | 921× |
| ousia_edges (1k pivots) | preload_multi_pivot_count | 471 µs | 410 ms | 20.5 ms | 870× |
| ousia_vs_raw (200 owners) | preload_owned_batch | 465 µs | 85.3 ms | 3.7 ms | 184× |
Edge Operations (ousia_edges — 10k users, 100k follows)
| Benchmark | ousia | raw sqlx | sea-orm |
|---|---|---|---|
| query_edges_forward | 435 µs | 416 µs | 428 µs |
| query_edges_reverse | 436 µs | 426 µs | 423 µs |
| count_edges | 420 µs | 417 µs | 420 µs |
| query_edges_with_filter | 425 µs | 642 µs | 691 µs |
| preload_forward (1 pivot → users) | 464 µs | 451 µs | 440 µs |
| preload_reverse (1 pivot ← users) | 445 µs | 443 µs | 435 µs |
| create_edge | 534 µs | 504 µs | 512 µs |
Object Queries (ousia_vs_raw — 10k users, 2k posts)
| Benchmark | ousia | raw sqlx | sea-orm |
|---|---|---|---|
| fetch_by_pk | 422 µs | 425 µs | 424 µs |
| eq_filter_indexed | 429 µs | 418 µs | 421 µs |
| count_aggregate | 502 µs | 405 µs | 406 µs |
| owner_scan (by owner ID) | 474 µs | 421 µs | 431 µs |
| range_sort + limit 20 | 482 µs | 438 µs | 453 µs |
| array_contains (GIN) | 423 µs | 983 µs | 1.20 ms |
| begins_with prefix | 502 µs | 632 µs | 480 µs |
| bulk_fetch × 10 | 422 µs | 421 µs | 418 µs |
| bulk_fetch × 50 | 539 µs | 474 µs | 474 µs |
| bulk_fetch × 100 | 666 µs | 536 µs | 569 µs |
| multi_sort + limit 50 | 501 µs | 503 µs | 515 µs |
Query Patterns (ousia_queries — 50k users, 2k posts)
| Benchmark | ousia | raw sqlx | sea-orm |
|---|---|---|---|
| AND filter (2 fields) ¹ | 422 µs | 22.1 ms | 31.3 ms |
| OR / IN condition | 543 µs | 444 µs | 430 µs |
| cursor page1 × 10 | 473 µs | 445 µs | 442 µs |
| cursor mid-page × 10 | 486 µs | 454 µs | 450 µs |
| cursor page1 × 50 | 475 µs | 500 µs | 477 µs |
| cursor mid-page × 50 | 483 µs | 508 µs | 501 µs |
| cursor page1 × 100 | 474 µs | 548 µs | 537 µs |
| cursor mid-page × 100 | 479 µs | 563 µs | 548 µs |
| full scan limit 100 | 473 µs | 554 µs | 539 µs |
| full scan limit 500 | 493 µs | 909 µs | 1.05 ms |
| multi_sort + limit 50 | 483 µs | 506 µs | 552 µs |
| create_object | 563 µs | 535 µs | 534 µs |
¹ At 50k rows, ousia's index_meta JSONB indexes turn a full-table scan into an index lookup — 52× faster than hand-written SQL without a matching composite index.
Joins & CTEs (ousia_vs_raw)
| Benchmark | ousia | raw sqlx | sea-orm |
|---|---|---|---|
| join_posts_users (published, top 20) | — | 590 µs | 575 µs |
| cte_ranked_posts (window fn top-3) | 511 µs ² | 1.42 ms | 1.49 ms |
² ousia fetches all published posts + groups top-3 per owner in Rust.
Key takeaways:
- Batch preload eliminates N+1 with 184–921× speedup — the gap grows with dataset size.
- At 50k rows, JSONB index queries beat full-table-scan SQL by 52× for compound AND filters.
- MessagePack (
bytea) storage cuts deserialization overhead — cursor pagination at 10 rows dropped from ~2.5 ms to ~475 µs vs the old JSONB baseline. - Single-query operations (PK fetch, GIN array search, all cursor sizes) match or beat raw sqlx.
- Joins and window functions are best expressed as raw SQL; ousia provides an escape hatch for these.
Metrics
- Query duration histogram
- Transaction amount histogram
- Transaction success rate histogram
☕️ Buy Me a Drink
If this project saved your time, helped you ship faster, or made you say "damn, that's slick!" — consider buying me a beer 🍻
👉 Send me a drink on Cointr.ee
License
MIT