hecks

ValueObject

Words available inside identified_by do ... end / value_object do ... end.

The tables on this page are generated from the language’s own aggregate-local syntax tables (lib/hecks/language/**/*.bluebook) by hecks language_run.project_reference — do not edit inside the markers. The prose between them is hand-written and survives regeneration.

Every example runs against examples/banking’s Account, whose value objects carry each of these three words for real:

```ruby boot Hecks::Adapters::Folder.new.load_bluebooks(File.join(InMemoryDomain::ROOT, “examples/banking/bluebook”))

Hecks.hecksagon(“Banking”) do attaches “Governance” Banking::Customer.persisted_by(“Memory”) Banking::Account.persisted_by(“Memory”) end Hecks.hecksagon(“Governance”) do Governance::RoleAssignment.persisted_by(“Memory”) Governance::RoleTransition.persisted_by(“Memory”) end


```ruby
runtime.dispatch("Banking::Customer.Register", with: { reference: { value: "vo-1" },
                                                       name: { given: "Melba", family: "Roy" },
                                                       email: { address: "melba@example.com" } })
account = Banking::Account.open!(customer: "vo-1", number: { value: "vo-a1" },
                                kind: { name: "current" }, daily_limit: { cents: 50_000 })

The single-attribute rule: .value

A value object with exactly ONE declared attribute is a NAME for a scalar, not a genuine group — and the language treats that as a rule, not a convention: every single-attribute value object answers .value, aliasing whatever its real sole field is actually called. A shorthand-declared StickerRef{value} and an author-named DailyLimit{cents} answer it identically:

account.number.value       # => "vo-a1"
account.daily_limit.value  # => 50000
account.daily_limit.cents  # => 50000

The alias covers indexed access ([:value], key?(:value)) and with(:value, ...) the same way — a write through the alias lands in the REAL field, never mints a literal :value key. Serialization is NOT aliased: to_h/to_json keep the real field name, so nothing stored or exported changes shape.

A MULTI-attribute value object refuses .value exactly as it always has — with two or more fields there is no single value it could honestly mean:

account.balance.respond_to?(:value)  # => false

The same rule collapses call sites: wherever a command or query argument (or an aggregate attribute) is typed as a single-attribute value object, a bare scalar wraps into that sole field automatically — daily_limit: 50_000 and daily_limit: { cents: 50_000 } build the identical DailyLimit. The explicit field-named spelling always keeps working; a multi-field value object still requires its fields spelled out. The Rust runtime reads the same rule (Fielded::as_scalar — a generated struct with one attribute reads as that attribute’s value, whatever its name), so a predicate comparing a single-attribute value against a bare literal agrees across engines.

The bare declaration shorthand for this shape — value_object "DailyLimit", Integer, no block — is documented with the word itself (aggregate.md’s own value_object section).

attribute

attribute name, type, default:, optional:, pattern:, matches:, admits:, one_of: — fills attributes

| argument | kind | required | fills | |—|—|—|—| | positional 1 | symbol | true | name | | positional 2 | constant | true | type | | default: | literal | false | default | | optional: | flag | false | optional | | pattern: | text | false | pattern | | matches: | symbol | false | matches | | admits: | text | false | admits | | one_of: | list | false | one_of |

Declares a field on the value object: a name, a type (scalar or another value object), and the usual default:/optional:/pattern:/admits: modifiers — same word, same rules, as inside an aggregate. See aggregates-and-value-objects.md for what each modifier promises.

Money is two fields, one of them defaulted:

```ruby skip

examples/banking/bluebook/

value_object “Money” do attribute :cents, Integer, default: 0 attribute :currency, String, default: “USD”

invariant(“a currency is a three-letter code”) { currency.to_s.size == 3 } end


A caller naming only `cents:` still gets a whole `Money` — `default:`
fills the rest at the entry point, not at read time:

```ruby
account.credit!(amount: { cents: 2_500 }, narrative: { text: "opening deposit" })
account.balance.cents     # => 2500
account.balance.currency  # => "USD"

