A .domain.specy file is Specy’s DSL for expressing a complete domain model -structure and behavior -in a single file per module. Entities with their fields, references, policies, invariants, operations, and transitions all live together, alongside the commands, events, value objects, enums, and services they interact with.

Module & Imports

Explanation: Every .domain.specy file begins with a module declaration that names the module -a physical separation of domain definitions into distinct files. An optional uses module declaration imports types from another module, enabling cross-module references without duplicating definitions.

Syntax:

module Name
uses module OtherModule

The module name is a PascalCase identifier. uses module makes all exported types from the target module available for references, service calls, and cross-module command triggers.

Example:

module Order
uses module Shipping

Here, the Order module can reference types defined in the Shipping module -for instance, triggering Shipping.PrepareShipment from an operation.

Key points:

  • A file has exactly one module declaration, appearing as the first line.
  • uses module declarations appear immediately after the module name.
  • Cross-module calls use dot-path syntax: Module.Command(args).

Entity

Explanation: An entity is a domain object with a unique identity that persists throughout its lifecycle. Entities are the primary organizing unit -they contain not just fields, but also references, policies, invariants, operations, and transitions. This makes each entity a self-contained aggregate root that declares both its structure and its behavior.

Syntax:

entity Name :: "justification" {
    identifier fieldName : type
    fields {
        fieldName : type constraints...
    }
    references {
        fieldName : TypeName cardinality
    }
    policies {
        name(params) :: "justification" { expression }
    }
    invariants {
        name :: "justification" { expression }
    }
    operations {
        "Label" on CommandType { clauses... }
        "Label" when EventType then CommandType { clauses... }
        name(params) :: "justification" { clauses... }
    }
    transitions {
        [*] --> state on "label"
        state --> state on "label"
    }
}

The :: "justification" after the entity name is optional and describes the entity’s purpose. The identifier declaration provides a dedicated slot for the entity’s unique key. Sub-blocks references, policies, invariants, operations, and transitions are all optional; identifier and fields are required.

Example:

entity Order :: "A customer order" {
    identifier id: UUID
    fields {
        status : OrderStatus default("draft")
        totalAmount : Money
        shippingAddress : Address
        placedAt : datetime optional pastOrPresent
        confirmedAt : datetime optional pastOrPresent
        createdAt : datetime immutable pastOrPresent
    }
    references {
        customer : Customer 1..1
        lines : OrderLine 1..N
    }

    policies {
        orderMustContainsLines(lines: list<OrderLine>) :: "Order must contain order lines" {
            isNotEmpty(lines)
        }
        orderMustBeDraft(order: Order) :: "Order must be in draft status" {
            order.status = draft
        }
    }

    invariants {
        orderContainsLines :: "An order must contain at least one line" {
            isNotEmpty(lines)
        }
        orderTotalPositive :: "Order total amount must not be negative" {
            totalAmount.amount >= 0
        }
    }

    operations { ... }
    transitions { ... }
}

Key points:

  • identifier id: UUID explicitly declares the identity field -every entity must have one.
  • fields { } wraps all data fields with their constraints.
  • references { } declares relationships with explicit cardinality (1..1, 1..N, 0..1, 0..N).
  • Policies, invariants, operations, and transitions are scoped to the entity -they operate on its data directly.
  • The justification operator :: on the entity declaration carries the why behind the entity’s existence.

Value Object

Explanation: A value object is immutable and has no identity -it is defined entirely by its attributes. Two value objects with the same field values are considered equal. Value objects support an invariants block, allowing self-consistency rules to be expressed directly on the value.

Syntax:

value Name {
    fields {
        fieldName : type constraints...
    }
    invariants {
        name :: "justification" { expression }
    }
}

Example:

value OrderLine {
    fields {
        productId : uuid
        quantity : int required min(1) max(1000)
        productPrice : Money
        total : Money
    }

    invariants {
        positiveQuantity :: "Order line quantity must be greater than zero" {
            quantity > 0
        }
        lineTotalConsistency :: "Line total must equal unit price multiplied by quantity" {
            total.amount = productPrice.amount * quantity
        }
    }
}

