Skip to main content

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, and frappe.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:

Hexagonal Architecture (Ports & Adapters)

LayerResponsibilityComponents & Interfaces
Driving (Inbound) AdaptersTranslates external client requests and system triggers into domain callsLitestar ASGI HTTP Routes, RPC Handlers, CLI Commands (m dev, m audit), WebSockets, Background Event Listeners
Ports (Interfaces)Pure Python Protocol contracts defining infrastructure requirementsAuthContextProtocol, PermissionProtocol (PDP), RepositoryProtocol, IdentityProtocol, StorageProtocol, CacheProtocol, EventBusProtocol
Core Domain (Inside)Business rules, validation, and aggregate invariant enforcementBaseDocType Entities, Value Objects, Domain Events, Application Services (OrderService, CaseService)
Driven (Outbound) AdaptersConcrete implementations of infrastructure portsSQLAlchemy 2.0 (PostgreSQL/SQLite), Redis/Dragonfly, NATS JetStream, AWS S3/MinIO, SpiceDB (ReBAC), OPA (ABAC)
  1. 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).
  2. Ports (framework_m_core.interfaces): Clean, typed Python Protocol classes declaring exactly what the domain requires (e.g. RepositoryProtocol, PermissionProtocol, IdentityProtocol, EventBusProtocol).
  3. 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.
  4. 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.
  5. 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:

  1. 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.
  2. 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.
  3. 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):

  1. 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.
  2. get_permitted_filters(principal, principal_attributes, resource, tenant_id) -> dict[str, Any]: Translates authorization policy into repository query filters for Row-Level Security (RLS) via apply_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"]):

AdapterModelImplementation LocationPurpose
RbacPermissionAdapterRole-Based (RBAC)framework_m_standard.adapters.authEvaluates role permissions defined in DocType.Meta.permissions and CustomPermission records.
SpiceDbPermissionAdapterRelationship-Based (ReBAC)Enterprise / Zanzibar engineEvaluates complex relationship graphs (e.g., User -> Member of Team -> Editor of Project -> Viewer of Document).
OpaPermissionAdapterAttribute-Based (ABAC)Open Policy Agent (OPA)Evaluates external Rego policies over HTTP/gRPC without altering Python code.
FederatedPermissionAdapterFederated / MeshMulti-service deploymentsResolves entitlements across distributed microservices.
ComboPermissionAdapterHybridMulti-strategyChains 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:

  1. 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 in O(log M) time.
  2. 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.
  3. 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:

  1. AuthContextProtocol (Inbound Port): Stateless ASGI middleware extraction: inspects inbound HTTP requests, bearer tokens, or gateway headers, and hydrates UserContext onto request.state before route handlers execute.
  2. IdentityProtocol (Outbound Port) (framework_m_core.interfaces.identity): Governs user management, credential validation, and token issuance:
    • authenticate(credentials: Credentials) -> Token
    • get_user(user_id: str) -> UserContext
    • validate_token(token: str) -> UserContext
    • get_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 (LocalUser DocType) 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. The FederatedIdentityAdapter validates cryptographically signed bearer JWTs or proxy headers and hydrates UserContext with 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:

  1. The primary transaction writes the entity mutation AND records an outbox event in the same database commit via UnitOfWork.
  2. 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)PurposeStandard Adapters
RepositoryProtocolData persistence & query compilationGenericRepository (SQLAlchemy 2.0 Async, PostgreSQL, SQLite, MongoDB)
StorageProtocolFile and document storageLocalStorageAdapter, S3StorageAdapter (AWS S3, MinIO, Cloudflare R2)
CacheProtocolKey-value caching with TTL and patternsMemoryCacheAdapter, RedisCacheAdapter (Redis, Dragonfly)
EventBusProtocolPub/Sub CloudEvents messagingInMemoryEventBusAdapter, NatsEventBusAdapter (NATS JetStream)
JobQueueProtocolAsynchronous background task executionTaskiqJobQueueAdapter, NatsJobQueueAdapter
FeatureEntitlementProtocolSaaS feature gating & tier quotasSubscriptionEntitlementAdapter, QuotaPolicyHandler
IdempotencyProtocolDistributed request deduplicationDatabaseIdempotencyAdapter, 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-PatternHexagonal Port & Adapter Production Solution
Writing ad-hoc SQL joins / WHERE EXISTS for authorizationInject 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 servicesUse stateless PolicyEvaluateRequest on PermissionProtocol
Managing stateful cookie sessions & passwords in enterprise appsUse IdentityProtocol with FederatedIdentityAdapter (CFE, Vouch, JWT)
Packing cross-table transactions into Controller hooksCoordinate transactions via UnitOfWork and EventBusProtocol Outbox Relays
Relying on generic POST /api/v1/{DocType} for core domain logicWrite explicit Application Services with typed DTOs
Hardcoding database queries and table manipulations in controllersUse injected RepositoryProtocol via Container DI
Hardcoding file system paths for attachmentsUse injected StorageProtocol (S3StorageAdapter, LocalStorageAdapter)