Skip to main content

Domain-Driven Design in Framework M

Framework M is designed to support complex enterprise applications. To manage this complexity, it heavily incorporates patterns from Domain-Driven Design (DDD).

However, we strongly believe in the Zero-Cliff Principle: developers should not need a PhD in software architecture to build simple forms, but the framework must gracefully scale to support advanced DDD patterns when the business logic demands it.

This document explains how classic DDD concepts map to Framework M's features, and why we provide multiple ways to define relationships.


1. Data Shape vs. Behavior (The Zero-Cliff Principle)

You might notice two different ways to define parent-child relationships in the Studio UI:

  1. Adding a Table field in the DocType Designer.
  2. Visually linking DocTypes in the Aggregate Builder.

They do not do the same thing. This separation is a deliberate application of the Zero-Cliff principle.

Level 1: Data Shape (The DocType Designer)

When you add a Table field to a DocType, you are defining the data composition.

  • What it does: It adds items: list[SalesOrderItem] to the Pydantic model.
  • Purpose: It tells the database how to load the data. It answers: "What data does a SalesOrder contain?"
  • Developer Experience: A beginner can build a nested form intuitively without knowing what an "Aggregate" is.

Level 2: Behavior (The Aggregate Builder)

When you use the Aggregate Builder to link SalesOrder to SalesOrderItem, you are defining a transactional boundary.

  • What it does: It sets is_aggregate_root = True and aggregate_children = ["SalesOrderItem"] in the DocType's Meta class.
  • Purpose: It tells the repository and code generators to treat this cluster of documents as a single Unit of Work. It answers: "What are the consistency rules for a SalesOrder?"
  • Developer Experience: An intermediate developer can enforce atomic saves across multiple tables without refactoring the underlying data model.

By separating the shape of the data from its transactional behavior, developers can start simple and progressively enhance their domain models.


2. Mapping DDD Concepts to Framework M

Framework M natively supports the core tactical patterns of DDD using modern Python and Pydantic.

Entities and References (LinkField)

An Entity is an object with its own independent identity and lifecycle (e.g., Customer, Product).

If your DocType needs to reference another Entity, you use a LinkField. They do not nest inside each other; they simply hold a reference to the other's ID.

Value Objects (Nested Pydantic Models)

A Value Object has no identity of its own. It is defined entirely by its attributes (e.g., an Address, Money, or Coordinates).

In Framework M, you do not use LinkField or child tables for Value Objects. Instead, you define a standard Pydantic BaseModel (not inherited from BaseDocType) and embed it directly. The SchemaMapper automatically stores this as a highly performant JSON column in PostgreSQL/SQLite.

from pydantic import BaseModel
from framework_m_core.domain.base_doctype import BaseDocType

# Value Object (No ID)
class Address(BaseModel):
city: str
country: str

# Entity / Aggregate Root
class Company(BaseDocType):
name: str
headquarters: Address # Automatically stored as JSON

Aggregates and Children

An Aggregate is a cluster of domain objects that can be treated as a single unit. The Aggregate Root is the only entry point.

In Framework M, an Aggregate is defined by setting is_aggregate_root = True and defining aggregate_children as a list[str] of DocType names.

Why is it a flat 1-level list? You never nest Aggregate Roots inside other Aggregate Roots. A 1-level visual cluster (a Root claiming ownership of specific child tables) is the clearest way to represent a transactional boundary. The GenericRepository guarantees that the Root and all declared aggregate_children are saved or deleted atomically.

Domain Events

State changes in an aggregate should emit Domain Events.

  • Automatic Events: The GenericRepository automatically emits standard lifecycle events (doc.created, doc.updated) to the NATS EventBusProtocol.
  • Custom Events: For business events (e.g., OrderShipped), you emit them manually from Controller hooks (after_save, on_submit) using the injected Event Bus.

3. Level 3: Event Sourcing & The MX Pattern

By default, Framework M relies on State-Sourcing (saving the current state to Postgres tables) combined with the Outbox Pattern and an Audit Log. This provides 99% of the benefits of DDD (scalability, auditability, CQRS support) without the extreme complexity of full Event Sourcing.

However, for that remaining 1% of high-value domains (like a double-entry financial Ledger or high-velocity InventoryMovement), true Event Sourcing is sometimes necessary.

Framework M supports this natively via the MX Pattern, without requiring you to fork the framework or rewrite the world.

Polyglot Persistence (Batteries are replaceable per-device)

You do not have to force Event Sourcing on your entire system. Framework M allows you to define custom storage adapters per-DocType.

If a team needs Event Sourcing, they can create a separate MX package (e.g., framework-mx-eventsource) containing an EventSourcedRepository that implements the standard RepositoryProtocol.

You then route specific Aggregate Roots to this new repository using standard Python entry points:

# pyproject.toml in your custom app or MX package

[project.entry-points."framework_m.adapters.repository"]
# Leave the default alone. 99% of DocTypes (Users, Customers) use standard Postgres CRUD.
# default = "framework_m_standard.adapters.db.generic_repository:GenericRepository"

# Route specific high-value Aggregate Roots to the Event Sourced engine
SalesOrder = "framework_mx_eventsource.repository:EventSourcedRepository"
LedgerEntry = "framework_mx_eventsource.repository:EventSourcedRepository"

The Result

  1. No Core Changes: The framework-m-core and standard packages remain untouched.
  2. Identical Business Logic: Your Controllers still inject RepositoryProtocol[SalesOrder] and call await repo.save(order). They have no idea the underlying storage is an event stream rather than a relational table.
  3. True Flexibility: You get to swap out the engine for the components that need it, while keeping the standard, easy-to-use CRUD engine for everything else.