Skip to main content

ADR-0006: MX Pattern - Building Database Variants Without Forking

  • Status: Accepted
  • Date: 2026-01-27
  • Deciders: @anshpansuriya14
  • Supersedes: N/A
  • Superseded by: N/A

Context

Enterprise customers often have non-negotiable database requirements:

  • MongoDB-only shops: "We standardized on MongoDB. SQLAlchemy is not an option."
  • Cassandra for scale: "We need distributed writes. PostgreSQL won't work."
  • Legacy Oracle: "All our data is in Oracle. We can't migrate."

Traditional frameworks force you to:

  1. Fork the framework (maintenance nightmare)
  2. Accept their database (organizational conflict)
  3. Build from scratch (expensive)

Framework-M solves this with the MX Pattern: Pure package composition enables database customization without forking.


Problem Statement

The Enterprise Dilemma

A company using MongoDB wants Framework-M's features but:

  • ❌ Can't use SQLAlchemy (organizational policy)
  • ❌ Can't fork Framework-M (maintenance cost)
  • ❌ Can't afford to build everything from scratch

Traditional Solution: Fork the framework, rip out SQLAlchemy, add MongoDB → Now you own a fork forever.

MX Pattern Solution: Install framework-mx-mongo package → MongoDB becomes the database without touching core code.


Decision

We split Framework-M into three layers to enable the MX pattern:

1. Core (Protocols Only)

Package: framework-m-core

Contains:

  • ✅ All Protocols (Interfaces): RepositoryProtocol, SchemaMapperProtocol, UnitOfWorkProtocol, etc.
  • ✅ DI Container (dependency-injector)
  • ✅ BaseDocType system
  • ✅ Controller/Hook framework
  • ✅ CLI foundation (cyclopts)

Does NOT contain:

  • ❌ Any database adapters
  • ❌ SQLAlchemy imports
  • ❌ Concrete implementations

Philosophy: Core defines what the system needs, not how to provide it.

# framework-m-core: Pure protocol
from typing import Protocol

class RepositoryProtocol[T](Protocol):
async def get(self, id: UUID) -> T | None: ...
async def save(self, entity: T) -> T: ...
# No SQLAlchemy, no MongoDB - just the contract

2. Standard (SQLAlchemy Adapters)

Package: framework-m-standard

Contains:

  • ✅ SQLAlchemy implementations of all protocols
  • ✅ PostgreSQL/MySQL/SQLite support
  • ✅ Litestar web framework integration
  • ✅ NATS event bus adapter
  • ✅ Redis cache adapter

Philosophy: The "default" batteries for 80% of users.

# framework-m-standard: SQLAlchemy implementation
from framework_m_core.interfaces.repository import RepositoryProtocol

class GenericRepository[T](RepositoryProtocol[T]):
async def get(self, id: UUID) -> T | None:
# SQLAlchemy implementation
result = await session.execute(select(...))
return result.scalar_one_or_none()

3. MX Packages (Custom Adapters)

Example: framework-mx-mongo

Contains:

  • ✅ MongoDB implementations of all protocols
  • ✅ Motor (async MongoDB driver) integration
  • ✅ JSON Schema validators for Pydantic models
  • ✅ MongoDB transaction support

Philosophy: Enterprises can publish their own framework-mx-* packages for custom databases.

# framework-mx-mongo: MongoDB implementation
from framework_m_core.interfaces.repository import RepositoryProtocol
from motor.motor_asyncio import AsyncIOMotorDatabase

class MongoGenericRepository[T](RepositoryProtocol[T]):
async def get(self, id: UUID) -> T | None:
# MongoDB implementation
doc = await self._collection.find_one({"id": str(id)})
return self._model.model_validate(doc) if doc else None

How It Works: Entry Points

The discovery mechanism uses Python's entry_points system:

1. Protocol Registration

Framework-M-Standard registers its adapters:

