this-rs automatically enriches link responses with full entity data, eliminating the need for separate queries and preventing N+1 query problems.
When you query links, instead of just getting IDs, you get complete entity objects embedded in the response.
{
"links": [
{
"id": "link-123",
"source_id": "order-abc",
"target_id": "invoice-xyz"
}
]
}
// Now you need 2 more queries:
// GET /orders/order-abc
// GET /invoices/invoice-xyz
{
"links": [
{
"id": "link-123",
"source_id": "order-abc",
"target_id": "invoice-xyz",
"target": {
"id": "invoice-xyz",
"type": "invoice",
"name": "INV-001",
"amount": 1500.00,
"due_date": "2024-12-31",
"status": "pending"
},
"metadata": {
"created_at": "2024-01-15",
"priority": "high"
}
}
]
}
// ✅ All data in one response!
Each entity store implements EntityFetcher:
#[async_trait]
pub trait EntityFetcher: Send + Sync {
async fn fetch_as_json(&self, entity_id: &Uuid) -> Result<serde_json::Value>;
}
// Implementation example
#[async_trait]
impl EntityFetcher for OrderStore {
async fn fetch_as_json(&self, entity_id: &Uuid) -> Result<serde_json::Value> {
let order = self.get(entity_id)
.ok_or_else(|| anyhow::anyhow!("Order not found"))?;
Ok(serde_json::to_value(order)?)
}
}
Modules provide entity fetchers:
impl Module for BillingModule {
fn get_entity_fetcher(&self, entity_type: &str) -> Option<Arc<dyn EntityFetcher>> {
match entity_type {
"order" => Some(Arc::new(self.store.orders.clone())),
"invoice" => Some(Arc::new(self.store.invoices.clone())),
"payment" => Some(Arc::new(self.store.payments.clone())),
_ => None,
}
}
}
ServerBuilder collects all fetchers:
// ServerBuilder.build()
let mut fetchers_map: HashMap<String, Arc<dyn EntityFetcher>> = HashMap::new();
for module in &self.modules {
for entity_type in module.entity_types() {
if let Some(fetcher) = module.get_entity_fetcher(entity_type) {
fetchers_map.insert(entity_type.to_string(), fetcher);
}
}
}
// AppState has access to all fetchers
let link_state = AppState {
entity_fetchers: Arc::new(fetchers_map),
// ...
};
pub enum EnrichmentContext {
FromSource, // Query from source -> only enrich target
FromTarget, // Query from target -> only enrich source
DirectLink, // Direct link access -> enrich both
}
async fn enrich_links_with_entities(
state: &AppState,
links: Vec<LinkEntity>,
context: EnrichmentContext,
link_definition: &LinkDefinition,
) -> Result<Vec<EnrichedLink>> {
for link in links {
let source_entity = match context {
EnrichmentContext::FromSource => None, // Skip, we came from source
_ => fetch_entity(state, &link_definition.source_type, &link.source_id).await,
};
let target_entity = match context {
EnrichmentContext::FromTarget => None, // Skip, we came from target
_ => fetch_entity(state, &link_definition.target_type, &link.target_id).await,
};
enriched.push(EnrichedLink {
source: source_entity,
target: target_entity,
// ...
});
}
}
GET /orders/123/invoices
Enrichment: Only target (invoices) included
{
"links": [
{
"source_id": "order-123",
"target_id": "invoice-456",
"target": { /* Full invoice data */ }
// No "source" field (we came from the order)
}
]
}
Rationale: You already have the order (you queried from it), only need invoice data.
GET /invoices/456/order
Enrichment: Only source (order) included
{
"links": [
{
"source_id": "order-123",
"target_id": "invoice-456",
"source": { /* Full order data */ }
// No "target" field (we came from the invoice)
}
]
}
Rationale: You already have the invoice, only need order data.
GET /orders/123/invoices/456
# or
GET /links/link-uuid
Enrichment: Both source and target included
{
"id": "link-uuid",
"source_id": "order-123",
"target_id": "invoice-456",
"source": { /* Full order data */ },
"target": { /* Full invoice data */ }
}
Rationale: Direct link access, provide complete context.
Traditional Approach (N+1 Problem):
1 query: Get links (N results)
N queries: Get each target entity
Total: N+1 queries ❌
this-rs Approach:
1 query: Get links
1 batch operation: Fetch all entities efficiently
Total: Effectively 2 operations ✅
// Entities are fetched in parallel when possible
async fn enrich_links_with_entities(...) {
let mut tasks = vec![];
for link in links {
if need_source {
tasks.push(fetch_entity(..., link.source_id));
}
if need_target {
tasks.push(fetch_entity(..., link.target_id));
}
}
// Execute all fetches concurrently
let results = join_all(tasks).await;
}
You can add caching in your EntityFetcher implementation:
#[async_trait]
impl EntityFetcher for OrderStore {
async fn fetch_as_json(&self, entity_id: &Uuid) -> Result<serde_json::Value> {
// Check cache first
if let Some(cached) = self.cache.get(entity_id) {
return Ok(cached);
}
// Fetch from storage
let order = self.get(entity_id)?;
let json = serde_json::to_value(order)?;
// Cache for next time
self.cache.put(entity_id, json.clone());
Ok(json)
}
}
curl http://localhost:3000/orders/abc-123/invoices | jq .
Response:
{
"links": [
{
"id": "link-1",
"source_id": "abc-123",
"target_id": "inv-001",
"target": {
"id": "inv-001",
"type": "invoice",
"name": "INV-001",
"amount": 1500.00,
"status": "pending"
}
},
{
"id": "link-2",
"source_id": "abc-123",
"target_id": "inv-002",
"target": {
"id": "inv-002",
"type": "invoice",
"name": "INV-002",
"amount": 2500.00,
"status": "paid"
}
}
],
"count": 2
}
curl http://localhost:3000/invoices/inv-001/order | jq .
Response:
{
"links": [
{
"id": "link-1",
"source_id": "abc-123",
"target_id": "inv-001",
"source": {
"id": "abc-123",
"type": "order",
"name": "ORD-123",
"amount": 5000.00,
"customer_name": "Acme Corp"
}
}
]
}
curl http://localhost:3000/orders/abc-123/invoices/inv-001 | jq .
Response:
{
"id": "link-1",
"source_id": "abc-123",
"target_id": "inv-001",
"source": {
"id": "abc-123",
"type": "order",
"name": "ORD-123",
"amount": 5000.00
},
"target": {
"id": "inv-001",
"type": "invoice",
"name": "INV-001",
"amount": 1500.00
},
"metadata": {
"created_at": "2024-01-15"
}
}
// ✅ Do this
#[async_trait]
impl EntityFetcher for YourStore {
async fn fetch_as_json(&self, entity_id: &Uuid) -> Result<serde_json::Value> {
// Implementation
}
}
// ✅ Do this
impl Module for YourModule {
fn get_entity_fetcher(&self, entity_type: &str) -> Option<Arc<dyn EntityFetcher>> {
match entity_type {
"your_entity" => Some(Arc::new(self.store.clone())),
_ => None,
}
}
}
// ✅ Do this - return None instead of error
async fn fetch_entity(...) -> Option<serde_json::Value> {
match fetcher.fetch_as_json(entity_id).await {
Ok(entity) => Some(entity),
Err(_) => None, // Entity not found or deleted
}
}
// Enriched link with missing entity
{
"source_id": "abc-123",
"target_id": "deleted-entity",
"target": null // Entity was deleted or not found
}
The framework automatically chooses the right context:
/orders/123/invoices → FromSource/invoices/456/order → FromTarget/orders/123/invoices/456 → DirectLink✅ No N+1 Queries - All data fetched efficiently
✅ Better UX - Clients get complete data in one request
✅ Reduced Network - Fewer round trips
✅ Type-Safe - EntityFetcher trait ensures correctness
✅ Context-Aware - Smart enrichment based on query direction
✅ Flexible - Easy to customize per entity
Enriched links make your API fast, efficient, and delightful to use! 🚀✨