ORM Design Philosophy
Most ORMs execute queries. Ours understands them.
Fullfinity’s ORM was built from scratch with one goal: zero wasted work. Every design decision optimizes for doing exactly what’s needed — nothing more.
The Problem with Traditional ORMs
Section titled “The Problem with Traditional ORMs”Traditional ORMs were designed when applications were simpler. They load entire records when you need one field. They fetch all dependencies for all computed fields, even when you only asked for one. They make N+1 queries easy to write and hard to debug.
Developers learn to work around these limitations — eager loading, manual prefetching, caching layers, read replicas. But these are band-aids on a fundamental design problem.
We started over.
Core Principles
Section titled “Core Principles”1. Selective Hydration
Section titled “1. Selective Hydration”When you request specific fields, the ORM fetches only those fields.
# Only fetches id, name, email from the databasecontacts = await Contact.with_fields("id", "name", "email").all()Traditional ORMs load the entire record and discard what you don’t need. Fullfinity’s ORM constructs a SELECT statement with exactly the columns requested.
This matters when:
- Records have large text/JSON fields you don’t need for a list view
- You’re fetching thousands of records
- Network bandwidth between app and database is a constraint
2. Smart Computed Field Dependencies
Section titled “2. Smart Computed Field Dependencies”Each computed field knows exactly what it needs — and the ORM loads only that.
class Invoice(Model): lines = OneToMany(related_model="InvoiceLine", related_name="invoice") total = Monetary(calculate="compute_total", store=True) customer_name = Char(calculate="compute_customer_name", store=False)
@Model.calculate("lines", "lines__amount") async def compute_total(self): await self.fetch_related("lines") self.total = sum(line.amount for line in self.lines or [])
@Model.calculate("customer", "customer__name") async def compute_customer_name(self): await self.fetch_related("customer") self.customer_name = self.customer.name if self.customer else ""Dependency paths use __ (double-underscore) to traverse relations — e.g.
"lines__amount", "customer__name" — never dot notation.
The key insight: When you request total, the ORM only loads lines. When you request customer_name, it only loads customer. Request neither, and no relations are fetched.
Traditional ORMs either:
- Load all computed field dependencies upfront (wasteful)
- Let you hit N+1 problems at runtime (slow)
- Require manual prefetch declarations scattered across your codebase (fragile)
Fullfinity analyzes the @calculate decorator for the specific fields you requested and fetches exactly those dependencies.
3. Intelligent Prefetching
Section titled “3. Intelligent Prefetching”Related records are batched automatically to eliminate N+1 queries.
# Fetches all orders, then all customers in ONE query, then all lines in ONE queryorders = await Order.prefetch_related("customer", "lines").all()
for order in orders: print(order.customer.name) # No additional query for line in order.lines: # No additional query print(line.product_name)The ORM collects all foreign key IDs from the first query, then fetches all related records in a single batched query per relation.
4. One Expression Language Everywhere
Section titled “4. One Expression Language Everywhere”Write a filter expression once. Use it in Python, JSON views, security rules, and client-side evaluation.
# Python queryleads = await Lead.filter(Q(amount__gt=1000) & Q(stage__is_won=True)).all()# YAML view - field visibility (evaluated client-side)- type: field name: discount properties: visible: Q(is_vip=True) required: Q(amount__gt=1000)# Search view filter- type: filter description: High Value filter: (Q(amount__gt=10000))# Security record ruledomain: (Q(user__id__eq=uid) | Q(team__members__id__eq=uid))No mental context-switching. No separate domain languages. The same Q() syntax works everywhere.
5. True Async — Not Bolted On
Section titled “5. True Async — Not Bolted On”The entire ORM is async from the ground up. Every database operation, every I/O call, uses Python’s native async/await.
# Non-blocking — other requests can be processed while waiting for DBproducts = await Product.filter(active=True).all()This isn’t “async support added to a sync ORM.” The ORM was designed async-first using asyncpg, with no synchronous fallback paths that could accidentally block.
Why this matters:
- A single process handles thousands of concurrent connections
- No thread pool overhead
- No GIL contention on I/O operations
- Predictable performance under load
6. Clean Model Inheritance
Section titled “6. Clean Model Inheritance”Multiple modules can extend the same model. Methods chain properly with super().
# Core module defines Contactclass Contact(Model): name = Char(max_length=255) email = Char(max_length=255)
# CRM module extends itclass ContactCRM(Model): __inherit__ = "Contact" lead_score = Integer(default=0)
# Invoicing module also extends itclass ContactInvoicing(Model): __inherit__ = "Contact" credit_limit = Monetary(default=0)All three definitions merge into a single Contact model with all fields. Method overrides chain via super() in dependency order. No monkey-patching, no runtime surprises.
7. Smart Caching with Invalidation
Section titled “7. Smart Caching with Invalidation”Query results are cached with intelligent invalidation — not “cache everything and hope.”
- Cache keys include the full query signature (model, filters, fields, ordering)
- Record mutations invalidate only affected cache entries
- Related record changes propagate invalidation correctly
- Cache is optional and configurable per-query
8. Deferred Writes
Section titled “8. Deferred Writes”Scalar field updates are buffered in memory rather than executing SQL immediately. Multiple updates to the same record merge into a single UPDATE, and records with the same changed fields are batched via executemany().
# These three updates produce ONE SQL statement at flush timeawait product.update(name="Widget")await product.update(price=15.0)await product.update(active=True)# → UPDATE "product" SET "name"=$1, "price"=$2, "active"=$3 WHERE id=$4What Gets Deferred
Section titled “What Gets Deferred”Only simple scalar field updates with no side effects:
- Char, Text, Integer, Float, Boolean, Date, DateTime, Monetary, Selection, etc.
- Fields with no compute cascade dependencies
What Executes Immediately
Section titled “What Executes Immediately”Updates are written to the database immediately when any of these apply:
- ManyToOne changes — parent collections must stay in sync
- Fields with compute dependencies — cascading calculations must run
- Company-scoped fields — require
jsonb_setatomic updates - Relational fields — O2M/M2M collection changes
- Inside
save()— context guard forces immediate execution
Auto-Flush
Section titled “Auto-Flush”Deferred writes are flushed automatically before any read operation on the same model:
await product.update(price=15.0) # Deferred — buffered in memoryproducts = await Product.filter().all() # Auto-flushes before queryThis applies to .all(), .first(), .count(), .sum(), .avg(), .min(), .max(), and .exists(). Deferred writes are also flushed at transaction commit.
Explicit Flush
Section titled “Explicit Flush”from fullfinity.engine.base import *
# Flush all deferred writes across all modelsawait Model.flush()
# Flush deferred writes for a specific model onlyawait Model.flush("Product")Identity Map Interaction
Section titled “Identity Map Interaction”When a field is deferred, the in-memory instance (_data) is updated immediately so subsequent Python reads see the new value. The database-backed _original_data is only updated at flush time. This means code that reads the record sees the deferred value without a database round-trip.
The Result
Section titled “The Result”You write business logic. The ORM figures out the optimal execution.
class Invoice(Model): customer = ManyToOne("Contact", related_name="invoices") lines = OneToMany(related_model="InvoiceLine", related_name="invoice") total = Monetary(calculate="compute_total", store=True) state = Selection(choices=["Draft", "Sent", "Paid"], default="Draft")
@Model.calculate("lines", "lines__subtotal") async def compute_total(self): await self.fetch_related("lines") self.total = sum(line.subtotal for line in self.lines or [])When a Kanban view requests ["name", "state", "customer"]:
- Only those 3 fields are fetched
totalis not computed (not requested)linesare not loaded (only needed fortotal)customeris prefetched in a single batched query
When a Form view requests all fields including total:
- The
compute_totalfunction runs - Only
linesandlines__subtotalare fetched (the declared dependencies) - Other relations remain unloaded
No configuration. No optimization hints. The ORM understands your code.
Engine Helpers
Section titled “Engine Helpers”from fullfinity.engine.base import * pulls in the field types and exceptions —
it also exports a handful of small engine helpers you reach for constantly in
model code. They resolve against the current request’s environment
(env_ctx), so they take no explicit user/company argument.
| Helper | Signature | Returns / does |
|---|---|---|
get_user_company() | await get_user_company() | The current user’s default company (falls back to the first active company, then the first company by id). Used in ~60 module files for company defaults. |
get_current_user() | await get_current_user() | The current logged-in User record (falls back to the admin user during migration/backfill). |
get_param(name) | await get_param(name) | The stored Params value for name, or None. Read under elevate(). |
set_param(name, value=None) | await set_param(name, value) | Upserts the Params row for name (under elevate()). |
get_action(...) | await get_action(action_id=..., identifier=..., model=..., record_id=...) | Resolves a WindowAction (by id, then identifier, then first action for model) into a fully-built action payload with its views. |
from fullfinity.engine.base import get_user_company, get_current_user
class SaleOrder(Model): async def create(cls, records): for record in records: if not record.get("company"): company = await get_user_company() record["company"] = company.id return await super().create(records)These are async — always await them. They are the supported way to read the
ambient user/company; never reconstruct it from request internals.
Comparison
Section titled “Comparison”| Aspect | Traditional ORMs | Fullfinity ORM |
|---|---|---|
| Field selection | Load all, use some | Fetch only requested |
| Computed dependencies | Load all or N+1 | Per-field analysis |
| Relation loading | Manual prefetch or N+1 | Automatic batching |
| Expression language | Backend only | Backend + Frontend + Security |
| Async support | Wrapper or adapter | Native async throughout |
| Model extension | Mixins or monkey-patch | Clean inheritance chain |
| Caching | Manual or all-or-nothing | Intelligent invalidation |
Next Steps
Section titled “Next Steps”- Querying Data — Filter, sort, and aggregate
- Computed Fields — Define calculations with dependencies
- Relationships — Model relations and prefetching