Browse pages
Model a Behavior Module
A behavior module gathers one business area's glossary terms, aggregates, rules, use cases, reactions, jobs, notifications, settings, events, and permissions into record files that the model builder, the tool that checks the project, reads. This tutorial works through one kind of record at a time, using the spending module of the Claimwick product as the worked example. Each step names the standard it satisfies, and the tutorial adds no rule of its own.
Prerequisites#
- Finish Start a Consumer Repository through its configuration steps.
- Read the product brief and the requirements this module fulfills.
- Read the decisions that constrain identity, persistence, security, or external behavior.
- Copy the
module.yaml,aggregate.yaml, anduse-case.yamltemplates from the template index, and open.litespec/glossary.yamlfor the module terms.
Lesson#
-
Name the module terms: write one term entry per word with its meaning, its layer, its aliases, and the words it rejects. Use the same term in documentation, domain types, use cases, API descriptions, and frontend features. This satisfies
litespec/backend/domain#use-one-ubiquitous-language,litespec/core/domain-model#define-every-behavior-term-in-the-glossary, andlitespec/core/domain-model#mark-each-term-with-its-layer.Term Meaning Layer Aliases Rejected words term/expenseOne work cost an employee submitted for approval behavior claim Voucher term/receiptProof of payment attached to an expense behavior proof of payment Docket term/policyThe company spending policy and its receipt threshold behavior None None term/approverThe manager who decides a submitted expense behavior None None Rejected words stop a later contributor from creating
VoucherbesideExpense. Aliases let industry readers find your word under the name they already know. They also tell an agent which plausible names are wrong for this domain and which are accepted synonyms. -
Write the Example section before the rules. Name one occasion the module exists for: who acts, what surrounds them, and what they use today instead. Record it in the module's explanation file under Example, and reuse its people and figures in every record beneath it. Keep the shared cast on the example cast page. This satisfies
litespec/core/behavior#illustrate-pages-with-examples-from-the-cast.Month end brings every team's expenses at once: employees paid work costs from their own pockets and wait weeks for a decision, chasing managers by email. Without an expense record, finance reconstructs who spent what from forwarded receipts and memory.
-
Write the module record file with its summary. Give the module a person-chosen identifier such as
module/spending. Name each module it depends on, or remove the dependency field when the module stands alone. Spending depends on no other module, so its dependency list stays empty. This satisfieslitespec/core/domain-model#state-every-module-dependency. -
Draw aggregate boundaries from invariants. An aggregate is a consistency boundary: state that must stay valid together in one command. An invariant is a condition that holds before and after every successful change. Declare the kind of every aggregate:
entityfor guarded state,sagafor a multi-step aggregate, andstorefor state no invariant guards. This satisfieslitespec/backend/domain#treat-aggregates-as-consistency-boundaries,litespec/backend/domain#keep-child-entities-inside-the-aggregate-boundary, andlitespec/core/domain-model#declare-the-kind-of-every-aggregate.Aggregate Kind Owns References by typed ID aggregate/spending.expenseentity Expense lifecycle, total, reimbursement facts ReceiptId,EmployeeIdaggregate/spending.receiptentity Receipt attachment and removal ExpenseIdaggregate/spending.policystore The current receipt threshold value None The policy store names its owner, the spending settings store, and carries no states, because no invariant guards the stored value: the setting record declares its default and bounds. This satisfies
litespec/core/domain-model#name-the-owner-of-every-store. An aggregate owns a child when the child has no independent boundary and changes only through the aggregate. Reference another aggregate by typed ID when it changes on its own. Do not placeEmployeeinsideExpensefor convenient navigation. This satisfieslitespec/backend/domain#reference-other-aggregates-by-id. A saga kind would add steps with compensations and guards, named exits, anddrivenBy; spending needs none. -
Define every state. A state is a business-valid condition with its required data and permitted behavior. Describe states before writing transitions. This satisfies
litespec/backend/domain#model-every-aggregate-lifecycle-with-state-typesandlitespec/core/domain-model#model-each-aggregate-lifecycle-with-states.State Meaning Required facts SubmittedThe expense awaits a decision None ApprovedThe manager approved the expense Approver, decision date RefusedThe manager refused the expense Approver, decision date, reason ReimbursedFinance reimbursed the expense Reimbursement date Create an abstract state record and one sealed record per state. Put state-specific data on the state record. This satisfies
litespec/backend/dotnet/domain#model-every-aggregate-lifecycle-with-state-records.C# public abstract record ExpenseState; public sealed record ExpenseSubmittedState : ExpenseState; public sealed record ExpenseApprovedState(Guid ApprovedBy, DateTimeOffset DecidedAt) : ExpenseState; public sealed record ExpenseRefusedState(Guid RefusedBy, DateTimeOffset DecidedAt, string Reason) : ExpenseState; public sealed record ExpenseReimbursedState(DateTimeOffset ReimbursedAt) : ExpenseState;For each state, answer what makes it true, which data it alone requires, and which actions it allows and rejects. Leave no state half-defined.
-
Connect the states with transitions. List every allowed state change with the use case that moves it in the aggregate's
transitionsfield. This satisfieslitespec/core/domain-model#connect-every-state-through-transitions.From To By None Submitted use-case/spending.submit-expenseSubmitted Approved use-case/spending.approve-expenseSubmitted Refused use-case/spending.approve-expenseApproved Reimbursed use-case/spending.reimburse-expenseCreation is a transition from None, so the first state stays reachable. One use case can move more than one transition: approval ends submitted either approved or refused.
-
Declare the data classes with their erasure positions. A data class groups fields that share one classification and one fate on erasure. Then list every field the aggregate keeps with its meaning, data class, and classification in the aggregate's
datafield. This satisfieslitespec/core/privacy#declare-every-data-class-with-its-erasure-positionandlitespec/core/privacy#classify-every-aggregate-data-field.Data class Classification Fields On erasure Enforced by expense-moneyinternal amount, state retain for 7 years by statute record-expiry submitterpersonal employee erase on approved request job/spending.erase-expired-personal-dataName Meaning Data Class Classification totalThe amount claimed, with its currency expense-moneyinternalreceiptThe payment proof attached to the expense expense-moneyinternalapproverThe manager who decided, once decided expense-moneyinternalemployeeThe employee who submitted the expense submitterpersonalEvery field names its data class, so the retention schedule can erase by class instead of by table. A personal or sensitive class that is retained or exempt needs an Intent decision or requirement accepting it.
-
Write the rules with kinds. Give each rule a person-chosen identifier of one to six lowercase words. A statement with
not,no,never, orcannotkeeps that word in its name. This satisfieslitespec/core/domain-model#state-each-rule-with-its-kindandlitespec/core/domain-model#keep-the-negation-word-in-the-rule-name.Rule Kind Statement Refusal rule/expense.receipt-required-above-thresholdpreconditionAn expense above the policy threshold needs a receipt. result/submit-expense.receipt-missingrule/expense.approver-is-not-submitterinvariantAn approver is not the submitter. result/approve-expense.own-expenserule/expense.reimbursed-onceinvariantAn expense is reimbursed at most once. result/reimburse-expense.already-reimbursedrule/expense.approved-before-reimbursementpreconditionAn expense is reimbursed only after approval. result/reimburse-expense.not-approvedrule/receipt.attaches-before-decisionpreconditionA receipt attaches before the expense is decided. result/attach-receipt.expense-decidedAn
invariantholds after every change to its aggregate. Apreconditionguards the behavior before it runs and refuses caller-correctable input. Acalculationderives a value and is proven by a scenario withexamples. Across-aggregaterule spans aggregates and lives on a rule record file. The remaining kinds cover atomicity, held reservations, frozen commercial values, and fallback defaults. Each rule that refuses names its refusal. This satisfieslitespec/core/domain-model#name-the-refusal-a-broken-rule-producesandlitespec/core/domain-model#keep-cross-aggregate-rules-on-a-rule-page. The cross-aggregate rulerule/spending.receipt-filed-once, a receipt file supports one expense across spending, lives on the spending rule record file. A tunable bound such as the threshold is a setting, not a rule. Input shape stays in the validator; current aggregate state stays in the aggregate. This satisfieslitespec/backend/application#separate-input-validation-from-invariants. -
Name the people and their permissions. Write one actor record file per party and one role record file per grantable set. Roles grant permissions; each use case later declares its access against one grant. This satisfies
litespec/core/access#grant-permissions-through-rolesandlitespec/backend/application#enforce-target-authorization-in-the-pipeline.Actor Role Permission actor/employeerole/employeeat self scopepermission/submit-expenseactor/managerrole/approverat team scopepermission/approve-expenseactor/financerole/financeat platform scopepermission/reimburse-expense -
Write the use cases. Give each use case a name that is unique across the project, so its results and scenarios resolve. Name the kind,
commandorquery, the goal, what happens, the journey steps it serves, the requirements it fulfills, the access it declares, the entry points, the aggregates it changes and reads, its consistency, its effects, and the events it raises. This satisfieslitespec/backend/application#use-this-use-case-layout,litespec/core/use-cases#give-each-use-case-a-command-or-query-kind,litespec/core/use-cases#connect-each-use-case-to-intent, andlitespec/core/access#declare-access-on-every-use-case.Use case Kind Serves Access grant Changes Consistency use-case/spending.submit-expensecommandjourney/submit-and-reimburse#s1permission/submit-expenseaggregate/spending.expenseatomic use-case/spending.approve-expensecommandjourney/submit-and-reimburse#s2permission/approve-expenseaggregate/spending.expenseatomic use-case/spending.reimburse-expensecommandjourney/submit-and-reimburse#s3permission/reimburse-expenseaggregate/spending.expenseatomic use-case/spending.list-expensesqueryjourney/submit-and-reimburse#s4self(permission/list-expenses)None None At least one of
servesandfulfillsis present on every use case. Access declares one grant and the tenant scope; a grant ofself(...)limits the permission to the caller's own records.changesandreadsname every aggregate the use case touches, andconsistencystates how the writes hold together. This satisfieslitespec/core/use-cases#grant-each-use-case-scoped-access,litespec/core/use-cases#name-every-aggregate-a-use-case-changes-or-reads, andlitespec/core/use-cases#state-the-consistency-of-every-use-case. Every edge the use case causes goes ineffectswith one verb and its timing:runs,sends,emits, orcalls. This satisfieslitespec/core/use-cases#declare-every-effect-with-one-verb-and-its-timing. Every input field names its origin and classification, and caller-supplied values on money or access paths carry bounding rules. This satisfieslitespec/core/use-cases#describe-every-use-case-input-with-origin-and-classification. Every use case names its idempotency and, for commands, its audit position. This satisfieslitespec/core/use-cases#name-the-idempotency-of-every-use-caseandlitespec/core/use-cases#state-the-audit-position-of-every-command.A query changes nothing and declares its
outputinincludegroups with freshness, plus its paging. This satisfieslitespec/core/use-cases#declare-every-query-outputandlitespec/core/use-cases#declare-paging-on-every-collection-query. -
Write one result per ending. A result is one way a use case ends:
success, or a namedrefusalwith its stablecode. List refusals in precedence order, first match wins. This satisfieslitespec/core/use-cases#end-every-use-case-in-named-results,litespec/core/use-cases#order-every-refusal-list-by-precedence, andlitespec/backend/application#model-expected-use-case-refusals-explicitly.Result Outcome Code Meaning result/submit-expense.submittedsuccessNone The expense is submitted. result/submit-expense.receipt-missingrefusalspending.receipt-missingThe expense is above the threshold and carries no receipt. Never write the generic forbidden or invalid-input refusals: the use case derives them from its access and input. This satisfies
litespec/core/use-cases#derive-generic-refusals-instead-of-writing-them. A refusal that stores the attempt carriesrecorded: true; a success with recorded outcomes inside it carriescodes. Each refusal explains what the caller sees. The code is stable across releases. The backend stack derives the HTTP status from the outcome. This satisfieslitespec/backend/api#use-consistent-status-codes. -
Write the scenarios. A scenario is one working example in
given,when,then: the situation, the action, and the expected ending. Each scenario names the result it ends in. A scenario with no proving test yet becomes a gap exception record with an expiry date. This satisfieslitespec/core/use-cases#write-every-scenario-in-given-when-thenandlitespec/core/use-cases#name-the-result-each-scenario-ends-in.Scenario Given When Then Result scenario/submit-expense.submitting-with-a-receipt-succeedsAn employee paid for a train ticket and kept the receipt. The employee submits the expense with the receipt. The expense is submitted. result/submit-expense.submittedscenario/approve-expense.approving-a-submitted-expense-succeedsAn employee submitted an expense with its receipt. The manager approves the expense. The expense is approved. result/approve-expense.approvedWhen the scenario proves a calculation, add
exampleswith the debated rows. This satisfieslitespec/core/domain-model#tabulate-every-calculation-with-examples. Name each test by the observable behavior it asserts. This satisfieslitespec/backend/testing#name-tests-by-observable-behavior. An acceptance test carries one proves mark naming its scenario. The model builder confirms the claim when the test calls the use case through the public boundary and asserts the result the scenario names. -
Record the events. A domain event is an immutable record of a completed fact. Name what completed and which fields it carries. This satisfies
litespec/core/domain-model#state-each-event-with-meaning-and-carriesandlitespec/backend/dotnet/domain#raise-immutable-domain-facts.Event Meaning Carries event/expense.submittedAn expense was submitted. expenseId,amount,currency,employeeIdevent/expense.reimbursedAn expense was reimbursed. expenseId,amount,currency,reimbursedAtA fact that leaves the product is an integration event with a direction, a CloudEvents type and source, and a payload. Reimbursing emits
integration-event/payout.requested, which translatesevent/expense.reimbursedfor the payout bank. The use case declares it ineffectswithemits. This satisfieslitespec/core/edges#describe-every-integration-event-for-its-directionandlitespec/core/edges#publish-every-outbound-integration-event-from-effects. -
Write the reactions, jobs, notifications, and settings as their own records. Work that answers an event is a reaction: it names the events in
triggeredByand runs its use cases througheffects, and it never writes an aggregate directly. Work that runs on a schedule or by hand is a job: it names itskindwith itsschedule,selection, andguard, and it never writes an aggregate directly either. This satisfieslitespec/core/automation#trigger-every-reaction-from-its-declared-events,litespec/core/automation#run-every-job-from-its-declared-kind,litespec/core/automation#declare-every-reaction-and-job-edge-in-effects, andlitespec/core/automation#write-no-aggregate-directly-from-a-reaction-or-job. File each reaction and job with the module whose records it changes, and run each reaction in a dedicated handler. This satisfieslitespec/core/automation#file-every-reaction-and-job-with-the-module-it-changesandlitespec/backend/dotnet/application#run-reactions-in-dedicated-handlers.Record Shape reaction/spending.notify-on-reimbursementOn event/expense.reimbursed, sendsnotification/spending.reimbursement-received.job/spending.send-approver-remindersScheduled nightly; runs use-case/spending.send-approver-reminderper waiting expense.notification/spending.approver-reminderEmail to the manager with the expense and its waiting time. setting/spending.receipt-thresholdMoney, default EUR 25, set by the operator, refuses at the bound. Declare every reaction with the events it answers and every job with its kind; a manual job also states where it runs and its guard. This satisfies
litespec/core/automation#trigger-every-reaction-from-its-declared-events,litespec/core/automation#run-every-job-from-its-declared-kind, andlitespec/core/automation#guard-every-manual-job. Describe every notification with its addresses, channel, and consent, and claim each promise it makes from a requirement. This satisfieslitespec/core/edges#describe-every-notification-with-audience-channel-and-consentandlitespec/core/edges#claim-every-notification-promise-from-a-requirement. Hold every commercial, legal, or varying setting default to a requirement, and keep model invariants in rules. This satisfieslitespec/core/use-cases#hold-every-setting-default-to-a-requirementandlitespec/core/domain-model#keep-model-invariants-in-rules. -
Name the entry points and the external systems. Give each entry point its kind: the expenses app is a browser, the payout bank callback is a webhook. This satisfies
litespec/core/access#name-every-entry-point-with-its-kind. Declare every operation on the payout bank with its timeout, retries, and response, and route erasure through an operation. This satisfieslitespec/core/access#declare-every-operation-on-every-external-systemandlitespec/core/access#declare-erasure-for-every-external-system. Schedule every data class for retention with its basis, period, enforcement, and Intent acceptance. This satisfieslitespec/core/privacy#schedule-every-data-class-for-retention. -
Keep one transaction per command. One handler stages the aggregates its use case owns and lets the pipeline commit once, with the consistency the record declares. This satisfies
litespec/backend/application#dispatch-only-declared-effect-edgesandlitespec/backend/persistence#commit-each-command-pipeline-once. -
Map the model to folders. Mirror the module, aggregate,
UseCases/, and use case path in every layer. This satisfieslitespec/backend/architecture#organize-code-by-module-aggregate-and-use-case.Text apps/api/src/Claimwick.Modules/ Spending/ Expenses/ Domain/ Expense.cs ExpenseState.cs Events/ ExpenseSubmittedEvent.cs Exceptions/ ExpenseReceiptMissingException.cs UseCases/ SubmitExpense/ SubmitExpenseCommand.cs SubmitExpenseCommandHandler.csCreate subfolders only when real types require them. The folders mirror the records so a reader finds the code; the builder follows the marks from code up to the records.
-
Run the model check. Run
litespec model checkfrom the consumer root. It resolves every citation, follows the marks from code and tests to their records and the screens calling each use case, prints a level beside each finding, and reports each unproved scenario. The command index states howlitespecruns in a consumer.
What You Built#
- One module with its terms, aggregates, data classes, rules, use cases, results, scenarios, events, reactions, jobs, notifications, settings, and permissions.
- Every identifier is person-chosen, every citation resolves, and every edge between records is declared.
- Every scenario is proved by a passing test or covered by a gap exception.