Skip to main content

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 a ContractModification 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() and reverse() methods on the ContractModification aggregate 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 ModificationTreatment as 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 EmptyAllocations guard 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::Decimal for 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.
ConditionTreatmentAccounting Effect
Modification adds distinct goods/services at standalone selling priceProspective (§25-12)Treated as a new, separate contract; no adjustment to existing revenue
Modification changes existing performance obligations or alters price of existing obligationsCumulative Catch-Up (§25-13)Catch-up adjustment recognized in the current period; remaining revenue re-allocated
The ContractModification aggregate encodes this bifurcation directly:
pub struct ContractModification {
    pub modification_id: String,          // contmod_{UUID v4}
    pub tenant_id: String,
    pub contract_id: String,
    pub treatment: ModificationTreatment,
    pub modification_type: ModificationType,
    pub status: ModificationStatus,
    pub effective_date: DateTime<Utc>,
    pub modification_reason: String,
    pub original_transaction_price: Decimal,
    pub modified_transaction_price: Decimal,
    pub bundle_allocations: Vec<BundleAllocation>,
    pub revenue_schedule_adjustments: Vec<RevenueScheduleAdjustment>,
    // Audit fields — first-class on the aggregate, not derived
    pub created_by: String,
    pub applied_at: Option<DateTime<Utc>>,
    pub applied_by: Option<String>,
    pub reversed_at: Option<DateTime<Utc>>,
    pub reversed_by: Option<String>,
}

pub enum ModificationTreatment {
    Prospective,
    CumulativeCatchUp,
}

pub enum ModificationType {
    AddGoods,
    RemoveGoods,
    PriceChange,
    ScopeChange,
}

pub enum ModificationStatus {
    Draft,
    Applied,
    Reversed,
}
The 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

The ContractModification 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 ──apply()──► Applied ──reverse()──► Reversed
                                          (terminal)
This state machine is not cosmetic. Each transition is a compliance checkpoint:
  • Draft → Applied: The modification has been reviewed and is now effective. Revenue schedules are adjusted. The applied_by and applied_at fields 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_by and reversed_at fields 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.
The implementation enforces these constraints through the error enum:
pub enum ContractModificationError {
    NotDraft(String),       // transition attempted from invalid source state
    AlreadyApplied,         // idempotency guard
    AlreadyReversed,        // terminal state guard
    NotApplied,             // reversal attempted before application
    EmptyAllocations,       // business rule: at least one allocation required
    InvalidAllocationSum(Decimal), // allocations must sum to 100% ± 0.01
}

impl ContractModification {
    pub fn apply(
        &mut self,
        applied_by: String,
    ) -> Result<ContractModificationEvent, ContractModificationError> {
        match self.status {
            ModificationStatus::Applied => Err(ContractModificationError::AlreadyApplied),
            ModificationStatus::Reversed => Err(ContractModificationError::AlreadyReversed),
            ModificationStatus::Draft => {
                self.status = ModificationStatus::Applied;
                self.applied_by = Some(applied_by.clone());
                self.applied_at = Some(Utc::now());
                Ok(ContractModificationEvent::Applied { /* fields */ })
            }
        }
    }

    pub fn reverse(
        &mut self,
        reversed_by: String,
        reason: String,
    ) -> Result<ContractModificationEvent, ContractModificationError> {
        match self.status {
            ModificationStatus::Draft => Err(ContractModificationError::NotApplied),
            ModificationStatus::Reversed => Err(ContractModificationError::AlreadyReversed),
            ModificationStatus::Applied => {
                self.status = ModificationStatus::Reversed;
                self.reversed_by = Some(reversed_by.clone());
                self.reversed_at = Some(Utc::now());
                Ok(ContractModificationEvent::Reversed { reason, /* fields */ })
            }
        }
    }
}
Each 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.
Reversed is a terminal state by design. An HTTP handler that attempts to re-apply a reversed modification must handle the AlreadyReversed error at the API layer rather than attempting to reset the aggregate’s status field directly. Any implementation that bypasses the aggregate’s apply() method to mutate status circumvents the compliance checkpoint and produces an inconsistent audit trail.

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. The BundleAllocation value object encodes one allocation entry:
pub struct BundleAllocation {
    pub performance_obligation_id: String,
    pub description: String,
    pub standalone_selling_price: Decimal,
    pub allocated_transaction_price: Decimal,
    pub allocation_percentage: Decimal,
    pub is_new_obligation: bool,
}
The 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():
fn validate_allocations(
    allocations: &[BundleAllocation],
) -> Result<(), ContractModificationError> {
    if allocations.is_empty() {
        return Err(ContractModificationError::EmptyAllocations);
    }

    let sum: Decimal = allocations
        .iter()
        .map(|a| a.allocation_percentage)
        .sum();

    let tolerance = Decimal::new(1, 2); // 0.01
    let hundred = Decimal::new(100, 0);

    if (sum - hundred).abs() > tolerance {
        return Err(ContractModificationError::InvalidAllocationSum(sum));
    }

    Ok(())
}
The ±0.01 tolerance is not permissiveness — it is arithmetic acknowledgment. When a transaction price of $147,500 is allocated across three performance obligations with standalone selling prices that produce repeating decimals, the computed allocation percentages will sum to 99.9999…% or 100.0001…%. Requiring exact equality would make the validation fail on correct inputs. Requiring only that the sum fall within ±0.01 of 100 accepts the arithmetic reality while detecting genuine allocation errors — a sum of 97% or 103% — that indicate a performance obligation was omitted or double-counted.
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

