Skip to content

nexusx Design Highlights: 12 Differentiated Decisions Excavated From the Code

This article is not a tutorial. It's a vertical audit of the nexusx codebase: pulling out the design decisions that actually let it stand on its own next to Strawberry / FastAPI / FastMCP, and explaining for each one what problem it solves, how it works under the hood, why it's hard, and how nexusx resolves it.

Audience: backend architects evaluating framework choices, contributors who want to understand the codebase, and engineers interested in "field selection as a general-purpose idea."

TL;DR — Three Threads

Collapsing the 12 highlights in this article, nexusx's differentiation comes down to three sentences:

  1. "Field selection" is a first-class citizen — from GraphQL selection to DefineSubset, to SQL column pruning and pagination type shrinkage, the same concept runs through four layers. Other frameworks treat "select fields on demand" as a GraphQL protocol feature; nexusx promotes it to a design principle that runs through the entire data flow.
  2. Relationships are not just ORM relationshipssqlalchemy.inspect auto-discovery + Relationship(...) custom escape hatch + DataLoader batching + ROW_NUMBER() window-function pagination cover the full spectrum from pure ORM to external data sources. Your data-access layer no longer has to split into two worlds just because you encounter a Redis cache or search engine.
  3. Transports are layered on top, not baked in — one UseCaseService + one method signature → GraphQL / REST / MCP / CLI, glued together by a metaclass + FromContext + selection triplet. Write the business method once; adding a transport is a configuration problem, not a code problem.

Below is the concrete support.


Thread 1: Field Selection as a First-Class Citizen

1. DefineSubset — Moving GraphQL Field Selection Into the Python Type System

Problem: In traditional web frameworks, there are a few paths from ORM entity to API response, each with costs:

  • Returning ORM entities directly → serialization exposes every column, leaking internal fields and providing no way to do derived computation;
  • Hand-writing Pydantic DTOs → every API endpoint requires duplicating field definitions, ORM schema changes drift out of sync with DTOs;
  • Inheriting from ORM entities for response variants → SQLModel / Pydantic inheritance semantics get murky around "which fields are table columns vs. serialization fields," and you can't express "I only want name, not email" type of on-demand shrinking.

GraphQL solves this with selection sets, but only inside the GraphQL path. REST handlers, background tasks, CLI commands — none benefit.

How it works: DefineSubset at src/nexusx/subset.py:739 uses a metaclass (SubsetMeta) to perform a compile-time transformation when the class is created:

class PostDTO(DefineSubset):
    __subset__ = (Post, ("id", "title", "author_id"))
    author: UserDTO | None = None     # field name matches relationship → auto-load
    full_title: str = ""

    def post_full_title(self, ancestor_context):
        return f"{ancestor_context['sprint_name']} / {self.title}"

The metaclass does three things:

  1. Reads the source entity and requested scalar columns from __subset__ = (Entity, fields), converting them to Pydantic field definitions. This step also validates: all column names must exist on the entity, and FK columns must be either in the allowlist or unused by relationship fields.
  2. Scans the class body for additional fields (e.g. author: UserDTO | None). If the field name matches a relationship on the source entity, the resolver wires it to the corresponding DataLoader at execution time; if it's a new field (e.g. full_title), it becomes a "derived field" that must be populated by a post_* method.
  3. Extracts resolve_* and post_* methods as lifecycle hooks. resolve_* runs before loader dispatch (can override default loading); post_* runs after nested data is ready (used for derived computation or aggregation).

The resulting PostDTO is a standard Pydantic BaseModel — it can be model_dump()-ed, validated by TypeAdapter, attached to a FastAPI endpoint as response_model. In other words, it doesn't invent a new type system — it leans on Pydantic — which is the key reason it stays compatible with the entire Python web ecosystem.

Why it matters: GraphQL is not the exclusive province of GraphQL frameworks. Strawberry's resolver machinery only serves GraphQL endpoints; if a REST handler wants "field selection + auto-loading," it has to fall back to hand-written DTOs. DefineSubset elevates this to a general posture of business code: the field declarations you write in DefineSubset are a "response contract" shared by REST / GraphQL / MCP. When a project needs both GraphQL's flexibility and REST's stability, the value is order-of-magnitude — it eliminates the entire workflow of "maintaining three field definitions for the same business entity across three protocols."

2. ExposeAs / SendTo / Collector — Bidirectional Context Flow Inside the DTO Tree

Problem: Consider a real requirement — "list the deduplicated owners of all tasks under a sprint as contributors." The three conventional approaches each have problems:

  1. Hand-written SQL subquery: performant, but cross-database dialect issues (DISTINCT ON exists in Postgres but not MySQL), and tightly coupled to ORM session;
  2. Hang a method on SprintDTO that walks self.tasks collecting owners: triggers lazy-loading of every task's owner → N+1;
  3. Pre-query contributors in the sprint business method: works, but contributors logic is scattered between the business layer and the DTO assembly layer, and as soon as you need "contributors should also show per-contributor task count" you're recursing.

Fundamentally this is a cross-layer data flow within a DTO tree problem: information needs to flow both parent→child and child→parent, and it has to cooperate with DataLoader's batching (to avoid N+1).

