Documentation Index
Fetch the complete documentation index at: https://www.aidonow.com/llms.txt
Use this file to discover all available pages before exploring further.
Executive Summary
ASC 606, Revenue from Contracts with Customers, is one of the most consequential accounting standards in US GAAP, governing when and how revenue is recognized across substantially all industries. Contract modifications — changes to an existing contract’s scope, price, or terms — introduce a bifurcated treatment requirement: modifications that add distinct goods or services at standalone selling price are treated as new contracts (prospective treatment, §25-12), while modifications that alter existing performance obligations require a catch-up adjustment in the current period (cumulative catch-up treatment, §25-13). Encoding these rules as ad hoc business logic — scattered across service layers, HTTP handlers, or database triggers — produces systems where the rules can be violated, bypassed, or inconsistently applied, all with audit consequences. This paper documents how applying Domain-Driven Design discipline to the contract modification problem produces aContractModification aggregate whose core ASC 606 invariants are compiler-enforced, testable in isolation without infrastructure, and structurally resistant to regression. The analysis covers the aggregate design, the ModificationTreatment enum as an accounting standards encoding, state machine enforcement, value object validation, DynamoDB access pattern design, and the money-safety argument for rust_decimal::Decimal over floating-point arithmetic.
Key Findings
- The
apply()andreverse()methods on theContractModificationaggregate are not CRUD operations — they are compliance checkpoints whose error conditions map directly to ASC 606 constraints, making violations impossible without explicit circumvention of the domain layer. - Encoding
ModificationTreatmentas an enum rather than a boolean or string field makes the accounting standard’s intent explicit in the type system, preventing the class of bugs where a modification is stored with one treatment but processed with another due to string comparison errors or missing branches in downstream logic. - Allocation percentage validation with a ±0.01 tolerance acknowledges the arithmetic reality of dividing transaction prices by standalone selling prices, while the
EmptyAllocationsguard enforces the business rule that a modification with no performance obligation mapping cannot be applied. - Audit fields —
applied_by,applied_at,reversed_by,reversed_at— are first-class aggregate fields, not derived from an event log, ensuring the compliance record survives regardless of downstream consumer availability. - Using
rust_decimal::Decimalfor all monetary values is a correctness requirement under ASC 606, not an ergonomics choice: IEEE 754 rounding errors compound across allocation percentage multiplications and period-level revenue schedule calculations, producing figures that differ from auditor calculations by amounts that constitute reportable misstatements. - The DynamoDB partition key design — all modifications for a contract in one partition — reflects the access pattern reality that contract modifications are always queried in the context of a specific contract, and that modification history is a first-class audit artifact requiring full retrieval.
1. The Compliance Encoding Problem: Why Business Logic Without a Domain Model Produces Audit Risk
Revenue recognition under ASC 606 is not a reporting problem — it is a timing and classification problem. Revenue must be recognized when, and only when, control of goods or services transfers to the customer. Contract modifications change the terms of that transfer, and the accounting treatment applied to the modification determines how previously recognized and future revenue is stated. The compliance risk in software systems is not that the rules are unknown — accounting teams understand ASC 606. The risk is that the rules exist in documentation, not in executable code. When the modification treatment decision is implemented as a flag in a database record with no enforcement of what that flag means downstream, or as a conditional in a service method with no guard against invalid state transitions, the system accepts invalid data. Invalid modification records do not fail loudly at the moment of creation. They fail quietly during the period-end revenue close, when recognized amounts do not reconcile with contracts, or during an audit, when the auditor asks for evidence that each modification was treated correctly. The conventional CRUD approach to this problem produces a service that creates, reads, updates, and deletes modification records. The domain model approach produces an aggregate that enforces invariants: a modification cannot be applied twice, cannot be reversed before it is applied, cannot carry a zero-allocation set. These invariants are not validated by an application layer that may be bypassed — they are enforced by the aggregate’s methods, which are the only path through which state changes occur. The distinction matters structurally. In a CRUD system, the invariant lives wherever the last developer put it. In a domain model, the invariant is co-located with the state it protects, versioned with the aggregate, and exercised by every unit test that touches the aggregate.2. ASC 606 Contract Modifications: Two Treatments, One Aggregate
ASC 606 §25-12 and §25-13 define two treatments for contract modifications, and the classification depends on whether the modification adds goods or services that are distinct and priced at their standalone selling price.| Condition | Treatment | Accounting Effect |
|---|---|---|
| Modification adds distinct goods/services at standalone selling price | Prospective (§25-12) | Treated as a new, separate contract; no adjustment to existing revenue |
| Modification changes existing performance obligations or alters price of existing obligations | Cumulative Catch-Up (§25-13) | Catch-up adjustment recognized in the current period; remaining revenue re-allocated |
ContractModification aggregate encodes this bifurcation directly:
ModificationTreatment enum is the load-bearing compliance encoding in this design. When a modification is created, the accounting team classifies it as Prospective or CumulativeCatchUp based on ASC 606 criteria. That classification is stored on the aggregate and propagates to every downstream consumer — the revenue schedule calculator, the period-end close process, the financial reporting layer — through match arms, not through string comparisons or boolean conditionals.
This design property is non-trivial in regulated contexts. A bool is_cumulative_catch_up field can be set to true and then ignored in a downstream branch that was written before the field existed. A ModificationTreatment enum produces a compile error if a match arm is missing, forcing every downstream consumer to explicitly handle both cases. The compiler becomes a compliance reviewer.
3. The State Machine: Draft → Applied → Reversed as Irreversible Compliance Checkpoints
TheContractModification aggregate follows a three-state lifecycle. The transitions are intentionally constrained: Reversed is a terminal state, Applied can only be reached from Draft, and Reversed can only be reached from Applied.
- Draft → Applied: The modification has been reviewed and is now effective. Revenue schedules are adjusted. The
applied_byandapplied_atfields are written. This event is immutable once committed. - Applied → Reversed: The modification is rescinded. A reversal entry is created in the revenue schedule. The
reversed_byandreversed_atfields are written. The modification record remains; reversal does not delete. - Draft → Reversed: Not permitted. A modification that was never applied cannot be reversed — there is nothing to reverse.
- Applied → Applied: Not permitted. Idempotency guard prevents double-application.
- Reversed → Applied: Not permitted. A reversal is permanent.
apply() and reverse() call returns a domain event. The event is the authoritative record of what occurred and is the input to downstream consumers — the revenue schedule calculator, the audit log writer, the notification service. The aggregate itself holds the current state; the event stream holds the history.
4. Value Objects as Constraint Enforcement: BundleAllocation and the 100% Allocation Rule
ASC 606 requires that the transaction price of a contract be allocated to each performance obligation in proportion to its standalone selling price. TheBundleAllocation value object encodes one allocation entry:
is_new_obligation field distinguishes performance obligations added by the modification (relevant only in Prospective treatment) from existing obligations whose allocation has changed (relevant in CumulativeCatchUp treatment). This distinction drives downstream revenue schedule calculation.
The validation constraint — that allocation_percentage values must sum to 100.00 ± 0.01 — is enforced at aggregate creation and before apply():
The
EmptyAllocations guard addresses a distinct failure mode from InvalidAllocationSum. A modification with no allocations has not been mapped to any performance obligations; it cannot be applied without producing an unallocated transaction price. The two guards are not redundant — they protect against different states.5. Revenue Schedule Adjustments: Prospective vs. Cumulative Catch-Up in the Type System
TheRevenueScheduleAdjustment value object represents the period-level revenue impact of a modification:
is_catch_up_adjustment field is only semantically meaningful when ModificationTreatment is CumulativeCatchUp. For Prospective modifications, the revenue schedule adjustments represent the new contract’s recognition schedule — no catch-up entry exists. For CumulativeCatchUp modifications, one or more adjustments will carry is_catch_up_adjustment: true, representing the current-period entry that brings cumulative recognized revenue to the amount that would have been recognized under the modified terms from contract inception.
This relationship — between the treatment enum on the aggregate and the catch-up flag on the value object — is a domain invariant that cannot be enforced by the type system alone, because it spans two distinct structures. It is enforced at the aggregate factory and at the apply boundary:
ModificationTreatment | is_catch_up_adjustment expected | Revenue schedule effect |
|---|---|---|
Prospective | Always false | New recognition schedule for added obligations |
CumulativeCatchUp | At least one true | Catch-up in current period + restated forward schedule |
is_catch_up_adjustment to determine whether to generate a prior-period-equivalent entry in the current period’s journal. The treatment enum on the parent aggregate is not re-read by the calculator — it has already shaped the value object’s content. This separation of concerns is deliberate: the aggregate enforces the domain rules; the value objects carry their consequences.
6. DynamoDB Design: Partition by Contract, Index by Status
The DynamoDB table design follows from the access patterns the domain requires, applied before any API design is considered. Table:{capsule_id}_revenue_contract_modifications
| Key | Value | Rationale |
|---|---|---|
| PK | TENANT#{tenant_id}#CONTRACT#{contract_id} | All modifications for a contract in one partition — the primary query is always contract-scoped |
| SK | MODIFICATION#{modification_id} | Ordered by ID; enables range queries over modification history |
| Key | Value | Use case |
|---|---|---|
| GSI1-PK | TENANT#{tenant_id}#STATUS#{status} | Query all Draft modifications for a tenant (period-end review) |
| GSI1-SK | created_at | Order by creation date within status group |
Draft modifications that have not been applied, across all contracts for a tenant. Without this index, that query requires either a full table scan or application-layer filtering of all modification records. With it, the query is a single DynamoDB Query call with GSI1-PK = TENANT#tenant-a#STATUS#Draft.
DynamoDB Streams are configured with
NEW_AND_OLD_IMAGES on this table. The stream captures both the before and after state of every modification record on every write. This provides an independent audit trail that is not subject to the application layer — even if the domain event consumer fails to process an apply() event, the stream contains evidence of the state transition with timestamps. The stream record is the compliance backstop.7. REST API: Five Endpoints Mapped to the State Machine
The five REST endpoints map directly to the aggregate’s methods and state transitions, with no additional operations:| Method | Path | Permission | Aggregate operation |
|---|---|---|---|
POST | /revenue/contracts/{cid}/modifications | revenue:modifications:write | ContractModification::create() → Draft |
GET | /revenue/contracts/{cid}/modifications | revenue:modifications:read | Query by PK (contract-scoped list) |
GET | /revenue/contracts/{cid}/modifications/{mid} | revenue:modifications:read | Get by PK + SK |
POST | /revenue/contracts/{cid}/modifications/{mid}/apply | revenue:modifications:write | aggregate.apply() → Applied |
POST | /revenue/contracts/{cid}/modifications/{mid}/reverse | revenue:modifications:write | aggregate.reverse() → Reversed |
PUT or PATCH endpoint. A Draft modification that contains an error is not edited in place — it is reversed or discarded and a new Draft is created. This constraint is intentional: once a modification has been applied, editing it retroactively would require reconciling the revenue schedule adjustments it produced, which is precisely the kind of operation ASC 606 requires to be tracked as a distinct event. The API surface reflects the domain model’s state machine.
The apply and reverse endpoints use POST rather than PATCH because they represent domain operations — transitions with business meaning — not partial resource updates. The distinction is not pedantic: a PATCH implies idempotent, reversible resource mutation. POST to a sub-resource path signals that an action with side effects is being executed. Downstream consumers subscribing to modification events can trust that an apply call produced a ContractModificationEvent::Applied event exactly once.
Permission separation between read and write operations supports the operational reality that revenue analysts may need read access to modification history without write access to apply or reverse modifications. The apply and reverse actions are write operations with audit consequences and are gated accordingly.
8. The Money-Safety ADR: Why Decimal Is a Correctness Requirement, Not an Ergonomics Choice
All monetary values in theContractModification aggregate — original_transaction_price, modified_transaction_price, standalone_selling_price, allocated_transaction_price, allocation_percentage, adjustment_amount, cumulative_recognized, remaining_unrecognized — are typed as rust_decimal::Decimal. The decision to prohibit f64 for monetary values is not a style preference. It is a correctness requirement with a specific failure mode.
IEEE 754 double-precision floating-point cannot represent all decimal fractions exactly. The value 0.1 is stored as the nearest representable binary fraction, which is approximately 0.1000000000000000055511151231257827021181583404541015625. This imprecision is invisible in most display contexts. It becomes visible when values are multiplied or accumulated:
InvalidAllocationSum error will reject inputs that are arithmetically correct when computed with Decimal but appear to sum incorrectly when computed with f64. More significantly, revenue recognized across multiple periods using f64 multiplication will produce figures that differ from the auditor’s recalculation by small but nonzero amounts — amounts that require explanation in an audit and may constitute misstatements at sufficient scale.
The rust_decimal crate implements decimal arithmetic using integer coefficient and exponent storage, avoiding binary representation entirely for decimal values within its supported range. The performance cost relative to f64 is measurable but insignificant at the transaction volumes typical of contract modification workflows. The correctness benefit — that multiplication and summation of monetary values produce results matching manual calculation — is not negotiable in a regulated accounting context.
9. Implementation Constraints
Several constraints emerged during implementation that are not visible in the aggregate design but have meaningful operational consequences. Floating-point tolerance in allocation validation is not a universal constant. The ±0.01 tolerance was selected for the current transaction price range and obligation count typical in the domain. Contracts with hundreds of performance obligations, each carrying a small allocation percentage, may accumulate allocation sum errors that exceed 0.01 even with correctDecimal arithmetic, depending on the rounding rules applied during percentage computation. The tolerance should be revisited if the obligation count distribution changes significantly.
The Reversed terminal state does not support deletion. A reversed modification remains in the DynamoDB table permanently. This is intentional — the modification is part of the contract’s history and may be referenced by audit queries — but it has storage implications for contracts with many modification cycles. Partition size on the contract key is bounded by DynamoDB’s 10 GB partition limit, which is not a practical concern for typical contract modification volumes but should be considered for contracts with unusual modification frequency.
GSI1 reflects status at write time, not at query time. A modification that transitions from Draft to Applied requires a DynamoDB update that changes the GSI1-PK value. This update is not atomic with the aggregate’s state transition in the application layer — there is a window during which the modification is Applied in the base table but still appears in the Draft GSI. Consumers of the GSI1 index for period-end review must account for this consistency window by re-validating modification status against the base table before treating a record as definitively Draft.
10. Recommendations
-
Implement the
ModificationTreatmentenum before any downstream revenue schedule logic is written. The enum’s match arms will surface every code path that must handle bothProspectiveandCumulativeCatchUptreatment, making incomplete handling a compile error. Writing the downstream logic first and adding the enum later requires finding and retrofitting all the branches that should have been required from the start. -
Unit-test the aggregate’s error conditions directly, without a database. The
apply()andreverse()methods are pure functions over the aggregate’s state. Write tests that construct an aggregate in eachModificationStatus, callapply()orreverse(), and assert the specificContractModificationErrorreturned. These tests run in milliseconds and cover all compliance-critical transition guards without infrastructure. -
Enforce the money-safety ADR with a linting rule or CI check that rejects
f64fields on any struct in the revenue domain. Manual code review is not sufficient to preventf64from re-entering the codebase as features are added. A static analysis check — a custom Clippy lint or a CI script that greps forf64in the revenue module — converts the ADR from a documentation artifact into an enforced constraint. -
Store the
modification_reasonfield on the aggregate and require it for all create calls. ASC 606 requires that the basis for modification classification be documentable. Amodification_reasonfield that is present but optional will be empty on a significant portion of production records. Make it a required, non-emptyStringin thecreate()factory; reject empty strings with a validation error at the domain layer, not the HTTP layer. -
Treat DynamoDB Streams as the audit backstop, not the primary audit mechanism. The
applied_by,applied_at,reversed_by, andreversed_atfields on the aggregate are the primary audit record. They are present on every modification record returned by any read operation. The stream provides a secondary record of every state change, including intermediate states, and should be consumed by an audit log writer that persists stream records independently. Do not rely solely on the stream — event consumers can fail or lag — but do not omit it.
Conclusion
Domain-Driven Design is conventionally positioned as a technique for managing complexity in large systems. Its value in regulated accounting domains is more specific: it is a mechanism for making compliance rules load-bearing rather than advisory. An ASC 606ModificationTreatment enum that drives a compiler error on an unhandled branch is not equivalent to a documentation rule that says “remember to handle both treatments.” A state machine that returns AlreadyReversed on an invalid transition is not equivalent to a code comment that says “don’t reverse a reversed modification.”
The aggregate design documented here encodes ASC 606 §25-12 and §25-13 as executable, testable, and compiler-enforced constraints. The validation rules — allocation sum within tolerance, non-empty allocation set, non-empty modification reason — encode accounting requirements as error conditions that are impossible to satisfy by accident. The money-safety ADR eliminates a class of arithmetic errors that are invisible in unit tests and consequential in audit.
As revenue recognition requirements continue to evolve — ASC 606 has been subject to ongoing interpretive guidance since its effective date — systems built on explicit domain models will accommodate those changes with lower risk than systems where accounting rules are distributed across service layers. A new treatment type or a new validation constraint is a bounded change in the domain model. In a CRUD system, it is a search-and-update across an unknown surface area.
All content represents personal learning from personal and open-source projects. Code examples are sanitized and generalized. No proprietary information is shared. Opinions are my own and do not reflect my employer’s views.