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
moduledeclaration, appearing as the first line. uses moduledeclarations 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: UUIDexplicitly 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, notCreditCardorCREDIT_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
CancelOrdercommand, when successful, produces anOrderCancelledevent. - Zero or more operations may declare
whenthis 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
// NOTEinstead.
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
setsor 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
policycalls, 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’soperationsblock. - 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:
// NOTEis appropriate for infrastructure that the domain model intentionally excludes.// UNCLEARshould 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.