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:
- Fork the framework (maintenance nightmare)
- Accept their database (organizational conflict)
- 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:
- Scans all
framework_m.adapters.*entry points - Binds discovered adapters to DI container
- 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-off | Why Acceptable |
|---|---|
| Complexity: Three packages instead of one | 80% of users install framework-m meta-package and never think about it |
| Protocol Overhead: Defining interfaces requires discipline | Type safety and testability gains outweigh boilerplate |
| Discovery Latency: Entry point scanning at startup | Happens once per boot; negligible compared to DB connection |
Mitigations
- Meta-package:
framework-mbundles 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-mongoas 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:
| Protocol | Purpose | Entry Point Group |
|---|---|---|
RepositoryProtocol | CRUD operations | framework_m.adapters.repository |
SchemaMapperProtocol | Schema creation/migration | framework_m.adapters.schema_mapper |
UnitOfWorkProtocol | Transaction management | framework_m.adapters.unit_of_work |
BootstrapProtocol | Startup initialization | framework_m.bootstrap |
PermissionProtocol | Authorization | framework_m.adapters.permission |
EventBusProtocol | Pub/sub messaging | framework_m.adapters.event_bus |
CacheProtocol | Caching layer | framework_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-standard → framework-mx-mongo
Database: PostgreSQL → MongoDB
Business Logic: Identical
Comparison: Framework-M vs Traditional Frameworks
| Aspect | Traditional Framework | Framework-M (MX Pattern) |
|---|---|---|
| Database Change | Fork + rewrite adapters | Install different package |
| Maintenance | Merge upstream forever | Auto-update via pip |
| Multi-DB Support | Hack job, fragile | Install both packages, configure binds |
| Testing | Hard to mock DB layer | Inject test doubles via protocols |
| Type Safety | Varies | 100% typed protocols |
| Lock-in | High (coupled to ORM) | None (protocol-based) |
Future Possibilities
MX Ecosystem
framework-mx-cassandra: Distributed writes, high availabilityframework-mx-dynamodb: AWS-native, serverlessframework-mx-neo4j: Graph database for complex relationshipsframework-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 --strictvalidates 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:
-
SchemaMapperHooks:_get_column_type(self, _field_name: str, python_type: type, field_info: FieldInfo): Allows customizing column datatype mappings (e.g. mappingbooltoNumeric(1)on Oracle).
-
DocumentNamingServiceHooks:_fetch_sequence_value(self, session: AsyncSession, sequence_name: str): Allows custom query generation for native sequence values (e.g. Oracle.nextvalor MSSQLNEXT VALUE FOR).
-
DeclarativeSyncEngineHooks:_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:
- Core provides infrastructure-agnostic abstractions
- Standard offers battle-tested SQLAlchemy defaults
- 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.