How it works: src/nexusx/context.py provides three primitives:

class SprintDTO(DefineSubset):
    __subset__ = (Sprint, ('id', 'name'))
    name: Annotated[str, ExposeAs('sprint_name')]   # broadcast downward
    tasks: list[TaskDTO] = []
    contributors: list[UserDTO] = []                 # container for upward aggregation

    def post_contributors(self, collector=Collector('contributors')):
        return collector.values()

class TaskDTO(DefineSubset):
    __subset__ = (Task, ('id', 'title'))
    owner: Annotated[UserDTO | None, SendTo('contributors')] = None  # push upward

The resolver walks the tree twice:

  • First pass (downward): once SprintDTO.name is loaded, its value is placed into an ancestor_context dict (keyed by the ExposeAs alias) and propagated to all descendant nodes. TaskDTO.post_full_title(self, ancestor_context) can then read ancestor_context['sprint_name']. This context is read-only broadcast — children cannot mutate parent values, avoiding side effects.
  • Second pass (upward): once TaskDTO.owner is loaded, the resolver checks whether it carries a SendTo('contributors') annotation, and if so, pushes the value into the nearest ancestor's 'contributors' Collector. Collector.add defaults to append; flat=True switches to extend (for list fields). SprintDTO.post_contributors is invoked after the entire subtree has run, pulling the pre-dedup list from collector.values() (dedup itself happens at the loader layer — the same User only gets queried once).

scan_expose_fields and scan_send_to_fields use class-level caches (_expose_cache / _send_to_cache) to avoid re-scanning annotations on every request. SendTo accepts a tuple (SendTo(('a', 'b'))) so a single value can fan out to multiple collectors — useful when the same data feeds multiple aggregations.

Why it matters: This pattern has essentially no equivalent in other Python GraphQL / DTO frameworks. GraphQL itself has @parent and context mechanisms, but they live inside GraphQL execution and don't escape to other layers; Pydantic's model_validator can only run inside a single model. The true value of ExposeAs / SendTo / Collector is treating the DTO tree as a directed acyclic graph supporting limited data flow between parent and child — neither unrestricted shared state (hard to debug) nor complete isolation (which forces you back to hand-written SQL). This "controlled bidirectional flow" is nexusx's core abstraction for tree-shaped response assembly; almost any "derived from subtree" field can be expressed elegantly with it. Code that used to take 30-50 lines of hand-rolled aggregation + dedup + batch loading now collapses to 3 lines of annotation.

3. selection Projection — Reusing GraphQL Field Syntax on Arbitrary Python Function Returns

Problem: GraphQL solved "the client tells the server which fields it wants," but that capability is locked inside the GraphQL protocol. A normal Python function (say, the return value of a UseCase method) has no such "trim on demand" capability — either the method returns the full object (bandwidth and serialization cost), or you write a variant per caller (method explosion).

MCP sharpens this problem: when an LLM calls a tool, it often only needs 10% of the return value (e.g. "list the sprint names and ids, no task details"), but the tool signature is fixed — the LLM has to take the full payload and trim it itself, wasting tokens and inflating prompt complexity.

How it works: apply_selection in src/nexusx/use_case/selection.py brings GraphQL-style field projection to any Python function return:

result = await SprintService.list_sprints()  # returns list[SprintDTO] with full fields

# Caller passes a GraphQL-style selection string
pruned = apply_selection(
    result,
    return_annotation=list[SprintDTO],
    selection="id name tasks { title }",
)

Four steps:

  1. Parse selection: QueryParser wraps "id name tasks { title }" as { __result: ... } and parses it into a FieldSelection tree — exactly the same code path as GraphQL query parsing. Nothing is reinvented.
  2. Extract root model from return type annotation: _extract_root_model handles container types like list[X] / X | None / dict[K, V], ultimately returning a Pydantic BaseModel subclass.
  3. Build subset model dynamically: build_subset_model recursively pydantic.create_models a brand-new type containing only the requested fields, based on FieldSelection. Nested relationship fields recurse. Arguments (like GraphQL limit/offset) are explicitly rejected (_reject_arguments) — this is the semantic difference between UseCase and GraphQL paths: the former only projects, never re-queries.
  4. TypeAdapter projection: the new model's TypeAdapter.validate_python(result) runs a Pydantic validation pass; the result is the trimmed subset.

Why it matters: This is the most direct expression of nexusx's internal coherence. The same field-selection syntax is reused across three execution paths: the GraphQL protocol layer (client → server), the DefineSubset declarative layer (developer writing code), and the selection runtime projection layer (caller dynamically trimming). This means a developer who has learned to write GraphQL selections automatically knows how to write DefineSubset and how to use MCP selection — zero extra mental cost. Architecturally, this "same concept reused across layers" is the fundamental feature that distinguishes nexusx from "a pile of loosely coupled features stitched into a framework." For MCP, it directly solves the "LLM wastes tokens on fields it doesn't care about" pain point — the LLM just adds a selection parameter to a tool call and controls the response precisely, like writing GraphQL.

4. Column Pruning (_query_meta + load_only) — Selection Sinks All the Way to SQL