Key points:

  • Fields are wrapped in a fields { } block, just like entities.
  • The invariants { } block lets value objects enforce self-consistency rules (e.g., a line total matching unit price times quantity).
  • All fields are implicitly immutable; do not add an identifier (that would make it an entity).
  • Values can be embedded inside entities, other values, commands, and events.

Enum

Explanation: An enum defines a closed set of named values representing a fixed domain classification. Enums carry no fields -just camelCase identifiers. They are used for statuses, categories, payment methods, and any concept where the set of valid options is known and finite.

Syntax:

enum Name {
    value1
    value2
    value3
}

Example:

enum OrderStatus {
    draft
    confirmed
    shipped
    delivered
    cancelled
}

enum PaymentMethod {
    creditCard
    bankTransfer
    paypal
}

Key points:

  • Values use camelCase -creditCard, not CreditCard or CREDIT_CARD.
  • Enum values have no associated data; if a concept needs fields, use a value object instead.
  • All valid values must be listed exhaustively -the set is closed.

Command

Explanation: A command represents an intent to change state in the domain. It carries the data payload needed to execute a specific action. Commands are named in verb-noun form using the imperative mood (PlaceOrder, CancelOrder) because they express what the caller wants to happen. Command fields are wrapped in a fields { } block.

Syntax:

command Name {
    fields {
        fieldName : type constraints...
    }
}

Example:

command PlaceOrder {
    fields {
        customerId : uuid
        lines : list<OrderLine>
        shippingAddress : Address
    }
}

command CancelOrder {
    fields {
        orderId : uuid
        reason : string optional maxLength(500)
    }
}

Key points:

  • Each command maps to exactly one entity operation ("Label" on CommandType).
  • Commands are pure data -behavior lives in the entity operation that handles them.
  • The fields { } block wraps all field declarations, consistent with entities and events.

Event

Explanation: An event is a past-tense record of something that happened in the domain. Events are immutable facts -once emitted, they cannot be changed. They are named using past-tense convention (OrderPlaced, OrderCancelled) and carry enough data for downstream consumers to react without querying back to the source. Event fields are wrapped in a fields { } block.

Syntax:

event Name {
    fields {
        fieldName : type constraints...
    }
}

Example:

event OrderPlaced {
    fields {
        orderId : uuid
        customerId : uuid
        totalAmount : Money
        placedAt : datetime
    }
}

event OrderCancelled {
    fields {
        orderId : uuid
        lines : list<OrderLine>
        reason : string optional
        cancelledAt : datetime
    }
}

Key points:

  • Events complement commands: a CancelOrder command, when successful, produces an OrderCancelled event.
  • Zero or more operations may declare when this event as a reactive trigger.
  • The fields { } block wraps all field declarations.

Fields & Types

Explanation: Every entity, value object, command, and event is composed of field declarations inside a fields { } block. A field has a name, a type, and zero or more constraints. The type system covers the primitives most domain models need, collection wrappers, and references to other user-defined types.

Field syntax:

fields {
    fieldName : fieldType constraints...
}

Primitive types:

Type Description
string Text values
int Whole numbers
decimal Decimal numbers (monetary, precision)
boolean True / false
date Calendar date (no time component)
datetime Date with time
uuid Universally unique identifier

Collection types:

  • list<T> -ordered collection (e.g., list<OrderLine>)
  • set<T> -unordered, unique collection (e.g., set<string>)

Type references: Any PascalCase name that is not a primitive or collection is treated as a reference to another definition -an entity, value object, or enum (e.g., Money, Address, OrderStatus).

Constraints:

Category Constraints Description
Presence required, optional Whether the field must have a value. Fields are required by default in most contexts.
Uniqueness unique No two instances may share the same value.
Mutability immutable Once set, the value cannot be changed.
Numeric bounds min(n), max(n), range(low, high) Minimum, maximum, or range for numeric values.
String length minLength(n), maxLength(n) Minimum or maximum character count.
Pattern pattern("regex") Value must match the given regular expression.
Temporal past, future, pastOrPresent, futureOrPresent Date/datetime must satisfy temporal condition.
Default default(value) Fallback value when none is set. Accepts strings, numbers, true, or false.