# libs/framework-m-standard/pyproject.toml
[project.entry-points."framework_m.adapters.repository"]
default = "framework_m_standard.adapters.db.generic_repository:GenericRepository"

[project.entry-points."framework_m.adapters.schema_mapper"]
default = "framework_m_standard.adapters.db.schema_mapper:SchemaMapper"

[project.entry-points."framework_m.adapters.unit_of_work"]
default = "framework_m_standard.adapters.db.session:SessionFactory"

2. MX Override

Framework-MX-Mongo registers the same entry point names, overriding the defaults:

# libs/framework-mx-mongo/pyproject.toml
[project.entry-points."framework_m.adapters.repository"]
default = "framework_mx_mongo.repository:MongoGenericRepository"

[project.entry-points."framework_m.adapters.schema_mapper"]
default = "framework_mx_mongo.schema_mapper:MongoSchemaMapper"

[project.entry-points."framework_m.adapters.unit_of_work"]
default = "framework_mx_mongo.unit_of_work:MongoSessionFactory"

3. Bootstrap Discovery

At startup, Framework-M:

  1. Scans all framework_m.adapters.* entry points
  2. Binds discovered adapters to DI container
  3. Priority: MX package > Standard > Core
# Automatic discovery at startup
from importlib.metadata import entry_points

def discover_adapters():
for ep in entry_points(group="framework_m.adapters.repository"):
if ep.name == "default":
adapter_class = ep.load()
container.bind(RepositoryProtocol, adapter_class)
# If framework-mx-mongo installed, MongoGenericRepository wins

Developer Experience

For Standard Users

# Install "batteries included"
pip install framework-m

# Uses SQLAlchemy by default
m migrate
m prod

For MongoDB Users

# Install core + MongoDB variant
pip install framework-m-core framework-mx-mongo

# Now uses MongoDB everywhere
m migrate # Creates MongoDB collections
m prod # Uses Motor for queries

Application Code (Identical!)

# This code works with BOTH SQLAlchemy and MongoDB!
from framework_m_core.interfaces.repository import RepositoryProtocol

class UserService:
def __init__(self, repo: RepositoryProtocol[User]):
self._repo = repo # Injected by DI

async def get_user(self, id: UUID) -> User | None:
return await self._repo.get(id)
# Works with SQL or MongoDB - protocol guarantees compatibility

Key Insight: Application code depends on Protocols, not concrete adapters. The DI container handles the binding.


Benefits

1. No Fork Required

  • ✅ Enterprises customize by installing packages, not forking code
  • ✅ Upstream updates flow automatically
  • ✅ No merge conflicts, no drift

2. Zero Vendor Lock-in

  • ✅ Switch from SQL → MongoDB → Cassandra by changing pip install
  • ✅ Test with SQLite, deploy with PostgreSQL, migrate to MongoDB later
  • ✅ Multi-database applications (SQL for transactions, MongoDB for logs)

3. Clean Separation

  • ✅ Core remains infrastructure-agnostic
  • ✅ Standard provides "good defaults"
  • ✅ MX packages are independent, publishable artifacts

4. Ecosystem Growth

  • ✅ Community can publish framework-mx-cassandra, framework-mx-dynamodb, etc.
  • ✅ Framework-M doesn't need to support every database
  • ✅ Best-of-breed adapters compete in the ecosystem

Trade-offs

Accepted Trade-offs

Trade-offWhy Acceptable
Complexity: Three packages instead of one80% of users install framework-m meta-package and never think about it
Protocol Overhead: Defining interfaces requires disciplineType safety and testability gains outweigh boilerplate
Discovery Latency: Entry point scanning at startupHappens once per boot; negligible compared to DB connection

Mitigations

  • Meta-package: framework-m bundles Core + Standard for simple installs
  • Documentation: Clear tutorials for both standard and MX paths
  • Testing: Protocol compliance tests ensure adapters are compatible

Implementation Checklist