Problem: DataLoader solves N+1 (batch-fetching children of N parents in one query), but by default each batch is SELECT * — even if the caller only cares about 3 columns. On wide tables (e.g. a 50-column entity) under high concurrency, the I/O and deserialization overhead of redundant columns is significant.

The harder part: the same loader instance may be reused by multiple consumers within a request. For example, User.posts may be requested by both PostSummary (only wants title) and PostDetail (wants title + content + author_id + created_at). If you prune to the minimum set per consumer, the other consumer hits AttributeError; if you SELECT *, you're back to square one.

How it works: src/nexusx/loader/query_meta.py + factories.py:_apply_load_only sink field selection all the way to SQL:

  1. Generate _query_meta: generate_query_meta_from_dto(dto_class) scans response-model fields and lists every field name corresponding to a SQL column; generate_query_meta_from_selection does the same from a GraphQL FieldSelection, preserving FK columns — even if the caller didn't explicitly select the FK, the next-level relationship still needs it as a batch key.
  2. Attach to loader instance: merge_query_meta(loader, meta) merges metadata into the loader's _query_meta attribute. The merge is a field union, ensuring every consumer's needed columns are present.
  3. Translate inside batch_load_fn: _get_effective_query_fields reads _query_meta, and _apply_load_only translates the field list into SQLAlchemy's load_only(*cols):
SELECT post.id, post.title, post.author_id FROM post WHERE post.author_id IN (1, 2, 3)
# instead of SELECT * FROM post WHERE post.author_id IN (1, 2, 3)