Example:

entity Customer :: "A customer who places orders" {
    identifier id: UUID
    fields {
        name : string minLength(1) maxLength(100)
        email : EmailAddress unique
        status : CustomerStatus default("active")
        birthDate : date optional past
        shippingAddress : Address optional
        createdAt : datetime immutable pastOrPresent
    }
}

Service

Explanation: A service models a stateless operation -domain logic that does not belong to any single entity. Services encapsulate computations, integrations, and cross-entity orchestration. Services wrap their operations in an operations { } block, and operation signatures use typed parameters directly in parentheses with an optional return type.

Syntax:

service Name {
    operations {
        opName(params) : returnType :: "justification" {
            body
        }
    }
}

The return type after ) is optional. The justification :: "justification" describes the operation’s purpose. The body can contain foreach, resolves, entity calls, and returns.

Example:

service PricingCalculator {
    operations {
        computeTotal(lines: list<OrderLine>) :: "Compute total from order lines" {
            returns sum(lines.total)
        }
    }
}

service StockService {
    operations {
        restock(lines: list<OrderLine>): void :: "Restore stock for each cancelled order line" {
            foreach lines as line {
                resolves Product from line.productId
                Product.increase(line.quantity)
            }
        }
    }
}

Key points:

  • Parameters and return type are declared directly in the operation signature.
  • Service operations are called from entity operations via dot-path syntax: PricingCalculator.computeTotal(placeOrder.lines).
  • Do not create services for pure infrastructure (password hashing, logging, caching) -use // NOTE instead.

Policy

Explanation: A policy is a precondition that must hold true before an operation can proceed. Policies are named, reusable constraints called inline from operations via policy name(args). They exist at two scopes.

File-level -cross-entity concerns, uses the policy keyword:

policy name(params) :: "justification" {
    expression
}

Entity-scoped -inside an entity’s policies { } block, NO policy keyword:

policies {
    name(params) :: "justification" {
        expression
    }
}

The expression body is the precondition directly -no must { } or message wrappers. The justification :: "justification" describes the business reason.

Example -file-level:

policy customerMustBeActive(customer: Customer) :: "Customer should be active" {
    customer.status = active
}

policy deliveryOnTime(order: Order) :: "Orders past their estimated delivery date require attention" {
    if order.estimatedDelivery is defined {
        order.estimatedDelivery > today()
    }
}

policy bankTransferMinimum(order: Order, payment: Payment) :: "Bank transfers require a minimum of 50" {
    if payment.method = bankTransfer {
        order.totalAmount.amount >= 50
    }
}

Example -entity-scoped (inside Order):

policies {
    productMustBeAvailable(lines: list<OrderLine>) :: "Every order line must be available" {
        every Product in lines {
            Product.available = true
        }
    }

    maxOrderLines(lines: list<OrderLine>) :: "Orders with more than 20 lines require manual review" {
        count(lines) <= 20
    }
}

Key points:

  • Policies have explicit typed parameters -they receive the data they need.
  • Use if condition { expression } for conditional validation (logical implication: holds when condition is false, or when both condition and body are true).
  • Use every TypeName in collection { expression } for universal quantification over collections.
  • File-level policies are for cross-entity concerns shared across multiple entities. Entity-scoped policies are for rules specific to one entity.

Invariant

Explanation: An invariant is a property that must always be true after any successful mutation. While policies guard before execution, invariants verify after. Invariants can be placed on both entities and value objects, allowing values to enforce self-consistency rules. Like policies, invariants exist at two scopes.

File-level -uses the invariant keyword:

invariant name :: "justification" {
    expression
}

Entity/value-scoped -inside an invariants { } block, NO invariant keyword:

invariants {
    name :: "justification" {
        expression
    }
}

The expression body is the condition directly -no must { } or message wrappers. Invariants have no parameters; they reference fields of their enclosing entity or value directly.

Example -entity-scoped (inside Order):

invariants {
    orderContainsLines :: "An order must contain at least one line" {
        isNotEmpty(lines)
    }
    orderTotalPositive :: "Order total amount must not be negative" {
        totalAmount.amount >= 0
    }
}