Core Package

  • Extract all Protocols to framework-m-core
  • Remove all SQLAlchemy imports from core
  • Define entry point groups for adapters
  • Implement DI auto-binding from entry points

Standard Package

  • Move SQLAlchemy adapters to framework-m-standard
  • Register entry points for all adapters
  • Maintain 100% backward compatibility

MX Example

  • Create framework-mx-mongo as proof-of-concept
  • Implement all required protocols
  • Demonstrate override mechanism works
  • Write comprehensive tutorial

Documentation

  • Protocol reference guide
  • MX pattern architecture (this document)
  • MongoDB MX tutorial
  • Guide: "Building Custom MX Packages"

Protocol Inventory

Framework-M defines these core protocols:

ProtocolPurposeEntry Point Group
RepositoryProtocolCRUD operationsframework_m.adapters.repository
SchemaMapperProtocolSchema creation/migrationframework_m.adapters.schema_mapper
UnitOfWorkProtocolTransaction managementframework_m.adapters.unit_of_work
BootstrapProtocolStartup initializationframework_m.bootstrap
PermissionProtocolAuthorizationframework_m.adapters.permission
EventBusProtocolPub/sub messagingframework_m.adapters.event_bus
CacheProtocolCaching layerframework_m.adapters.cache

Each protocol is:

  • ✅ Database-agnostic (no SQLAlchemy types in signatures)
  • ✅ Async-first
  • ✅ Type-safe (full generics support)
  • ✅ Runtime-checkable

Real-World Example: MongoDB Primary

Scenario

Company: Large financial institution Requirement: All data must be in MongoDB (compliance + expertise) Challenge: Want Framework-M's metadata engine, RBAC, and DocType system

Solution

# Install MX variant instead of standard
pip install framework-m-core framework-mx-mongo

# Configure MongoDB connection
export DATABASE_URL="mongodb://localhost:27017/myapp"

# Bootstrap
m migrate # Creates MongoDB collections with JSON Schema validators
m prod # Litestar + Motor + Framework-M

Application Code

# doctypes/invoice.py - IDENTICAL to SQL version!
from framework_m_core.domain import BaseDocType

class Invoice(BaseDocType):
customer: str = Field(index=True)
amount: float
status: str = "Draft"

# controllers/invoice_controller.py
from framework_m_core.interfaces.repository import RepositoryProtocol

class InvoiceController:
def __init__(self, repo: RepositoryProtocol[Invoice]):
self._repo = repo

async def create_invoice(self, data: dict) -> Invoice:
invoice = Invoice.model_validate(data)
return await self._repo.save(invoice)
# Uses MongoDB via MongoGenericRepository - no code changes!

What Changed?

Code: Nothing Dependencies: framework-m-standardframework-mx-mongo Database: PostgreSQL → MongoDB Business Logic: Identical


Comparison: Framework-M vs Traditional Frameworks

AspectTraditional FrameworkFramework-M (MX Pattern)
Database ChangeFork + rewrite adaptersInstall different package
MaintenanceMerge upstream foreverAuto-update via pip
Multi-DB SupportHack job, fragileInstall both packages, configure binds
TestingHard to mock DB layerInject test doubles via protocols
Type SafetyVaries100% typed protocols
Lock-inHigh (coupled to ORM)None (protocol-based)

Future Possibilities

MX Ecosystem

  • framework-mx-cassandra: Distributed writes, high availability
  • framework-mx-dynamodb: AWS-native, serverless
  • framework-mx-neo4j: Graph database for complex relationships
  • framework-mx-hybrid: SQL for transactions, MongoDB for events

Multi-Database Applications

# Use SQLAlchemy for transactional data
pip install framework-m-standard

# Use MongoDB for audit logs (write-heavy)
pip install framework-mx-mongo

# Configure binds in config
DATABASES = {
"default": "postgresql://...", # SQLAlchemy
"audit": "mongodb://...", # MongoDB
}

