Skip to content

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.

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.

When you request specific fields, the ORM fetches only those fields.

# Only fetches id, name, email from the database
contacts = 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

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.

Related records are batched automatically to eliminate N+1 queries.

# Fetches all orders, then all customers in ONE query, then all lines in ONE query
orders = 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.

Write a filter expression once. Use it in Python, JSON views, security rules, and client-side evaluation.

# Python query
leads = 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 rule
domain: (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.

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 DB
products = 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

Multiple modules can extend the same model. Methods chain properly with super().

# Core module defines Contact
class Contact(Model):
name = Char(max_length=255)
email = Char(max_length=255)
# CRM module extends it
class ContactCRM(Model):
__inherit__ = "Contact"
lead_score = Integer(default=0)
# Invoicing module also extends it
class 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.

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

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 time
await 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=$4

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

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_set atomic updates
  • Relational fields — O2M/M2M collection changes
  • Inside save() — context guard forces immediate execution

Deferred writes are flushed automatically before any read operation on the same model:

await product.update(price=15.0) # Deferred — buffered in memory
products = await Product.filter().all() # Auto-flushes before query

This applies to .all(), .first(), .count(), .sum(), .avg(), .min(), .max(), and .exists(). Deferred writes are also flushed at transaction commit.

from fullfinity.engine.base import *
# Flush all deferred writes across all models
await Model.flush()
# Flush deferred writes for a specific model only
await Model.flush("Product")

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.

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
  • total is not computed (not requested)
  • lines are not loaded (only needed for total)
  • customer is prefetched in a single batched query

When a Form view requests all fields including total:

  • The compute_total function runs
  • Only lines and lines__subtotal are fetched (the declared dependencies)
  • Other relations remain unloaded

No configuration. No optimization hints. The ORM understands your code.

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.

HelperSignatureReturns / 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.

AspectTraditional ORMsFullfinity ORM
Field selectionLoad all, use someFetch only requested
Computed dependenciesLoad all or N+1Per-field analysis
Relation loadingManual prefetch or N+1Automatic batching
Expression languageBackend onlyBackend + Frontend + Security
Async supportWrapper or adapterNative async throughout
Model extensionMixins or monkey-patchClean inheritance chain
CachingManual or all-or-nothingIntelligent invalidation