Example -value-scoped (inside OrderLine):

invariants {
    positiveQuantity :: "Order line quantity must be greater than zero" {
        quantity > 0
    }
    lineTotalConsistency :: "Line total must equal unit price multiplied by quantity" {
        total.amount = productPrice.amount * quantity
    }
}

Key points:

  • Invariants apply to both entities and value objects.
  • No parameters -invariants reference fields of their enclosing type directly.
  • Scope is determined by placement -invariants inside an entity apply to that entity, invariants inside a value apply to that value.
  • Never place invariants on commands or events -only on entities and values.

Operations

Explanation: Operations are defined inside an entity’s operations { } block. They model how commands are handled, how events trigger reactions, and how internal behaviors are encapsulated. There are three forms.

Form 1 -Command-triggered:

"Business intent label" on CommandType {
    clauses...
}

Exactly one operation per command. The label is a human-readable description of the business intent.

Form 2 -Event-triggered:

"Business intent label" when EventType then InternalCommandType {
    clauses...
}

The then CommandType names the internal command that carries the event data. Zero or more operations may react to the same event.

Form 3 -Internal:

name(params) :: "justification" {
    clauses...
}

A named operation callable from other operations or services. Not directly triggered by a command or event.

Operation clauses:

Clause Syntax Purpose
resolves resolves TypeName from dotPath Loads an existing entity for use in the operation.
policy call policy name(args) Calls a named policy as a precondition check.
creates creates TypeName { field = expr ... } Creates a new entity with field assignments.
sets sets TypeName { field = expr ... } Mutates fields on a resolved or created entity.
emits emits EventType { field = expr ... } Emits an event with field assignments.
service call Service.operation(args) Calls a service operation. Optional :: "justification".
foreach foreach dotPath as id { clauses... } Iterates over a collection with per-item clauses.

Resolution patterns:

Direct -from a command or event field:

resolves Customer from placeOrder.customerId
resolves Order from cancelOrder.orderId

Indirect -from an already-resolved entity:

resolves Payment from Order

Cross-module calls with named arguments:

Shipping.PrepareShipment(orderId = Order.id) :: "Launch shipping process"

Example:

"Place a new order" on PlaceOrder {
    resolves Customer from placeOrder.customerId

    policy customerMustBeActive(Customer)
    policy orderMustContainsLines(placeOrder.lines)
    policy productMustBeAvailable(placeOrder.lines)
    policy maxOrderLines(placeOrder.lines)

    creates Order {
        status = draft
        customer = Customer
        shippingAddress = placeOrder.shippingAddress
        lines = placeOrder.lines
        placedAt = now()
        totalAmount = PricingCalculator.computeTotal(placeOrder.lines)
    }

    policy minimumOrderAmount(Order)
    policy maxOrderAmount(Order)

    emits OrderPlaced {
        orderId = Order.id
        totalAmount = Order.totalAmount
        placedAt = Order.placedAt
        customerId = Customer.id
    }
}

"Cancel an order on payment failure" when PaymentFailed then CancelAfterPaymentFailure {
    sets Order {
        status = cancelled
        cancelledAt = now()
    }

    StockService.restock(Order.lines)

    emits OrderCancelled {
        orderId = Order.id
        lines = Order.lines
        reason = "Order cancelled due to payment failure"
        cancelledAt = Order.cancelledAt
    }
}

Key points:

  • Every entity you sets or reference must be resolved or created first.
  • Policies can be called both before and after creates -pre-creation policies validate input; post-creation policies validate the resulting state.
  • Resolution is direct via dot-path: resolves Customer from placeOrder.customerId.
  • Preconditions are enforced via named policy calls, not inline guards.

Transitions

Explanation: A transitions block declares a prescriptive state machine for an entity’s status field. It makes the valid lifecycle of the entity explicit and enforceable. Transition labels must match operation labels defined in the same entity, ensuring that every state change is traceable to a specific business action.

Syntax:

transitions {
    [*] --> state on "operation label"
    state --> state on "label", "label"
}

