vernac / architecture, expressed.
Browse documentation

Domain Collection Contract

Status: normative contract for the 0.4.0 language revision. MUST denotes a requirement. Build and installed-plugin verification are release checks, not alternative semantics.

Purpose

A domain collection is a named, structurally immutable list or set of one Vernac domain type. It has no independent domain identity. It supplies useful collection operations, not a transaction boundary or aggregate ownership policy.

Collections are never generated implicitly. They may be requested for IDs, ordinary value objects, enum value objects, entities, and aggregates using the same syntax. A collection does not turn mutable element objects into immutable snapshots.

Related: Value objects, Enums, Names and Unicode.

Syntax, names, and packages

namespace example.tasks;

id TaskId list;
value Name(String value) set Names;
value Status = PENDING | COMPLETED list StatusHistory;
entity Task[TaskId](Name) list Tasks;
aggregate Work[TaskId](Name) set Works;

The list/set clause follows the element definition, including its optional validation and method blocks. Its own optional body declares COLLECTION methods:

value Name(String value) behavior {
    public boolean isBlank() { return self.value().isBlank(); }
} list Names behavior {
    public boolean hasRepeatedNames() { return !self.duplicates().isEmpty(); }
};

An element definition MAY have zero or more attached list/set declarations, separated by commas. Each declaration MAY omit its name independently:

value Wert(String) list, set Werte;
value Name(String) list History, list Archive, set SelectedNames;

The first declaration produces Werts and Werte. Multiple lists or multiple sets are also permitted. Collections remain separate nominal types even when they have the same element type and kind. Declaration order does not give one collection priority over another.

Each declaration MAY have its own behavior block. Java imports and private helpers are local to that block; they MUST NOT leak into another collection’s behavior. The optional element behavior precedes the collection clauses. A trailing comma without another list/set clause is invalid.

Every omitted name derives independently from the same element-name-plus-s rule. Two omitted names therefore collide: value Name(String) list, set; MUST fail. An explicit name can also collide with an inferred name or any namespace type. Vernac MUST report the conflict at the declarations, without numbering, silently selecting a collection or changing the existing collection’s name. Adding a distinctly named collection MUST preserve the existing collection’s generated API.

Ordinary fields, imports, DTO mappings and persistence select a collection by its own type identity. Repository queries MAY return any declared collection of their aggregate; ordering rules apply to that selected list/set kind. A collection of a different aggregate remains invalid, even if both aggregates use the same ID type.

The default collection name is exactly the element type name plus s, with no linguistic inflection. TaskId becomes TaskIds; Category becomes Categorys. Authors can explicitly choose Categories. No numbering or automatic collision repair is permitted. Collections are namespace-level types and participate in ordinary type collision checks, reserved built-in type names, imports, and Unicode rules.

Each collection MUST generate one public final class implementing Iterable (directly for sets, through DomainList for lists), annotated @NullMarked, in `.domain`. It has private construction and private final storage. No collection-specific or inherited package override is permitted. The old `collection` keyword is replaced by `list`/`set`.

Structural guarantees and element identity

All creation paths MUST take an independent structural snapshot of the input. Subsequent changes to an input list, set, or array MUST NOT change the collection. Exported views and iterators MUST NOT permit structural mutation.

Element references are retained. No automatic deep copying, replacement, merging, or choice of a newer Entity/Aggregate state is performed. Custom Java methods and callbacks must respect the element model; an immutable container cannot prevent a callback from mutating an Entity supplied to it.

Null input containers, null elements, null candidates/IDs, and null predicates are invalid. Generated factories and operations reject them with DomainValidationException. An empty collection is valid. Java-inherited operations such as Iterable.forEach retain their Java exception contracts.

All transformations return the same generated collection type. The source remains unchanged, including on failure. Implementations may return the original instance when the result is unchanged; a fresh Java identity is not guaranteed.

Additional business invariants belong in a containing VO or domain object, not in this structural collection definition. Such wrappers validate their own creation paths. Returning a derived collection does not mutate or revalidate its owner.

List and set semantics

Lists preserve encounter order and repetitions. Sets identify membership using equals/hashCode and contain at most one representative of each equality group.

For set creation, the first encountered equal instance wins. For plus/plusAll, existing instances win; among newly supplied equal elements, the first encountered wins. Duplicate insertion is a no-op in content, not an exception. The incoming instance MUST NOT replace the existing instance, even if their other state differs.

Sets iterate in first-insertion order for predictable processing, but their equality and hashCode MUST NOT depend on that order. Input with unspecified encounter order cannot guarantee which equal instance is encountered first across executions.

Entity and Aggregate equality follows their ID contract. Two equal elements may represent different states of the same business object. Lists can retain all those instances; sets deliberately keep one. There is no implicit version reconciliation.

Factories and Java interoperability

For a generated type C containing T:

API Meaning
C.empty() Empty collection
C.of(T... items) Snapshot of an array or individual arguments
C.of(Iterable<? extends T> items) Snapshot of a Java or generated collection
iterator() / enhanced for loop Read-only traversal
stream() Sequential stream of contained instances
asList() (lists only) Unmodifiable List view
asSet() (sets only) Unmodifiable Set view

The class MUST NOT implement List or Set and MUST NOT expose mutation methods that merely fail at runtime. No redundant elements ()/toList ()/toSet () aliases are generated. A separate collection map operation is not provided; use stream mapping and an explicit destination factory. Optional.map remains available on single-result APIs.