The RevenueScheduleAdjustment value object represents the period-level revenue impact of a modification:
pub struct RevenueScheduleAdjustment {
    pub period: String,                  // e.g. "2026-Q2"
    pub adjustment_amount: Decimal,
    pub cumulative_recognized: Decimal,
    pub remaining_unrecognized: Decimal,
    pub is_catch_up_adjustment: bool,
}
The 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:
ModificationTreatmentis_catch_up_adjustment expectedRevenue schedule effect
ProspectiveAlways falseNew recognition schedule for added obligations
CumulativeCatchUpAt least one trueCatch-up in current period + restated forward schedule
The downstream revenue schedule calculator consumes this value object and branches on 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
KeyValueRationale
PKTENANT#{tenant_id}#CONTRACT#{contract_id}All modifications for a contract in one partition — the primary query is always contract-scoped
SKMODIFICATION#{modification_id}Ordered by ID; enables range queries over modification history
Global Secondary Index (GSI1):
KeyValueUse case
GSI1-PKTENANT#{tenant_id}#STATUS#{status}Query all Draft modifications for a tenant (period-end review)
GSI1-SKcreated_atOrder by creation date within status group
The partition key design encodes a domain invariant: contract modifications are always managed in the context of a specific contract. A query that retrieves modifications without a contract identifier has no valid business meaning in this domain. Partitioning by contract ensures that such queries cannot be expressed as efficient DynamoDB operations — a friction that reflects the domain constraint rather than fighting it. The GSI1 design supports the operational workflow: at period-end, accounting teams need to identify all 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:
MethodPathPermissionAggregate operation
POST/revenue/contracts/{cid}/modificationsrevenue:modifications:writeContractModification::create()Draft
GET/revenue/contracts/{cid}/modificationsrevenue:modifications:readQuery by PK (contract-scoped list)
GET/revenue/contracts/{cid}/modifications/{mid}revenue:modifications:readGet by PK + SK
POST/revenue/contracts/{cid}/modifications/{mid}/applyrevenue:modifications:writeaggregate.apply()Applied
POST/revenue/contracts/{cid}/modifications/{mid}/reverserevenue:modifications:writeaggregate.reverse()Reversed
There is no 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 the ContractModification 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:
// f64 arithmetic — not suitable for monetary calculations
let price: f64 = 147500.0;
let percentage: f64 = 0.333333;
let allocated = price * percentage; // 49999.97... not 49999.95

// Decimal arithmetic — correct
let price = Decimal::new(147500, 0);
let percentage = Decimal::from_str("0.333333").unwrap();
let allocated = price * percentage; // 49166.62... exact per specified precision
The compounding effect in ASC 606 allocation calculations is not theoretical. A transaction price allocated across three performance obligations, each producing a repeating-decimal percentage, will accumulate rounding error across the allocation sum validation, the revenue schedule calculation, and the period-end close. The aggregate’s 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.
Introducing f64 for intermediate calculations — even temporarily, as in computing a percentage for display purposes — risks contaminating the result with IEEE 754 rounding error before it is stored back as Decimal. All monetary arithmetic must remain within Decimal types throughout the computation chain. Converting to f64 at any point and back to Decimal does not recover the lost precision.

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 correct Decimal 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

  1. Implement the ModificationTreatment enum before any downstream revenue schedule logic is written. The enum’s match arms will surface every code path that must handle both Prospective and CumulativeCatchUp treatment, 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.
  2. Unit-test the aggregate’s error conditions directly, without a database. The apply() and reverse() methods are pure functions over the aggregate’s state. Write tests that construct an aggregate in each ModificationStatus, call apply() or reverse(), and assert the specific ContractModificationError returned. These tests run in milliseconds and cover all compliance-critical transition guards without infrastructure.
  3. Enforce the money-safety ADR with a linting rule or CI check that rejects f64 fields on any struct in the revenue domain. Manual code review is not sufficient to prevent f64 from re-entering the codebase as features are added. A static analysis check — a custom Clippy lint or a CI script that greps for f64 in the revenue module — converts the ADR from a documentation artifact into an enforced constraint.
  4. Store the modification_reason field on the aggregate and require it for all create calls. ASC 606 requires that the basis for modification classification be documentable. A modification_reason field that is present but optional will be empty on a significant portion of production records. Make it a required, non-empty String in the create() factory; reject empty strings with a validation error at the domain layer, not the HTTP layer.
  5. Treat DynamoDB Streams as the audit backstop, not the primary audit mechanism. The applied_by, applied_at, reversed_by, and reversed_at fields 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 606 ModificationTreatment 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.