Hexagonal Architecture & Design FAQ
Frequently Asked Questions on Framework M's Mental Model, Hexagonal Ports & Adapters, and Enterprise Production Patterns.
1. Architectural Philosophy: The Hexagonal Mental Model
Q: Why is Framework M not a drop-in clone of Frappe?
A: Legacy ERP frameworks (like Frappe) were built around a 2010s monolithic, tightly coupled paradigm:
- Global State & Thread-Locals: Reliance on globals like
frappe.db,frappe.session.user, andfrappe.flags. - Coupled Persistence: Direct, synchronous SQL execution mixed directly into controller classes and web endpoints.
- Implicit Transactions: Database commits and rollbacks implicitly tied to WSGI request cycles.
- Magic Auto-CRUD: Forms write directly to database tables without bounded contexts or domain validation.
Framework M is built from the ground up on Hexagonal (Ports and Adapters) Architecture for high-concurrency, asynchronous, decoupled enterprise platforms:
- Strict Hexagonal Separation: Pure business logic (inside the hexagon) is completely decoupled from databases, web frameworks, and third-party drivers.
- Code-First Pydantic v2: 100% static type safety, compile-time validation, automatic JSON Schema generation, and end-to-end TypeScript contracts.
- Async ASGI Core: Built on Litestar + SQLAlchemy 2.0 Async, handling WebSockets, background event streaming, and non-blocking I/O.
- Micro-Frontend Federation: The Desk SPA dynamically mounts remote MFEs across independent macroservices.
Q: What is the anatomy of the Hexagon in Framework M?
A: The framework is structured into four distinct concentric layers:
| Layer | Responsibility | Components & Interfaces |
|---|---|---|
| Driving (Inbound) Adapters | Translates external client requests and system triggers into domain calls | Litestar ASGI HTTP Routes, RPC Handlers, CLI Commands (m dev, m audit), WebSockets, Background Event Listeners |
| Ports (Interfaces) | Pure Python Protocol contracts defining infrastructure requirements | AuthContextProtocol, PermissionProtocol (PDP), RepositoryProtocol, IdentityProtocol, StorageProtocol, CacheProtocol, EventBusProtocol |
| Core Domain (Inside) | Business rules, validation, and aggregate invariant enforcement | BaseDocType Entities, Value Objects, Domain Events, Application Services (OrderService, CaseService) |
| Driven (Outbound) Adapters | Concrete implementations of infrastructure ports | SQLAlchemy 2.0 (PostgreSQL/SQLite), Redis/Dragonfly, NATS JetStream, AWS S3/MinIO, SpiceDB (ReBAC), OPA (ABAC) |
- Core Domain (Inside the Hexagon):
Contains domain models (
BaseDocType), value objects, aggregate roots, domain events, and Application Services. The core domain never imports external drivers (no SQLAlchemy, no Litestar, no Redis, no S3). - Ports (
framework_m_core.interfaces): Clean, typed PythonProtocolclasses declaring exactly what the domain requires (e.g.RepositoryProtocol,PermissionProtocol,IdentityProtocol,EventBusProtocol). - Driving (Inbound) Adapters:
Translate external triggers into domain operations: Litestar HTTP/REST routes, RPC endpoints, WebSockets, CLI commands (
m dev,m audit), and event consumers. - Driven (Outbound) Adapters: Implement port contracts using specific technology backends: SQLAlchemy async repositories, Redis caches, NATS JetStream event buses, AWS S3 storage, or SpiceDB graph engines.
- Dependency Injection & Inversion of Control:
The
Container(framework_m_core.container.Container) binds Ports to Adapters dynamically via Python entry points (framework_m.adapters.*) or runtime overrides.
2. Inbound Boundary & Domain Modeling: Driving Ports & Application Services
Q: Why shouldn't complex applications rely on generic auto-CRUD (/api/v1/{DocType})?
A: Generic auto-CRUD routes (meta_router.py) are an Inbound Scaffolding Adapter intended strictly for administrative tooling, rapid prototyping, and generic lookups.
In domain-driven design, sensitive enterprise workflows (such as case management, medical records, or financial approvals) have rich contextual business invariants:
- Who is performing the action?
- What state transitions are permitted?
- What integration events or ledger entries must be triggered?
Attempting to pack complex, context-dependent business logic into generic CRUD endpoints leads to fragile controller hooks and authorization bypasses. In production applications (like business-m and tenant-registry), teams write explicit Application Services (OrderService, CaseService) that receive explicit UserContext and govern domain operations within an explicit UnitOfWork.
# The Hexagonal Production Pattern (Explicit Application Service)
class CaseService:
def __init__(
self,
repo: GenericRepository[Case],
perm: PermissionProtocol,
uow_factory: UoWFactory,
event_bus: EventBusProtocol,
):
self.repo = repo
self.perm = perm
self.uow_factory = uow_factory
self.event_bus = event_bus
async def file_case(self, user: UserContext, data: CaseCreateDTO) -> Case:
# 1. Stateless Authorization Check via Permission Port
decision = await self.perm.evaluate(
PolicyEvaluateRequest(
principal=user.id,
action=PermissionAction.CREATE,
resource="Case",
tenant_id=user.tenant_id,
principal_attributes={"roles": user.roles, "teams": user.teams},
resource_attributes=data.model_dump(),
)
)
if not decision.authorized:
raise PermissionDenied(decision.reason)
# 2. Transactional Mutation via UnitOfWork Port
async with self.uow_factory() as uow:
case = Case(owner=user.id, **data.model_dump())
saved = await self.repo.save(uow.session, case, context={"user": user})
# 3. Publish Domain Event via Event Bus Port
await self.event_bus.publish(
Event(
type="case.filed",
subject=f"Case:{saved.id}",
data={"case_id": saved.id, "filed_by": user.id},
)
)
await uow.commit()
return saved
3. Authorization & Permissions Port: PermissionProtocol & ReBAC
Q: Why is ReBAC NOT solved by writing custom SQL queries in domain endpoints?
A: In Framework M's hexagonal architecture, business logic and domain services must never contain hardcoded SQL joins or graph queries for authorization (such as ad-hoc WHERE EXISTS (SELECT 1 FROM document_share ...)).
Coupling business services to raw SQL authorization creates severe architectural liabilities:
- Bypasses the Policy Decision Point (PDP): Centralized security features—such as audit logging, decision caching, attribute enrichment, multi-tenant lineage, and quota checks—are completely bypassed.
- Violates Hexagonal Decoupling: If you swap your authorization engine (for example, moving from DocType RBAC to an enterprise Zanzibar/SpiceDB graph or OPA), you would be forced to rewrite SQL queries across every domain controller and repository.
- Pollutes Domain Boundaries: Application Services exist to enforce business invariants on aggregate roots, not to navigate database security graphs.
Q: How does Framework M model authorization via PermissionProtocol?
A: Authorization is governed by the PermissionProtocol port (framework_m_core.interfaces.permission). It acts as Framework M's universal, stateless Policy Decision Point (PDP):
evaluate(request: PolicyEvaluateRequest) -> PolicyEvaluateResult: A stateless point-in-time authorization check for single-entity actions (read,write,create,delete,submit,cancel,amend). All context (principal, action, resource, tenant lineage, attributes) is provided upfront.get_permitted_filters(principal, principal_attributes, resource, tenant_id) -> dict[str, Any]: Translates authorization policy into repository query filters for Row-Level Security (RLS) viaapply_rls_filters().
Q: What adapters implement PermissionProtocol (RBAC vs. ReBAC vs. ABAC)?
A: Framework M supports swappable adapters configured via the DI Container (Container().permission_adapter) or pyproject.toml entry points ([project.entry-points."framework_m.adapters.permission_adapter"]):
| Adapter | Model | Implementation Location | Purpose |
|---|---|---|---|
RbacPermissionAdapter | Role-Based (RBAC) | framework_m_standard.adapters.auth | Evaluates role permissions defined in DocType.Meta.permissions and CustomPermission records. |
SpiceDbPermissionAdapter | Relationship-Based (ReBAC) | Enterprise / Zanzibar engine | Evaluates complex relationship graphs (e.g., User -> Member of Team -> Editor of Project -> Viewer of Document). |
OpaPermissionAdapter | Attribute-Based (ABAC) | Open Policy Agent (OPA) | Evaluates external Rego policies over HTTP/gRPC without altering Python code. |
FederatedPermissionAdapter | Federated / Mesh | Multi-service deployments | Resolves entitlements across distributed microservices. |
ComboPermissionAdapter | Hybrid | Multi-strategy | Chains ReBAC/ABAC evaluations with fallback to standard DocType RBAC. |
Q: How does the Port/Adapter architecture solve the "O(N) ID Materialization Problem"?
A: The "O(N) ID Materialization Problem" is an adapter-level implementation failure:
When a list query runs, apply_rls_filters() calls adapter.get_permitted_filters(...). A naive ReBAC adapter asks the graph engine LookupResources(user), receives 50,000 permitted IDs into application memory, and returns {"id": ["id-1", ..., "id-50000"]}. This materializes a massive WHERE id IN (...) SQL clause, exceeding database bind limits (SQLite 999 / 32,766; PostgreSQL 65,535) and crashing workers with query-planner memory churn.
How a well-architected PermissionAdapter solves this:
- Attribute & Boundary Translation in the Adapter:
Instead of enumerating individual document IDs, the adapter translates graph relationships into coarse-grained attribute constraints (e.g.
{"tenant_id": user.tenant_id, "department_id": user.department_ids}). These execute against composite B-tree indexes inO(log M)time. - Read-Side Projection Adapters (CQRS):
In high-scale ReBAC systems, the adapter subscribes to authorization events. When a relationship is granted or revoked, the adapter updates a pre-indexed read projection. In list queries, the adapter queries the pre-computed projection in
O(1)time. - Decoupled Filter Expressions: When fine-grained sharing is required, the adapter emits subquery filter expressions rather than serializing ID arrays into Python memory. Application services and controllers remain 100% clean and agnostic of the underlying query mechanism.
4. Authentication & Identity Ports: IdentityProtocol & AuthContextProtocol
Q: How does Framework M decouple authentication using IdentityProtocol?
A: Framework M separates authentication into two distinct ports:
AuthContextProtocol(Inbound Port): Stateless ASGI middleware extraction: inspects inbound HTTP requests, bearer tokens, or gateway headers, and hydratesUserContextontorequest.statebefore route handlers execute.IdentityProtocol(Outbound Port) (framework_m_core.interfaces.identity): Governs user management, credential validation, and token issuance:authenticate(credentials: Credentials) -> Tokenget_user(user_id: str) -> UserContextvalidate_token(token: str) -> UserContextget_attributes(user_id: str) -> dict[str, Any]
Q: What adapters implement IdentityProtocol?
A: Identity management is swappable across application scales:
LocalIdentityAdapter(Indie Mode): Uses the local database (LocalUserDocType) with secure password hashing. Ideal for standalone monoliths and single-node instances.FederatedIdentityAdapter(Enterprise Gateway Mode): In production enterprise platforms (tenant-registry,business-m), services do not manage local passwords or stateful cookie sessions. Instead, upstream gateways (Castlecraft/CFE, Vouch Proxy, or enterprise IdPs like Keycloak, Okta, Auth0) authenticate users at the ingress. TheFederatedIdentityAdaptervalidates cryptographically signed bearer JWTs or proxy headers and hydratesUserContextwith verified roles, tenants, and attributes without database session roundtrips.
5. Persistence & Concurrency Ports: RepositoryProtocol, UnitOfWork & Outbox Relays
Q: Why shouldn't entity controllers perform multi-database cleanup in after_delete?
A: Entity controllers exist to enforce invariants on a single aggregate root. Putting external side-effects (notifying third parties, deleting dependent records in other databases, or purging caches) inside after_delete() creates severe transactional liabilities:
- Opening a second database session inside a hook causes deadlocks (e.g. SQLite
database is locked). - On PostgreSQL, if the external cleanup fails after the primary delete commits, your system is left in an inconsistent, non-atomic state.
Q: How do Driven Adapters handle transactional cleanup and cross-service side effects?
A: Use the Transactional Outbox Relay Pattern driven by the EventBusProtocol:
- The primary transaction writes the entity mutation AND records an outbox event in the same database commit via
UnitOfWork. - A background outbox relay worker reads the event and executes external cleanup with exponential backoff and retry guarantees over NATS JetStream or Kafka.
6. Infrastructure Ports & Adapters: Storage, Cache, Bus & Jobs
Q: How does Framework M abstract storage, caching, and background jobs?
A: In Framework M, all infrastructure is defined as typed Ports (framework_m_core.interfaces) with pluggable Driven Adapters:
| Port (Interface) | Purpose | Standard Adapters |
|---|---|---|
RepositoryProtocol | Data persistence & query compilation | GenericRepository (SQLAlchemy 2.0 Async, PostgreSQL, SQLite, MongoDB) |
StorageProtocol | File and document storage | LocalStorageAdapter, S3StorageAdapter (AWS S3, MinIO, Cloudflare R2) |
CacheProtocol | Key-value caching with TTL and patterns | MemoryCacheAdapter, RedisCacheAdapter (Redis, Dragonfly) |
EventBusProtocol | Pub/Sub CloudEvents messaging | InMemoryEventBusAdapter, NatsEventBusAdapter (NATS JetStream) |
JobQueueProtocol | Asynchronous background task execution | TaskiqJobQueueAdapter, NatsJobQueueAdapter |
FeatureEntitlementProtocol | SaaS feature gating & tier quotas | SubscriptionEntitlementAdapter, QuotaPolicyHandler |
IdempotencyProtocol | Distributed request deduplication | DatabaseIdempotencyAdapter, RedisIdempotencyAdapter |
Swapping an infrastructure dependency (e.g., migrating from local disk to S3, or from memory cache to Redis) requires zero changes to business logic—only configuring the adapter in DI.
Summary: Port & Adapter Mental Model vs. Monolithic Anti-Patterns
| Monolithic / Naive Anti-Pattern | Hexagonal Port & Adapter Production Solution |
|---|---|
Writing ad-hoc SQL joins / WHERE EXISTS for authorization | Inject PermissionProtocol port via DI Container (Container().permission_adapter) |
Materializing leaf IDs in Python for WHERE id IN (...) | Let PermissionAdapter return attribute constraints or CQRS read projections |
Hardcoding role checks (if "Admin" in roles) in services | Use stateless PolicyEvaluateRequest on PermissionProtocol |
| Managing stateful cookie sessions & passwords in enterprise apps | Use IdentityProtocol with FederatedIdentityAdapter (CFE, Vouch, JWT) |
Packing cross-table transactions into Controller hooks | Coordinate transactions via UnitOfWork and EventBusProtocol Outbox Relays |
Relying on generic POST /api/v1/{DocType} for core domain logic | Write explicit Application Services with typed DTOs |
| Hardcoding database queries and table manipulations in controllers | Use injected RepositoryProtocol via Container DI |
| Hardcoding file system paths for attachments | Use injected StorageProtocol (S3StorageAdapter, LocalStorageAdapter) |