Queries

API Return Meaning
size() int Number of elements
isEmpty() boolean Whether size is zero
contains(T element) boolean Membership by equality; may stop at first match
count(T element) int Number of equal elements; 0 or 1 for sets
find(T element) Optional First contained equal instance, not the query object
get(int index) (list) T Zero-based access; invalid indices throw IndexOutOfBoundsException
first() / last() (list) Optional First/last element, or empty

contains(x) is equivalent to count(x) > 0; !contains(x) to count(x) == 0. No missing result is represented as null.

Transformations

API Meaning
plus(T element) Append to a list; insert if absent into a set
plusAll(Iterable<? extends T> elements) Include all supplied elements in encounter order
minus(T element) Remove the first equal occurrence, if present
minusAll(T element) Remove every equal occurrence
minusAll(Iterable<? extends T> elements) Remove all source elements equal to any candidate
matching(T element) Retain every equal source occurrence
matching(Iterable<? extends T> elements) Retain all source elements equal to any candidate
filter(Predicate<? super T> predicate) Retain elements satisfying the predicate
distinct() (list only) Keep the first instance of each equality group
duplicates() (list only) Keep ALL occurrences of groups appearing at least twice

Missing removal candidates do not cause errors. Candidate repetitions do not multiply results and do not represent counts to subtract. Selection preserves source order and source instances, not candidate instances:

[A, B, A, C].minus(A)          -> [B, A, C]
[A, B, A, C].minusAll(A)       -> [B, C]
[A, B, A, C].minusAll([A, A])  -> [B, C]
[A, B, A, C].matching([A, C])  -> [A, A, C]
[A, B, A, C].distinct()        -> [A, B, C]

Duplicates: every occurrence, including the first

duplicates () does not return only the extra occurrences and does not deduplicate its result. It selects equality groups whose frequency is at least two and returns ALL original members of those groups, preserving order and exact instances.

[A, B, A, C, B].duplicates()            -> [A, B, A, B]
[A, B, A, C, B].duplicates().distinct() -> [A, B]
[A, A, A].duplicates()                 -> [A, A, A]
[A, B, C].duplicates()                 -> []
[].duplicates()                        -> []

Use duplicates ().distinct () for one representative of each repeated group. The first original instance is retained by distinct ().

Example: Account (id=42, balance=100) and Account (id=42, balance=200) compare equal by identity. duplicates () retains BOTH instances for investigation. Calling distinct () afterward deliberately selects the first and discards the other state. Neither operation decides which balance is correct or more recent.

Identity helpers (Entity/Aggregate elements only)

The ID parameter uses the element’s resolved Vernac ID type:

API Return Meaning
by(Id id) Optional First element with the ID
contains(Id id) boolean Whether that ID is present
minusId(Id id) C Remove the first matching identity
minusAllId(Id id) C Remove all matching identities

contains (T) and contains (Id) are overloads. ID collections themselves need only find (id), contains (id), and the ordinary element operations; no redundant ID helpers. The collection is an in-memory subset, not a repository. A valid ID may be absent.

Equality, hashing, and rendering

Collections have no identity of their own. equals requires the same generated class and equal contents under its list/set semantics. Lists include order and frequency; sets compare membership irrespective of iteration order. hashCode is consistent with equals and computed from current elements, not cached. toString identifies the collection type and displays contents, without promising a serialization format.

The generated equals parameter MUST be @Nullable Object. Elements use their own contracts. Entity collection equality is ID-based membership equality, not equality of all Entity state. Domain IDs must remain stable while objects are stored in sets.

Collections in ordinary value objects

Collections of IDs, enums, and immutable VOs MAY be used as ordinary VO fields, including optional fields. Collections of Entities or Aggregates MUST be rejected as VO fields. Wrapping mutable objects in a collection does not make them values.

id TaskId list;
value Name(String);
value Selection(Name, TaskIds);
value OptionalSelection(TaskIds? taskIds);

Required collection fields follow VO null rejection. Optional collection fields use nullable inputs and Optional getters. Absence and an existing empty collection are distinct. Same-file, same-namespace, imported, and qualified collection types use the shared namespace resolver.

Custom methods, diagnostics, and tooling

Collection behavior blocks MAY contain public and private methods. Their signatures use resolved Vernac/approved scalar types and normal optional/nullness rules. They cannot define mutable storage, custom constructors, packages, or collection invariants. Implementation code lives in a separate top-level class and cannot access private collection storage. Public implementations receive self; private helpers use explicit parameters.

Actual generated/inherited method signature conflicts and invalid member/parameter names MUST produce source-located compiler and LSP diagnostics. Legal overloads remain permitted. Type names are resolved semantically, never guessed from strings. See Behavior for scoped Java imports, external delegation and null checking.

Compiler and LSP share collection naming and semantic rules. Tests MUST cover completion/navigation across namespaces, field eligibility, and generated symbols.

Required verification

Collection declarations participate in the multi-file project pipeline for all supported owner kinds. Persistence and adapter mappings have separate capability rules; see persistence and mappings.

Pagination type boundary

Generated lists additionally implement the runtime DomainList contract while retaining their concrete type and existing asList API. Sets do not. This permits [materialized pagination wrappers](/contracts/pagination.html) to require order at the Java type level; it does not add repository pagination syntax or change field eligibility.