[*] represents the initial state (entry point). Multiple operation labels on a single transition are comma-separated.

Example:

transitions {
    [*] --> draft on "Place a new order"
    draft --> confirmed on "Confirm an order after payment"
    draft --> cancelled on "Cancel an order", "Cancel an order on payment failure"
    confirmed --> shipped on "Ship a confirmed order"
    confirmed --> cancelled on "Cancel an order", "Cancel an order on payment failure"
    shipped --> delivered on "Deliver a shipped order"
}

Key points:

  • Labels must exactly match the "Label" strings in the entity’s operations block.
  • Every operation that mutates the status field should have a corresponding transition.
  • Multiple operations can trigger the same transition (e.g., both “Cancel an order” and “Cancel an order on payment failure” lead to cancelled).

Expressions

Explanation: Expressions appear inside policy bodies, invariant bodies, sets value assignments, and service operation bodies. The expression grammar supports boolean logic, comparisons, arithmetic, dot-path field references, and a set of built-in functions.

Boolean operators:

  • and, or, not -standard logical connectives.

Comparison operators:

  • =, !=, >, <, >=, <=

Arithmetic operators:

  • +, -, *, /

Field tests:

  • field is defined -checks that a value is present.
  • field is not defined -checks that a value is absent.

Set membership:

  • field in { value1, value2 } -value is in the set.
  • field not in { value1, value2 } -value is not in the set.

Dot-path traversal:

  • Order.totalAmount.amount -navigates from entity to value object to field.
  • Payment.order.customer.id -follows references across entities.

Collection functions:

Function Description
count(collection) Number of items
sum(collection.field) Sum of a numeric field across items
size(collection) Alias for count
isEmpty(collection) True if no items
isNotEmpty(collection) True if at least one item

Temporal functions:

Function Description
now() Current datetime
today() Current date (no time component)

Conditional expression:

if condition {
    expression
}

Logical implication: if A { B } holds when A is false (vacuous truth) or when both A and B are true. Used for field-dependent validation rules.

Quantifiers:

every TypeName in collection {
    expression
}

Universal quantification -the expression must hold for every item in the collection.

exists TypeName where {
    expression
}

Existential quantification -at least one instance of TypeName must satisfy the expression. Acts as a reverse-lookup query. Can be negated with not exists.

Example combining multiple features:

policy bankTransferMinimum(order: Order, payment: Payment) :: "Bank transfers require a minimum of 50" {
    if payment.method = bankTransfer {
        order.totalAmount.amount >= 50
    }
}

invariants {
    lineTotalConsistency :: "Line total must equal unit price multiplied by quantity" {
        total.amount = productPrice.amount * quantity
    }
}

Comments & Annotations

Explanation: Specy uses line comments for structured annotations that carry domain-relevant metadata. These are not arbitrary comments -each prefix has a specific semantic purpose.

Syntax:

// source: <reference>
// NOTE: <explanation>
// UNCLEAR: <question or ambiguity>

// source: -links a construct to its origin in source code, documentation, or conversation. Provides traceability.

// NOTE: -marks infrastructure concerns or implementation details that the DSL does not model (e.g., retry mechanisms, caching, logging). Used when a concept exists but is intentionally omitted from the domain model.

// UNCLEAR: -flags an ambiguity or open question in the domain. Used when a business rule exists but cannot be expressed faithfully in the current grammar. This signals that the rule needs further discussion with domain experts.

Example:

// source: OrderService.java:45
entity Order :: "A customer order" {
    // ...

    operations {
        "Handle payment failure" when PaymentFailed then HandlePaymentFailure {
            resolves Payment from handlePaymentFailure.paymentId

            sets Payment {
                status = failed
            }
            // NOTE: retry mechanism (infrastructure)

            // UNCLEAR: admin role authorization (cannot express role checks in grammar)
        }
    }
}

Key points:

  • // NOTE is appropriate for infrastructure that the domain model intentionally excludes.
  • // UNCLEAR should be used instead of writing a tautological or incorrect expression when the business rule cannot be faithfully captured.
  • These annotations are part of the modeling discipline, not optional decoration.