DDD Primer

Morph’s schema language is built on domain-driven design concepts. This page covers the ones you need to know, with morph-specific examples.

Entities

An entity is an object with a persistent identity. Two entities with the same attributes are still different objects if they have different IDs. In morph, entities declare their attributes, relationships, and aggregate role:

@root
entity Todo "A task to be completed." {
    completed: boolean "Whether the task is finished"
    title: string "What needs to be done"
    userId: User.id "The user who owns this todo"
    belongs_to User "Created by user"
}

Every entity gets an implicit id field. Attribute types can be primitives (string, boolean, integer, float, date), arrays (string[]), optionals (string?), unions ("low" | "medium" | "high"), or references to other entities (User.id).

Value Objects

A value object has no identity — it’s defined entirely by its attributes. Two addresses with the same street, city, and zip are the same address. Value objects are immutable and are embedded within entities, not stored independently:

value Address "A structured mailing address." {
    city: string
    country: string
    street: string
    zip: string
}

entity Contact {
    name: string
    address: Address "Mailing address"
}

Unlike entities, value objects have no relationships and no aggregate role.

Aggregates

An aggregate is a cluster of entities treated as a unit for consistency. One entity is the aggregate root — the entry point for all access. In morph, mark roots with @root:

@root
entity User { ... }

@root
entity Todo {
    userId: User.id
    belongs_to User
}

Operations declare which aggregates they touch via reads and writes clauses. Cross-aggregate operations (touching multiple roots) are domain services:

command transferTodos "Transfer todos between users."
    reads User, writes Todo
    input {
        fromUserId: User.id
        toUserId: User.id
    }

Commands and Queries

Commands change state and must emit at least one domain event:

command completeTodo "Mark a todo as completed."
    writes Todo
    pre UserOwnsTodo
    input { todoId: Todo.id }
    output Todo
    emits TodoCompleted "Emitted when a todo is marked as complete"

Queries are read-only — no state changes, no events:

query getTodo "Get a single todo by ID."
    reads Todo
    pre UserOwnsTodo
    input { todoId: Todo.id }
    output Todo

This is CQRS (command-query responsibility segregation). See CQRS for the full picture.

Functions

Functions are pure transformations with no aggregate access and no side effects. They’re used in transformation-centric domains (code generators, compilers, parsers):

function generate "Generate code from a schema."
    input { schema: string }
    output GenerationResult

Functions can have type parameters, unlike commands and queries. See Transformation Domains.

Domain Events

A domain event records that something happened. Events are facts — immutable records of state changes. In morph, commands declare the events they emit:

command createTodo "Create a new todo."
    writes Todo
    input { title: string, userId: User.id }
    output Todo
    emits TodoCreated "Emitted when a new todo is created"

Events can be consumed by subscribers for side effects like auditing or notifications:

subscriber logTodoEvents "Log todo events for auditing."
    on TodoCreated, TodoCompleted, TodoDeleted

See Domain Events for how events differ from operation replay.

Invariants

An invariant is a rule that must always hold. Morph invariants are boolean conditions checked before or after operations. They have several scopes:

Entity-scoped — rules about a specific entity:

invariant UserOwnsTodo on Todo
    "User can only modify their own todos."
    violation "You can only modify your own todos"
    where todo.userId == context.currentUser.id

Context-scoped — rules about the request context (authentication, authorization):

@context
invariant UserIdMatchesCurrentUser
    "Operation userId must match the authenticated user."
    violation "You must be authenticated as the specified user"
    where input.userId == context.currentUser.id

Operations reference invariants as pre-conditions (pre) or post-conditions (post):

command deleteTodo
    writes Todo
    pre UserOwnsTodo
    input { todoId: Todo.id }

When an invariant references context.currentUser, morph automatically infers that the operation requires authentication. See Authorization.

Bounded Contexts

A bounded context is a linguistic boundary — the same word can mean different things in different contexts. In morph, context blocks group related entities, operations, and invariants:

context tasks "Task management." {
    entity Todo { ... }
    entity User { ... }
    command createTodo { ... }
    query listTodos { ... }
    invariant UserOwnsTodo { ... }
}

context orders "Order management." {
    depends on catalog
    entity Order { ... }
    command placeOrder { ... }
}

Each context gets its own generated packages (DSL, core, implementations). Cross-context dependencies are explicit via depends on.

Ports and Contracts

Ports define dependency injection contracts for pluggable backends (storage, auth, cache). This is hexagonal architecture — the domain doesn’t know about infrastructure:

port Cache<T> "Generic key-value cache." {
    get(key: string): T throws CacheMissError
    put(key: string, value: T): void
    remove(key: string): void
}

Contracts are property-based specifications that any port implementation must satisfy:

contract PutGetRoundTrip on Cache
    "Putting a value and getting it returns the same value."
    given key: string, value: T
    when put(key, value), get(key)
    then result == value

How These Fit Together

A morph schema is a complete domain model:

context → entities + value objects + operations + invariants + ports
                          ↓
              commands (write, emit events)
              queries  (read only)
              functions (pure transforms)
                          ↓
              invariants enforce consistency
              events record what happened
              ports abstract infrastructure

The schema compiles to a JSON domain model, and morph’s generators produce running code for each target (@api, @cli, @mcp, @ui) from that model.