Nothing at the call site names a direction — Credit and Debit each append their own, and the closed set (LedgerDirection’s own one_of:, above) is what makes the two readable apart:

account.debit!(amount: { cents: 500 }, narrative: { text: "lunch" })
account.ledger.map { |entry| entry[:direction][:value] }  # => ["credit", "debit"]

one_of

one_of do ... end — fills rows

The block form is gone — a single-field closed set is attribute’s own one_of: keyword now, and a multi-field one is bare member lines, both shown above. This row stays admitted only because frozen era text still writes it (EraGuard.shadow_parse’s own S0a bridge reads it); writing it in live source refuses:

Hecks.bluebook("LedgerDirectionAgain") { aggregate("Thing") { identified_by :thing_id; value_object("Kind") { attribute :value, String; one_of { member value: "a" } } } }  # ~> Malformed: one_of do ... end wrapper is gone

invariant

invariant description do ... end — fills invariants

| argument | kind | required | fills | |—|—|—|—| | positional 1 | text | true | description |

A rule that travels with the value, not with any one command — it fires in every command that carries this value object, not just the one where it was declared. See command.md for where an invariant sits relative to a command’s other checks.

PositiveMoney carries two, and neither belongs to any one command:

account.credit!(amount: { cents: -1 }, narrative: { text: "negative" })  # ~> InvariantViolation: an amount is positive

NO BLOCK is a REFERENCE, not a fresh declaration — the same move given (command.md) already makes for a shared precondition, one level over: a rule shared across SIBLING value objects on the same aggregate, declared once, on the first one that needs it. Money and PositiveMoney (both on Account) used to both retype invariant("a currency is a three-letter code") { currency.to_s.size == 3 }, byte for byte — Money still declares it with a block, PositiveMoney now just names it back:

account_ir = Banking::Account.ir
money          = account_ir.value_objects.find { |vo| vo.hecks_name == "Money" }
positive_money = account_ir.value_objects.find { |vo| vo.hecks_name == "PositiveMoney" }
money.invariants.map(&:description)          # => ["a currency is a three-letter code"]
positive_money.invariants.map(&:canonical)    # => ["cents.positive?", "currency.to_s.size == 3"]

The currency rule fires from the same value, in the same command, without either being mentioned where the money is spent:

account.credit!(amount: { cents: 100, currency: "DOLLARS" }, narrative: { text: "wrong currency" })  # ~> InvariantViolation: a currency is a three-letter code

“Travels with the value” is literal — Debit never declares these rules, and gets both anyway because it takes a PositiveMoney too:

account.debit!(amount: { cents: -1 }, narrative: { text: "negative" })  # ~> InvariantViolation: an amount is positive

member

member members — opens a Member body

| argument | kind | required | fills | |—|—|—|—| | positional 1 | pairs | true | members |

One legal row of a closed set, written directly in the value object’s own body — no wrapper around it. A single-field set has the shorter one_of: keyword (see attribute, above); member is for when each admitted value carries MORE than one field, so there is no single field one_of: could name.

StatementFrequency is three fields per member — a cadence names both how long records are kept and what a paper copy costs:

```ruby skip

examples/banking/bluebook/

value_object “StatementFrequency” do attribute :cadence, String attribute :retention_months, Integer attribute :paper_fee_cents, Integer

member cadence: “monthly”, retention_months: 84, paper_fee_cents: 0 member cadence: “quarterly”, retention_months: 120, paper_fee_cents: 0 member cadence: “annual”, retention_months: 240, paper_fee_cents: 500 end


Each `member` line stays exactly that — three named fields together,
never split across rows the way a single-field `one_of:` array is:

```ruby
frequency = runtime.registry.bluebook("Banking").aggregate("Statement").value_object("StatementFrequency")
frequency.closed_set?  # => true
frequency.members      # => [{ cadence: "monthly", retention_months: 84, paper_fee_cents: 0 }, { cadence: "quarterly", retention_months: 120, paper_fee_cents: 0 }, { cadence: "annual", retention_months: 240, paper_fee_cents: 500 }]