Skip to content

Loyalty Context

The Loyalty context manages loyalty program enrollment, point accrual from both dine-in and online purchases, and point redemption. It is isolated as its own bounded context so the loyalty program can be developed and rolled out independently without touching any other context.

Purpose

The CEO wanted a customer loyalty program but was told it required a "major refactor" of the existing system. By modeling loyalty as an isolated context that listens to payment events via adaptors, the program can be developed and deployed incrementally without modifying Front of House or Online Ordering.

Interview Connection

From the CEO's interview:

"I tried to get them to build a loyalty program... The development team told us that this would require a major refactor."

From the Delivery Driver's interview:

"They tell me that those will go through the same app. That just sounds like more headache."

The isolation pattern means loyalty is additive — it receives events but doesn't require changes to the systems that generate those events.

Types

type LoyaltyAccountId is Id(Loyalty.LoyaltyAccount) with {
  briefly "Loyalty account identifier"
  described by "Unique identifier for a loyalty account."
}

type LoyaltyCustomerId is UUID with {
  briefly "Loyalty customer identifier"
  described by "Unique identifier for the loyalty customer."
}

type LoyaltyAccountStatus is any of {
  LoyaltyActive,
  LoyaltySuspended
} with {
  briefly "Account status"
  described by "Current status of a loyalty account."
}

type PointTransaction is {
  transactionId is UUID
  pointsChanged is Integer
  transactionReason is String(1, 200)
  sourceOrderRef is optional String(1, 50)
  transactionTimestamp is TimeStamp
} with {
  briefly "Point transaction"
  described by "A single loyalty point accrual or redemption."
}

Note that pointsChanged is an Integer (not Natural) — it can be positive for accruals or negative for redemptions.

Entity: LoyaltyAccount

The LoyaltyAccount entity has a 5-command lifecycle:

entity LoyaltyAccount is {

  command EnrollCustomer is {
    loyaltyAccountId is LoyaltyAccountId
    loyaltyCustomerId is LoyaltyCustomerId
    customerDisplayName is String(1, 100)
    customerEmail is String(5, 254)
  }

  command AccruePoints is {
    loyaltyAccountId is LoyaltyAccountId
    accrualPoints is Natural
    accrualReason is String(1, 200)
    accrualOrderRef is optional String(1, 50)
  }

  command RedeemPoints is {
    loyaltyAccountId is LoyaltyAccountId
    redemptionPoints is Natural
    redemptionReason is String(1, 200)
  }

  command SuspendAccount is {
    loyaltyAccountId is LoyaltyAccountId
    suspensionReason is String(1, 500)
  }

  command ReactivateAccount is {
    loyaltyAccountId is LoyaltyAccountId
  }

  // Events: CustomerEnrolled, PointsAccrued, PointsRedeemed,
  //         AccountSuspended, AccountReactivated

  state ActiveAccount of LoyaltyAccount.LoyaltyAccountStateData

  handler LoyaltyAccountHandler is {
    on command EnrollCustomer {
      morph entity Loyalty.LoyaltyAccount to state
        Loyalty.LoyaltyAccount.ActiveAccount
        with command EnrollCustomer
      tell event CustomerEnrolled to
        entity Loyalty.LoyaltyAccount
    }
    on command AccruePoints {
      tell event PointsAccrued to
        entity Loyalty.LoyaltyAccount
    }
    on command RedeemPoints {
      tell event PointsRedeemed to
        entity Loyalty.LoyaltyAccount
    }
    on command SuspendAccount {
      tell event AccountSuspended to
        entity Loyalty.LoyaltyAccount
    }
    on command ReactivateAccount {
      tell event AccountReactivated to
        entity Loyalty.LoyaltyAccount
    }
  }
}

The state tracks both current pointBalance and lifetimePoints, plus a list of recentTransactions. The PointsAccrued event includes a newBalance field so downstream systems know the current balance without querying.

Repository

repository LoyaltyAccountRepository is {
  schema LoyaltyAccountData is relational
    of accounts as LoyaltyAccount
    index on field LoyaltyAccount.loyaltyAccountId
    index on field LoyaltyAccount.loyaltyCustomerId
    index on field LoyaltyAccount.customerEmail
}

The index on customerEmail enables account lookup during enrollment to prevent duplicate accounts.

Adaptors

Loyalty has two inbound adaptors — one for dine-in payments, one for online payments:

adaptor FromPayment from context Restaurant.FrontOfHouse is {
  handler DineInLoyaltyIntake is {
    on event Restaurant.FrontOfHouse.TableOrder.PaymentProcessed {
      prompt "Accrue loyalty points from dine-in payment"
    }
  }
}

adaptor FromOnlinePayment from context Restaurant.OnlineOrdering is {
  handler OnlineLoyaltyIntake is {
    on event Restaurant.OnlineOrdering.OnlineOrder.OnlinePaymentProcessed {
      prompt "Accrue loyalty points from online payment"
    }
  }
}

Both adaptors listen for payment events and trigger point accrual. The key insight: neither Front of House nor Online Ordering needs to know about loyalty. They simply process payments as normal, and the loyalty context reacts to those events. This is why the CEO's loyalty program doesn't require a "major refactor."

Design Decisions

Why isolated? The entire value proposition of the Loyalty context is independence. It can be developed, tested, and deployed without modifying any existing context. The adaptors pattern makes it purely additive — it consumes events that are already being produced.

Why two separate payment adaptors? Dine-in and online payments have different event structures (PaymentProcessed vs OnlinePaymentProcessed) and different contexts of origin. Separate adaptors keep the translation logic clean and independently testable.

Incremental rollout strategy: Loyalty can be deployed to a single location first, then rolled out chain-wide. Since it only listens to events, enabling it at a location is just a matter of routing payment events to the loyalty context — no changes to the POS or online ordering systems.

Source