Why it matters: Most "ORM + GraphQL" frameworks either SELECT * (Strawberry's default behavior) or force you to hand-write load_only per resolver (and risk conflicts with other resolvers). nexusx makes pruning completely transparent through reverse inference + union merging — you only declare the fields you want in DefineSubset, and SQL only queries those columns; when multiple DTOs share a loader, the union prevents over-pruning. Performance-wise, fetching 5 of 50 columns typically saves 90% bandwidth; more importantly, the database buffer-pool hit rate improves significantly (smaller rows → more rows in buffer), a multiplier effect that applies to every query. This "selection shrinks throughout" design is the key thing distinguishing nexusx from other Python GraphQL frameworks on the performance axis.

5. Pagination Type Shrinks with Selection

Problem: A GraphQL pagination response usually looks like:

type PostResult {
  items: [Post!]!
  pagination: Pagination!
}
type Pagination {
  has_more: Boolean!
  total_count: Int
}

But total_count requires COUNT(*), which on a million-row table can be an order of magnitude slower than the LIMIT query itself. If the caller never asked for total_count, computing it is pure waste.

How it works: create_result_type in src/nexusx/loader/pagination.py doesn't generate a fixed full Pagination type — it shrinks dynamically based on selection:

def create_result_type(item_type, pagination_selection=None):
    fields = {"items": (list[item_type], ...)}
    if pagination_selection:
        pag_model = _build_pagination_model(pagination_selection)
        fields["pagination"] = (pag_model, ...)
    return create_model(...)

_build_pagination_model only includes fields that actually appear in the selection (has_more / total_count) in the dynamically generated Pydantic model. Downstream, create_page_one_to_many_loader reads this shrunk schema when running SQL: if total_count isn't in the response model, COUNT(*) is skipped entirely — only has_more is computed (via the LIMIT + 1 trick).

Why it matters: This is the pagination-specific case of item 4's "selection shrinks throughout," but called out separately because pagination is a high-sensitivity area for GraphQL performance. Standard GraphQL Relay pagination defaults to computing cursor + total_count + has_previous_page + has_next_page — each potentially a separate SQL. Many frameworks, by not distinguishing "what the caller asked" from "what the protocol mandates," end up with pagination metadata overhead exceeding the actual data. nexusx makes metadata computation follow selection too — this is "field selection as first-class citizen" fully implemented in engineering: any field not requested should not appear, from Pydantic model down to SQL.


Thread 2: Relationships Beyond ORM

6. Auto-Generating DataLoaders from SQLAlchemy Metadata

Problem: DataLoader is GraphQL's standard weapon against N+1. But DataLoader is tedious to use:

  • Every relationship requires a hand-written loader class (subclass aiodataloader.DataLoader, implement batch_load_fn);
  • Every resolver must manually fetch the corresponding loader from context, call .load(key);
  • Batch-key design, SQL construction, the IN (...) clause — repeated for every loader;
  • Many-to-many requires handling the association table, much more complex than one-to-many.

Strawberry's documentation devotes considerable space to this, and every relationship is a chunk of boilerplate.

How it works: ErManager in src/nexusx/loader/registry.py does three things at startup:

  1. _inspect_relationships(entity, all_entities, session_factory) uses sqlalchemy.inspect(entity) to read all relationships from the ORM mapper, classifying direction into MANYTOONE / ONETOMANY / MANYTOMANY, extracting FK column, target entity, and list-ness.
  2. Invoke the matching factory (factories.py) per direction:
  3. create_many_to_one_loader: batch key is the child entity's FK column values; SQL is SELECT * FROM child WHERE child.fk IN (...); results grouped by FK and returned per key.
  4. create_one_to_many_loader: batch key is parent PKs; SQL is SELECT * FROM child WHERE child.parent_id IN (...); results grouped by parent_id.
  5. create_many_to_many_loader: an additional association-table step — first SELECT * FROM association WHERE ... IN (...) to get pairs, then batch-fetch target entities.
  6. Register in self._registry: a dict[type[SQLModel], dict[str, RelationshipInfo]]. At runtime, lookup via entity → rel_name → loader_cls; each loader instance is cached per request scope (get_loader creates loader_cls() on first call and reuses it for the rest of the request).

The user participates in none of this. Once entities are defined, relationships are queryable — Sprint.tasks, Task.owner, User.posts require zero resolver lines.

Why it matters: This is the fulfillment of the "write SQLModel classes, done" promise. Compare Strawberry's typical usage — every relationship needs @strawberry.field(resolver=lambda ...) → context.loader.load(...); a 10-entity project might need 30-50 resolvers, each a similar boilerplate chunk. nexusx dissolves all this via ORM metadata reflection. Code volume drops from "entity-count × relationship-count × several lines" to "0." More importantly, reflection accuracy — SQLAlchemy's relationship metadata is well-considered (supporting back_populates, secondary, viewonly, cascade, etc.), and nexusx reuses its semantics rather than reinventing them, meaning all SQLAlchemy user knowledge and intuition transfers directly. This is engineering discipline of "don't reinvent the wheel."

7. Non-ORM Custom Relationship — The Escape Hatch That Doesn't Tie You to SQLAlchemy

Problem: No matter how good reflection gets in item 6, it can only cover relationships expressible as SQLAlchemy Relationship(...). Real-world business has many relationships outside this scope:

  • Cross-store: "User's recently viewed articles" lives in Redis, not a database table;
  • Derived: "Articles relevant to me" may require querying Elasticsearch;
  • Aggregated: "Sprint contributors" requires joining task + user and deduplicating, but you want to express it as a database view or materialized view;
  • External API: pulling data from third-party services.

In these scenarios, the conventional approach is to hand-write "query entity first, then augment fields" in the business layer, splitting code into "ORM data + manual stitching." Result: these fields miss out on DataLoader, can't use DefineSubset, get no representation in ER diagrams.

How it works: Relationship at src/nexusx/relationship.py:38 is a first-class construct that abstracts "relationship" from "ORM metadata" into "an async batch function":

async def tags_by_post_id_loader(post_ids: list[int]) -> list[list[Tag]]:
    # Redis / ES / external API — anything, as long as it's a batch interface
    redis_keys = [f"post:{pid}:tags" for pid in post_ids]
    results = await redis.mget(redis_keys)
    return [parse_tags(r) for r in results]

class Post(SQLModel, table=True):
    __tablename__ = "post"
    __relationships__ = [
        Relationship(
            fk='id',                    # use post.id as batch key
            target=list[Tag],           # one-to-many
            name='tags',                # relationship name
            loader=tags_by_post_id_loader,
        ),
    ]
    id: int | None = Field(default=None, primary_key=True)
    title: str

ErManager.__init__, after reflecting ORM relationships, calls get_custom_relationships(entity) to read the __relationships__ list, wraps each Relationship into a RelationshipInfo (with direction='CUSTOM'), and inserts it into the same registry. Note the registry doesn't distinguish ORM from custom relationships — downstream loader / resolver / DefineSubset / ER diagram all treat it as a first-class relationship.

The Relationship contract is determined by the target type:

  • Scalar target (target=User): loader signature async def fn(keys: list[K]) -> list[V | None], one-to-one return per key;
  • List target (target=list[Tag]): loader signature async def fn(keys: list[K]) -> list[list[V]], returns sub-list per key.

At startup ErManager validates name uniqueness (rel.name in entity_rels → raise), preventing a custom relationship from silently overriding an ORM relationship.

Why it matters: This is one of nexusx's most underappreciated designs. It resolves a fundamental framework dilemma: a framework either leans hard on ORM (gaining reflection convenience but becoming tied down) or stays ORM-agnostic (flexible but requiring hand-written everything). By abstracting "relationship" from "ORM relationship metadata" into "a batch function," nexusx lets both sides share the same infrastructure — DefineSubset can select custom fields, DataLoader can batch-load them, ER diagrams visualize them, selection projects them. The elegance of this abstraction: batch loading is fundamentally a function list[K] → list[V]; ORM is just one implementation of that function. Strawberry / FastAPI in this scenario can only bypass the framework and hand-write — effectively excommunicating this code from the framework's jurisdiction.

8. ROW_NUMBER() OVER (PARTITION BY ...) Batch Pagination

Problem: One-to-many relationships with pagination, naively, become N independent LIMIT queries:

SELECT * FROM post WHERE author_id = 1 ORDER BY id LIMIT 3;   -- parent 1
SELECT * FROM post WHERE author_id = 2 ORDER BY id LIMIT 3;   -- parent 2
SELECT * FROM post WHERE author_id = 3 ORDER BY id LIMIT 3;   -- parent 3
-- ... N parents → N round-trips

This is nearly catastrophic in nested GraphQL pagination — a 5-level-deep query tree with 10 parents per level means N+1 at every level. Some frameworks do application-level batching (one IN query for everything, then in-memory grouping + trimming), but that pulls all data into the app layer, defeating pagination's purpose; and has_more / total_count still need separate queries.

How it works: create_page_one_to_many_loader in src/nexusx/loader/factories.py uses a window function to achieve "N items per parent in one SQL":

SELECT * FROM (
  SELECT *,
    ROW_NUMBER() OVER (PARTITION BY author_id ORDER BY id) AS _rn,
    COUNT(*) OVER (PARTITION BY author_id) AS _total
  FROM post
  WHERE author_id IN (1, 2, 3, ...)
) AS t
WHERE _rn BETWEEN :offset + 1 AND :offset + :limit

One round-trip yields:

  • Each parent's current-page items (_rn BETWEEN ... filter);
  • total_count (COUNT(*) OVER (PARTITION BY ...) computed);
  • has_more (true when _rn <= _offset + _limit but _total > _offset + _limit).

Many-to-many (create_page_many_to_many_loader) adds a layer: first batch-query the association table for (parent_id, child_id) pairs, then window-function pagination, then batch-fetch child details. The entire flow happens in SQL; the application layer only assembles.

PageArgs (pagination.py:24) validates arguments in __post_init__ (limit >= 0, offset >= 0, max_page_size cap); effective_limit uses min(limit, max_page_size) to defend against malicious large pages.

Why it matters: This is the production-grade GraphQL pagination pattern (cf. Shopify's / GitHub's Relay cursor mode internals), but in the Python ecosystem, few frameworks auto-wire it from ORM metadata and share the same abstraction with regular loaders. Its value scales exponentially in nested pagination scenarios: 3-level nesting, 20 parents per level, 5 items per page — naive N+1 is 20×20×20 = 8000 queries; the window-function approach is 3 (one per level). Even ignoring query count, single round-trip means more stable tail latency — which often matters more to SLOs than average latency. Combined with item 5's Pagination type shrinking, nexusx's pagination hits production-grade on all three dimensions of "response correctness + performance + protocol conformance."

9. Auto-Generated Standard Queries — Entities Sprout by_id / by_filter for Free

Problem: CRUD is 80% of backend work, yet every entity needs the same boilerplate hand-written: "query by PK, filter by field, sort, paginate." In a typical FastAPI + SQLModel project, every entity has a router file, every endpoint a chunk of select(Entity).where(...) — high cost to write and maintain. Django DRF solves part of this with ModelViewSet + FilterSet, but ties you to Django; other frameworks either don't solve it, or use code generators (heavy).

How it works: src/nexusx/standard_queries.py uses AutoQueryConfig to auto-generate two query types per entity:

  1. <entity>by_id (_create_by_id_query): reads PK columns from the entity (_get_primary_key_fields handles single PK, composite PK), generates a GraphQL query field with signature (id: ID!) returning a single entity.
  2. <entity>by_filter (_create_by_filter_query): dynamically pydantic.create_models a filter input type, each scalar column becoming an optional filter field supporting = comparison; returns list[Entity] with limit / offset.
class AutoQueryConfig:
    def __init__(
        self,
        session_factory,
        default_limit: int = 10,
        generate_by_id: bool = True,
        generate_by_filter: bool = True,
    ):
        ...

add_standard_queries registers the generated methods on the entity at schema-build time, with the @query decorator — downstream SDL generation treats them like any other @query method. The generated queries also go through the DataLoader path — only scalar columns loaded, relationship fields lazy-loaded per selection, enjoying all the benefits of column pruning and batch pagination.

Why it matters: Eliminates the "hand-write get_by_id for every table" boilerplate. A 20-entity project that used to need 20×3 = 60 endpoints (by_id, by_filter, list) now needs 0 lines of code. But more importantly, it doesn't over-complicate simple problems: when the auto-generated query doesn't fit (e.g. needs join, permission filter, complex sort), just write a @query method — auto queries and hand-written queries coexist. generate_by_id=False turns off one direction to avoid exposing unwanted schema entries. This "default-on + can-disable + doesn't block hand-written" design is more sustainable than "generate everything; modifying means modifying the generator."


Thread 3: Transports Are Layered On Top

10. One UseCaseService Simultaneously Generates GraphQL / REST / MCP / CLI

Problem: Modern backend services often serve multiple client types simultaneously:

  • Web frontend: REST + OpenAPI (mature toolchain, cache-friendly);
  • Mobile: REST or GraphQL (team preference);
  • Third-party integrations: GraphQL (flexible) or REST (standard);
  • AI agents: MCP (Anthropic's tool protocol);
  • Ops: CLI (scripting).

The conventional approach is one codebase per protocol: FastAPI for REST, Strawberry for GraphQL, FastMCP for MCP tools, Click for CLI. The same business method ("list the current user's sprints") is implemented 4 times, with auth, validation, and error handling scattered across 4 codebases; changing business logic means synchronizing 4 places.

How it works: UseCaseService at src/nexusx/use_case/business.py:122 + the BusinessMeta metaclass decouple business-method definition from protocol translation:

class SprintService(UseCaseService):
    @query
    async def list_sprints(cls, limit: int = 10) -> list[SprintSummary]:
        """Get all sprints with task counts."""
        ...

    @mutation
    async def create_sprint(cls, name: str) -> SprintSummary:
        """Create a new sprint."""
        ...

config = UseCaseAppConfig(name="project", services=[SprintService])

# Same config → four transports
mcp = create_use_case_graphql_mcp_server(apps=[config])
app.include_router(create_use_case_router(config))
graphql_handler = create_use_case_graphql_handler(config)
cli = build_cli(config)

Underlying mechanism:

  1. BusinessMeta metaclass (business.py:60) scans the class body at class creation, collecting all @query / @mutation-decorated classmethods into _business_methods. This step is discovery only — the metaclass is unaware of and indifferent to protocols.
  2. One builder per protocol:
  3. compose_schema.py: scans _business_methods, combining method signature (inspect.signature) + return-type annotation (get_type_hints) into a GraphQL schema (dict[str, TypeInfo], see item 13);
  4. router.py:create_router: scans methods the same way, translating to a FastAPI router where each method becomes a POST endpoint;
  5. compose_mcp_server.py: translates into MCP tool definitions where each method becomes a callable tool;
  6. cli.py:build_cli: translates into Click/Typer-style CLI commands.
  7. Protocol-shared parts: argument resolution (FromContext), response projection (selection), error model (errors.py), type mapping (compose_type_mapper.py) are all protocol-agnostic and reused by all four builders.

A comment in compose_executor.py makes a key design explicit: "The executor does NOT wrap results in Resolver()". The GraphQL execution path doesn't auto-apply DTO assembly — if a method wants DefineSubset / Collector capabilities, it calls Resolver().resolve(dtos) in its own body. This is single responsibility: the executor only schedules methods + projects selection; DTO assembly is the business method's job.

Why it matters: Write the business method once; adding a transport is a configuration problem. This is an order-of-magnitude improvement to a project's evolution capability:

  • Adding a new protocol (e.g. gRPC): write a new builder, zero business-code changes;
  • Cross-protocol consistency: argument schemas, error codes, and documentation are inherently in sync because they all generate from the same method signature;
  • Test tiering: business methods are independently unit-testable (no protocol dependency); protocol layers only test translation correctness.

Compare to a typical FastAPI project — one codebase per protocol, changes require synchronizing multiple files, behavior drifts across protocols — nexusx's "single source of truth" advantage scales linearly with the number of transports and entities. This is Clean Architecture's "delivery-independence" actually implemented in engineering, not停留在 PPT 的口号.

11. FromContext — Same Signature, Cross-Transport Parameter Injection

Problem: Business methods often need parameters that are "not in the protocol, but needed at execution time":

  • user_id (decoded from JWT, the client cannot forge it);
  • tenant_id (multi-tenant system);
  • request_id (tracing);
  • db_session (transaction boundary).

The trouble with these parameters:

  • GraphQL path: should not appear in schema (otherwise clients could pass arbitrary user_id);
  • REST path: must be decoded from header / cookie / token — every endpoint repeats Depends(get_current_user);
  • MCP path: must be decoded from MCP context (headers / session metadata);
  • CLI path: from --user-id arguments or environment variables.

The conventional approach is to rewrite extraction logic per endpoint, or stuff a request parameter into the business method (leaking protocol details into the business layer).

How it works: FromContext in src/nexusx/use_case/context.py marks a parameter as "cross-transport injected":

from typing import Annotated
from nexusx.use_case import UseCaseService, FromContext

class TaskService(UseCaseService):
    @query
    async def my_tasks(cls,
                      user_id: Annotated[int, FromContext()],
                      limit: int = 10) -> list[TaskDTO]:
        return await Resolver().resolve(
            await get_tasks_by_user(user_id, limit)
        )

Each transport injects in its own way:

  • GraphQL / MCP: upon detecting FromContext via is_from_context_annotation, the parameter is not exposed in schema (is_from_context=True in compose_type_mapper.py); at execution, the value comes from context_extractor(request) -> {"user_id": ...} (_build_kwargs in compose_executor.py);
  • FastAPI: _make_context_extractor_dep in router.py wraps context_extractor as a FastAPI Depends, decoding the context dict from the Request object, then dispatching to corresponding parameters;
  • CLI: cli.py maps FromContext parameters to --user-id command-line options or environment variables.

context_extractor is a user-supplied callback with signature (request) -> dict | Awaitable[dict], attached at config level:

UseCaseAppConfig(
    name="project",
    services=[TaskService],
    context_extractor=lambda req: {"user_id": decode_jwt(req.headers["Authorization"])},
)

Why it matters: The signature is a single source of truth. Cross-cutting concerns like auth, tenant context, and tracing used to require rewriting extraction logic per endpoint — a 30-endpoint project might write Depends(get_current_user) 30 times, each handling token parsing, error responses, user-object loading. FromContext reduces this to one annotation on the method signature + one callback at config level. More importantly, it preserves business-method purity: the business method sees only user_id: int, unaware whether the value came from JWT, cookie, API key, or CLI argument — this is the precise implementation of Clean Architecture's "business logic doesn't depend on delivery details." When a project migrates from single-tenant to multi-tenant, from session auth to JWT, from HTTP to gRPC, the business methods change zero lines.

12. UseCase Path Explicitly Rejects GraphQL Introspection — AI-Agent-First Design

Problem: One of GraphQL's signature capabilities is introspection — clients query __schema to get the entire schema; tools (GraphiQL, Apollo Studio) depend on it for autocomplete and docs. But for LLM agents, introspection is a disaster:

  • Token cost: a project with 30 services × 5 methods per service may have a __schema JSON output exceeding 50K tokens, carried on every conversation;
  • Cognitive load: LLM accuracy finding "which method should I call" inside a giant schema is far lower than calling a structured discovery tool;
  • Cache friendliness: introspection response is constant but transmitted every time, wasting the "hot" portion of the context window.

MCP's design philosophy is "tool interfaces specifically optimized for LLMs," but many GraphQL-via-MCP solutions just forward the GraphQL endpoint, letting the LLM run introspection queries — inheriting GraphQL's problems wholesale.

How it works: src/nexusx/use_case/compose_executor.py actively rejects introspection and provides dedicated discovery tools:

_INTROSPECTION_REJECTION_HINT = (
    "GraphQL introspection is not available via compose_query. "
    "Use describe_compose_schema(app_name=...) and "
    "describe_compose_method(app_name=..., service_name=..., method_name=...) "
    "to discover the schema."
)

Implementation in two steps:

  1. AST-level detection: _document_uses_introspection walks the GraphQL AST before execution; any __schema / __type / __typename field triggers rejection before method dispatch. This avoids "dispatch first, fail later" side effects.
  2. Dedicated discovery tools: describe_compose_schema(app_name) returns a compact service/method list (one line per method: description + parameter schema + return type); describe_compose_method(...) returns detailed schema for a single method. LLMs can discover in stages: describe_compose_schema for the overview, then describe_compose_method for methods of interest.

describe_compose_schema's output is 5-10x more compact than GraphQL introspection — stripping type-system meta-info (kind, of_type nesting, etc.) and keeping only what's actually useful to the LLM: "what's this method called, what parameters, what return."

Why it matters: This is anti-GraphQL-textbook but consistent with MCP philosophy. In the "human developer using an IDE to write queries" scenario, introspection + GraphiQL is irreplaceable DX; in the "LLM agent using tools to complete tasks" scenario, a giant schema is a burden. nexusx separates the two: the GraphQL path (GraphQLHandler) preserves full introspection for humans; the UseCase / MCP path (compose_query) rejects introspection for agents. This "same framework, different protocols, different optimization targets"精细化设计 is the genuine expression of nexusx treating AI agents as first-class citizens, rather than "wrapping GraphQL in a layer of MCP and calling it MCP support." For teams actually building agents with nexusx, this decision translates directly to lower token costs and higher LLM call accuracy — both core KPIs in production agent systems.


Supporting Engineering: Less Visible but Critical Decisions

13. Custom Schema Registry — Not Using graphql-core's GraphQLSchema Directly

How it works: The GraphQL schema is modeled as dict[str, TypeInfo] (compose_schema.py); each TypeInfo is a frozen + slotted dataclass whose structure is isomorphic to the graphql introspection __schema payload. render_introspection() converts this internal representation to introspection JSON; the result round-trips through graphql.utilities.build_client_schema(...) and feeds GraphiQL. IntrospectionGenerator (introspection.py:24) follows a similar path, generating introspection JSON directly from entities + methods, without going through graphql-core's GraphQLSchema.

Why it matters: Three benefits: (1) avoids being tied to graphql-core's internal APIs — smaller migration surface when graphql-core upgrades (graphql-core 5.0 changed a number of internal interfaces); (2) UseCase / GraphQL / MCP share the same schema description, no triple conversion layer; (3) slotted dataclasses have smaller memory footprint and faster construction than graphql-core's GraphQLObjectType etc., helping both startup time and concurrency. This is a "we thought it through" architectural decision — usually invisible but every path benefits.

14. split_loader_by_type — Precise Isolation for Multi-Consumer Column Conflicts

How it works: By default, the same loader shares one global instance (loader_cls → instance in get_loader); when multiple consumers reuse it, _query_meta fields union (see item 4). But occasionally the same relationship in a tree is consumed by different DTO types with wildly different column needs — say PostCardDTO wants only title and cover_url, PostDetailDTO wants 12 columns — the union grows until SELECT is effectively back to SELECT *. split_loader_by_type=True switches to split mode, keying loader instances by frozenset[fields] as a second-level cache:

# Default: {loader_cls: instance}
# Split:   {loader_cls: {frozenset[fields]: instance}}
def get_loader(self, loader_cls, type_key=None):
    if not self._split_mode or type_key is None:
        return self._loader_instances.setdefault(loader_cls, loader_cls())
    inner = self._loader_instances.setdefault(loader_cls, {})
    return inner.setdefault(type_key, loader_cls())

Why it matters: This is the precise solution to a niche but real scenario. Most projects don't need this switch (the default union mode is fine), but when "the same relationship appears in a response as multiple DTO variants" hits, split mode makes column pruning actually work rather than getting diluted by unions. The default-off embodies "don't pay complexity for non-existent needs," but it's there when needed.

15. Forward Reference / PEP 563 Handling

How it works: Correctly resolving types under from __future__ import annotations + SQLModel + Pydantic + list["Post"] + int | None mix is unglamorous work — invisible when present, breaking everything when absent. _resolve_forward_reference in response_builder.py handles string-form forward references; _extract_entity_from_annotation extracts entity types from complex annotations (Optional[list[X]] etc.). _get_namespace_annotations in subset.py even handles Python 3.14's behavior change — 3.14 in some cases sets __annotations__ to None inside class body, requiring direct namespace reads.

Why it matters: Python type-annotation behavior is inconsistent across 3.10 / 3.11 / 3.12 / 3.13 / 3.14 — each version has its own edge cases. The quality of this "glue code beneath the framework skeleton" directly determines "does it run out of the box" — a project running fine on 3.10 may break on 3.12 due to runtime representation changes of X | Y. nexusx invests considerable code in this part (see subset.py's multiple _unwrap_* / _get_namespace_annotations / _extract_* functions). This code isn't glamorous, but without it the framework wouldn't stand. It's a hidden indicator of engineering maturity.


How the Highlights Relate

flowchart TB
    subgraph Main1["Thread 1: Field selection as first-class citizen"]
        DS["DefineSubset<br/>declarative DTO"]
        SEL["selection projection<br/>runtime field selection"]
        QM["_query_meta + load_only<br/>SQL column pruning"]
        PAG["Pagination type shrinkage"]
        ESC["ExposeAs/SendTo/Collector<br/>in-tree context flow"]
    end

    subgraph Main2["Thread 2: Relationships beyond ORM"]
        AUTO["inspect → DataLoader<br/>ORM auto-discovery"]
        CUST["Custom Relationship<br/>non-ORM escape hatch"]
        ROWNUM["ROW_NUMBER batch pagination"]
    end

    subgraph Main3["Thread 3: Transports are layered on top"]
        UC["UseCaseService<br/>one signature, many transports"]
        FC["FromContext<br/>cross-transport injection"]
        NOINTRO["Reject introspection<br/>AI-agent-first"]
    end

    DS --> QM
    SEL --> QM
    QM --> PAG
    DS --> ESC

    AUTO --> DS
    CUST --> AUTO
    AUTO --> ROWNUM
    ROWNUM --> PAG

    UC --> DS
    UC --> SEL
    FC --> UC
    NOINTRO --> UC

The three threads aren't independent: DefineSubset's post_* hooks reuse DataLoader (Threads 1 + 2); selection projection triggers column pruning on the UseCase path (Threads 1 + 3); custom Relationship also flows into DefineSubset (Threads 2 + 1). This "layers feeding each other" property is the fundamental reason nexusx-the-whole is greater than the sum of its parts.


Comparison With Other Frameworks (Selected Dimensions)

Dimension nexusx Strawberry FastAPI + SQLModel FastMCP
GraphQL field selection ←→ Python DTO Consistent via DefineSubset GraphQL-only Hand-written N/A
Auto relationship loading (no resolver) ✓ inspect + custom Hand-written resolver
N+1 prevention ✓ DataLoader auto Manual DataLoader wiring
SQL column pruning follows selection _query_meta
Cross-layer context in DTO tree ✓ ExposeAs/SendTo
Non-ORM relationships Relationship(...)
Same signature → GraphQL+REST+MCP ✓ UseCaseService
AI-agent-friendly schema discovery ✓ Rejects introspection + discovery tools Partial

For Contributors: Where to Invest Next

Several potential improvement directions I noticed during this audit (not "highlights" yet, but "next-to-shine"):

  • Item 4 (_query_meta) is currently a runtime union: if selection → column-pruning inference were hoisted to schema-build time (for known selection sets), per-request merge overhead could be eliminated.
  • Item 7 (custom Relationship) integration with GraphQL schema generation: how custom relationships get first-class representation in SDL / ER diagrams is currently under-documented.
  • Item 12 (introspection rejection) agent discovery tools: could describe_compose_method further generate few-shot examples to help LLMs learn to call methods — an open problem in the MCP ecosystem.
  • Item 14 (split_loader_by_type) observability: enabling split mode causes loader-instance count to explode with type_key combinations; monitoring metrics are needed.

Closing

nexusx's true selling point isn't "yet another GraphQL library" or "yet another MCP framework," but unifying three things that are usually separate — field selection, relational batch loading, and transport translation — into one programming model. DefineSubset is the syntactic expression of this unity; ExposeAs / SendTo / Collector is its extension over tree structures; UseCaseService is its extension across transports.

If you're building a project that needs GraphQL's flexibility + REST's delivery + AI agent integration, the "don't rewrite" savings along these three dimensions is order-of-magnitude.

Related docs - Clean Architecture Comparison — nexusx vs. Litestar / Django / Strawberry et al. - Cross-Layer Data Flow APIExposeAs / SendTo / Collector API reference - How it works — full conversion logic and module deep-dive (in-repo)