Core API Mode
GraphQL mode auto-generates a full query language. But many APIs are simpler — they just need well-shaped REST responses with related data loaded efficiently.
Core API gives you the same DataLoader batch loading and N+1 prevention, but as building blocks you control: define your DTOs, declare which fields need related data, and let the Resolver assemble the tree.
Step 1: Define DTOs with DefineSubset
DefineSubset picks fields from your SQLModel entities and generates Pydantic models. Relationship fields declared in the class body are auto-loaded by name:
from nexusx import DefineSubset
class UserDTO(DefineSubset):
__subset__ = (User, ("id", "name"))
class TaskDTO(DefineSubset):
__subset__ = (Task, ("id", "title", "owner_id"))
owner: UserDTO | None = None # Name matches Task.owner → auto-loaded
class SprintDTO(DefineSubset):
__subset__ = (Sprint, ("id", "name"))
tasks: list[TaskDTO] = [] # Name matches Sprint.tasks → auto-loaded
What DefineSubset does
- Picks fields from the source entity — only the ones you list appear in the output
- Hides FK fields automatically —
owner_idwon't appear in the JSON response, but remains accessible internally inresolve_* - Relationship fields go in the class body, not in
__subset__— their types must be DTO types
Warning
Relationship field types must be DTO types (DefineSubset or BaseModel subclasses). Using SQLModel entity types directly will raise a TypeError.
# Wrong — SQLModel entity type
class TaskDTO(DefineSubset):
__subset__ = (Task, ("id", "title"))
owner: User | None = None # TypeError!
# Correct — DTO type
class TaskDTO(DefineSubset):
__subset__ = (Task, ("id", "title"))
owner: UserDTO | None = None # OK
Step 2: Initialize ErManager and Resolver
At application startup — run once:
from nexusx import ErManager
er = ErManager(base=SQLModel, session_factory=async_session)
Resolver = er.create_resolver()
ErManagerdiscovers all SQLModel entities and their relationshipscreate_resolver()returns a Resolver class bound to the entity graph
Warning
base and entities parameters are mutually exclusive — you can't pass both.
Step 3: Resolve in Your Route
from fastapi import FastAPI
from sqlmodel import select
app = FastAPI()
@app.get("/api/sprints")
async def get_sprints():
async with async_session() as session:
sprints = (await session.exec(select(Sprint))).all()
dtos = [SprintDTO(id=s.id, name=s.name) for s in sprints]
return await Resolver().resolve(dtos)
The Resolver traverses the DTO tree and fills in all relationship fields. You write the query, build the DTOs, and call resolve().
How Auto-Loading Works
When the Resolver encounters SprintDTO, it resolves the tree layer by layer:
SprintDTO(id=1, name="Sprint 1")
→ Auto-load: tasks → [TaskDTO(...), TaskDTO(...)]
→ Each TaskDTO: Auto-load: owner → UserDTO(...)
→ Result: complete nested response
Each relationship executes only one DataLoader query, regardless of how many Sprints or Tasks exist.
Four conditions for implicit auto-loading
The Resolver auto-loads a field when all four conditions are met:
- The field has no corresponding
resolve_*method - The field is an extra field (not in
__subset__) - The field name matches a registered ORM/custom relationship
- The field type is a BaseModel DTO compatible with the relationship target
When any condition fails, you can use resolve_* to load the field manually — see Core API Advanced.
Recap
DefineSubsetgenerates DTOs from SQLModel entities — pick fields, hide FKs, declare relationship fields- Relationship fields are auto-loaded when the field name matches a registered relationship
ErManagerdiscovers entities;Resolvertraverses and assembles the DTO tree- Relationship field types must be DTO types, not SQLModel entities
Next Steps
- Core API Advanced —
resolve_*/post_*/ cross-layer data flow - Custom Relationships — Non-ORM relationship declarations
- ER Diagram — How ErManager discovers relationships