Type to search pages and rules.

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.

Markdown

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, and use-case.yaml templates from the template index, and open .litespec/glossary.yaml for the module terms.

Lesson#

  1. 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, and litespec/core/domain-model#mark-each-term-with-its-layer.

    TermMeaningLayerAliasesRejected words
    term/expenseOne work cost an employee submitted for approvalbehaviorclaimVoucher
    term/receiptProof of payment attached to an expensebehaviorproof of paymentDocket
    term/policyThe company spending policy and its receipt thresholdbehaviorNoneNone
    term/approverThe manager who decides a submitted expensebehaviorNoneNone

    Rejected words stop a later contributor from creating Voucher beside Expense. 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.

  2. 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.

  3. 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 satisfies litespec/core/domain-model#state-every-module-dependency.

  4. 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: entity for guarded state, saga for a multi-step aggregate, and store for state no invariant guards. This satisfies litespec/backend/domain#treat-aggregates-as-consistency-boundaries, litespec/backend/domain#keep-child-entities-inside-the-aggregate-boundary, and litespec/core/domain-model#declare-the-kind-of-every-aggregate.

    AggregateKindOwnsReferences by typed ID
    aggregate/spending.expenseentityExpense lifecycle, total, reimbursement factsReceiptId, EmployeeId
    aggregate/spending.receiptentityReceipt attachment and removalExpenseId
    aggregate/spending.policystoreThe current receipt threshold valueNone

    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 place Employee inside Expense for convenient navigation. This satisfies litespec/backend/domain#reference-other-aggregates-by-id. A saga kind would add steps with compensations and guards, named exits, and drivenBy; spending needs none.

  5. 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-types and litespec/core/domain-model#model-each-aggregate-lifecycle-with-states.

    StateMeaningRequired facts
    SubmittedThe expense awaits a decisionNone
    ApprovedThe manager approved the expenseApprover, decision date
    RefusedThe manager refused the expenseApprover, decision date, reason
    ReimbursedFinance reimbursed the expenseReimbursement 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.

  6. Connect the states with transitions. List every allowed state change with the use case that moves it in the aggregate's transitions field. This satisfies litespec/core/domain-model#connect-every-state-through-transitions.

    FromToBy
    NoneSubmitteduse-case/spending.submit-expense
    SubmittedApproveduse-case/spending.approve-expense
    SubmittedRefuseduse-case/spending.approve-expense
    ApprovedReimburseduse-case/spending.reimburse-expense

    Creation 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.

  7. 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 data field. This satisfies litespec/core/privacy#declare-every-data-class-with-its-erasure-position and litespec/core/privacy#classify-every-aggregate-data-field.

    Data classClassificationFieldsOn erasureEnforced by
    expense-moneyinternalamount, stateretain for 7 years by statuterecord-expiry
    submitterpersonalemployeeerase on approved requestjob/spending.erase-expired-personal-data
    NameMeaningData ClassClassification
    totalThe amount claimed, with its currencyexpense-moneyinternal
    receiptThe payment proof attached to the expenseexpense-moneyinternal
    approverThe manager who decided, once decidedexpense-moneyinternal
    employeeThe employee who submitted the expensesubmitterpersonal

    Every 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.

  8. Write the rules with kinds. Give each rule a person-chosen identifier of one to six lowercase words. A statement with not, no, never, or cannot keeps that word in its name. This satisfies litespec/core/domain-model#state-each-rule-with-its-kind and litespec/core/domain-model#keep-the-negation-word-in-the-rule-name.

    RuleKindStatementRefusal
    rule/expense.receipt-required-above-thresholdpreconditionAn expense above the policy threshold needs a receipt.result/submit-expense.receipt-missing
    rule/expense.approver-is-not-submitterinvariantAn approver is not the submitter.result/approve-expense.own-expense
    rule/expense.reimbursed-onceinvariantAn expense is reimbursed at most once.result/reimburse-expense.already-reimbursed
    rule/expense.approved-before-reimbursementpreconditionAn expense is reimbursed only after approval.result/reimburse-expense.not-approved
    rule/receipt.attaches-before-decisionpreconditionA receipt attaches before the expense is decided.result/attach-receipt.expense-decided

    An invariant holds after every change to its aggregate. A precondition guards the behavior before it runs and refuses caller-correctable input. A calculation derives a value and is proven by a scenario with examples. A cross-aggregate rule 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 satisfies litespec/core/domain-model#name-the-refusal-a-broken-rule-produces and litespec/core/domain-model#keep-cross-aggregate-rules-on-a-rule-page. The cross-aggregate rule rule/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 satisfies litespec/backend/application#separate-input-validation-from-invariants.

  9. 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-roles and litespec/backend/application#enforce-target-authorization-in-the-pipeline.

    ActorRolePermission
    actor/employeerole/employee at self scopepermission/submit-expense
    actor/managerrole/approver at team scopepermission/approve-expense
    actor/financerole/finance at platform scopepermission/reimburse-expense
  10. 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, command or query, 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 satisfies litespec/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, and litespec/core/access#declare-access-on-every-use-case.

    Use caseKindServesAccess grantChangesConsistency
    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)NoneNone

    At least one of serves and fulfills is present on every use case. Access declares one grant and the tenant scope; a grant of self(...) limits the permission to the caller's own records. changes and reads name every aggregate the use case touches, and consistency states how the writes hold together. This satisfies litespec/core/use-cases#grant-each-use-case-scoped-access, litespec/core/use-cases#name-every-aggregate-a-use-case-changes-or-reads, and litespec/core/use-cases#state-the-consistency-of-every-use-case. Every edge the use case causes goes in effects with one verb and its timing: runs, sends, emits, or calls. This satisfies litespec/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 satisfies litespec/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 satisfies litespec/core/use-cases#name-the-idempotency-of-every-use-case and litespec/core/use-cases#state-the-audit-position-of-every-command.

    A query changes nothing and declares its output in include groups with freshness, plus its paging. This satisfies litespec/core/use-cases#declare-every-query-output and litespec/core/use-cases#declare-paging-on-every-collection-query.

  11. Write one result per ending. A result is one way a use case ends: success, or a named refusal with its stable code. List refusals in precedence order, first match wins. This satisfies litespec/core/use-cases#end-every-use-case-in-named-results, litespec/core/use-cases#order-every-refusal-list-by-precedence, and litespec/backend/application#model-expected-use-case-refusals-explicitly.

    ResultOutcomeCodeMeaning
    result/submit-expense.submittedsuccessNoneThe 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 carries recorded: true; a success with recorded outcomes inside it carries codes. Each refusal explains what the caller sees. The code is stable across releases. The backend stack derives the HTTP status from the outcome. This satisfies litespec/backend/api#use-consistent-status-codes.

  12. 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 satisfies litespec/core/use-cases#write-every-scenario-in-given-when-then and litespec/core/use-cases#name-the-result-each-scenario-ends-in.

    ScenarioGivenWhenThenResult
    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.submitted
    scenario/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.approved

    When the scenario proves a calculation, add examples with the debated rows. This satisfies litespec/core/domain-model#tabulate-every-calculation-with-examples. Name each test by the observable behavior it asserts. This satisfies litespec/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.

  13. 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-carries and litespec/backend/dotnet/domain#raise-immutable-domain-facts.

    EventMeaningCarries
    event/expense.submittedAn expense was submitted.expenseId, amount, currency, employeeId
    event/expense.reimbursedAn expense was reimbursed.expenseId, amount, currency, reimbursedAt

    A 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 translates event/expense.reimbursed for the payout bank. The use case declares it in effects with emits. This satisfies litespec/core/edges#describe-every-integration-event-for-its-direction and litespec/core/edges#publish-every-outbound-integration-event-from-effects.

  14. Write the reactions, jobs, notifications, and settings as their own records. Work that answers an event is a reaction: it names the events in triggeredBy and runs its use cases through effects, and it never writes an aggregate directly. Work that runs on a schedule or by hand is a job: it names its kind with its schedule, selection, and guard, and it never writes an aggregate directly either. This satisfies litespec/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, and litespec/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 satisfies litespec/core/automation#file-every-reaction-and-job-with-the-module-it-changes and litespec/backend/dotnet/application#run-reactions-in-dedicated-handlers.

    RecordShape
    reaction/spending.notify-on-reimbursementOn event/expense.reimbursed, sends notification/spending.reimbursement-received.
    job/spending.send-approver-remindersScheduled nightly; runs use-case/spending.send-approver-reminder per 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, and litespec/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 satisfies litespec/core/edges#describe-every-notification-with-audience-channel-and-consent and litespec/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 satisfies litespec/core/use-cases#hold-every-setting-default-to-a-requirement and litespec/core/domain-model#keep-model-invariants-in-rules.

  15. 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 satisfies litespec/core/access#declare-every-operation-on-every-external-system and litespec/core/access#declare-erasure-for-every-external-system. Schedule every data class for retention with its basis, period, enforcement, and Intent acceptance. This satisfies litespec/core/privacy#schedule-every-data-class-for-retention.

  16. 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-edges and litespec/backend/persistence#commit-each-command-pipeline-once.

  17. Map the model to folders. Mirror the module, aggregate, UseCases/, and use case path in every layer. This satisfies litespec/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.cs

    Create 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.

  18. Run the model check. Run litespec model check from 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 how litespec runs 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.