Application code uses the same RepositoryProtocol - DI binds the right adapter based on DocType metadata.


Alignment with Framework-M Principles

Ports & Adapters (Hexagonal Architecture)

  • Ports: Protocols define the contracts (Core)
  • Adapters: Implementations are pluggable (Standard/MX)
  • Domain: Business logic depends only on protocols

No Vendor Lock-in

  • ✅ Switching databases requires zero code changes
  • ✅ Applications are portable across infrastructures

Type Safety

  • ✅ Protocols enforce compile-time guarantees
  • mypy --strict validates protocol compliance

Testability

  • ✅ Mock repositories for unit tests
  • ✅ In-memory adapters for integration tests
  • ✅ Protocol compliance tests ensure compatibility

Hybrid SQL Subclassing (The Hybrid MX Pattern)

While some databases (like MongoDB) require completely swapping all protocols with database-specific implementations, relational database variants (like Oracle, MSSQL, and MySQL) can utilize the Hybrid SQL Pattern.

Rather than rewriting the entire repository, mapping, and transaction management codebase from scratch, hybrid MX packages subclass the default adapters from framework-m-standard and override only the dialect-specific parts.

Extensible Hooks in framework-m-standard

To support this, the standard adapters expose clean hooks for subclasses to customize:

  1. SchemaMapper Hooks:

    • _get_column_type(self, _field_name: str, python_type: type, field_info: FieldInfo): Allows customizing column datatype mappings (e.g. mapping bool to Numeric(1) on Oracle).
  2. DocumentNamingService Hooks:

    • _fetch_sequence_value(self, session: AsyncSession, sequence_name: str): Allows custom query generation for native sequence values (e.g. Oracle .nextval or MSSQL NEXT VALUE FOR).
  3. DeclarativeSyncEngine Hooks:

    • _check_ddl_permissions(self, engine: Engine): Overrides table creation checks.
    • _apply_lock_timeout(self, conn: Any, dialect_name: str, max_lock_time: int | None): Custom DDL execution locks.
    • _create_schema(self, conn: Any, dialect_name: str, schema_name: str | None): Custom schema scoping rules.
    • _get_add_column_sql(self, engine: Engine, table_name: str, column_name: str, col_type: str, nullable: str, default: str): Custom table alteration commands.

This hybrid approach allows the framework to support the "Big 4" relational databases (PostgreSQL, Oracle, SQL Server, MySQL) officially and with minimal code duplication.


Conclusion

The MX Pattern enables enterprises to adopt Framework-M without database compromises:

  1. Core provides infrastructure-agnostic abstractions
  2. Standard offers battle-tested SQLAlchemy defaults
  3. MX packages customize for specific databases

Result: Organizations gain Framework-M's metadata engine, RBAC, and DocType system while using their required database - all without forking.

This architectural decision transforms Framework-M from "another opinionated framework" into a composable platform that respects enterprise constraints.


References


Appendix: Entry Point Groups

Framework-M defines these standard entry point groups:

Adapters

framework_m.adapters.repository
framework_m.adapters.schema_mapper
framework_m.adapters.naming_service
framework_m.adapters.declarative_sync_engine
framework_m.adapters.unit_of_work
framework_m.adapters.permission
framework_m.adapters.event_bus
framework_m.adapters.cache
framework_m.adapters.storage
framework_m.adapters.introspection
framework_m.adapters.discovery

Bootstrap Steps

framework_m.bootstrap

CLI Commands

framework_m_core.cli_commands

Each group follows the convention:

  • Name: "default" for primary adapters, or {dialect_name} (e.g., oracle, mssql, snowflake) for database-dialect-specific overrides.
  • Value: "module.path:ClassName"
  • Priority: Last installed package wins (MX overrides Standard), with dialect-specific matches resolved first.

Status: This ADR is accepted and fully implemented in Phase 11.