Subscription Billing
A subscription lifecycle with four commands and strict state transition rules. Demonstrates computed state, lifecycle guards, and revenue projections.
Domain
Section titled “Domain”A SaaS billing system where subscriptions move through: Started → Renewed (repeatable), Suspended, or Cancelled. Guards enforce valid transitions at every step.
Define the System
Section titled “Define the System”CREATE AGGREGATE Subscription;
CREATE COMMAND Subscribe ( subscription_id STRING, customer_id STRING, plan STRING, monthly_rate DECIMAL);
CREATE COMMAND RenewSubscription ( subscription_id STRING);
CREATE COMMAND CancelSubscription ( subscription_id STRING, reason STRING);
CREATE COMMAND SuspendSubscription ( subscription_id STRING, reason STRING);
CREATE EVENT SubscriptionStarted ( customer_id STRING, plan STRING, monthly_rate DECIMAL);
CREATE EVENT SubscriptionRenewed ();
CREATE EVENT SubscriptionCancelled ( reason STRING);
CREATE EVENT SubscriptionSuspended ( reason STRING);Lifecycle Decisions
Section titled “Lifecycle Decisions”Starting is unconditional:
CREATE DECISION SubscribeFOR SubscriptionON COMMAND SubscribeEMIT AS SELECT EVENT SubscriptionStarted ( customer_id := :customer_id, plan := :plan, monthly_rate := :monthly_rate );Renewal only from active states:
CREATE DECISION RenewSubscriptionFOR SubscriptionON COMMAND RenewSubscriptionSTATE AS SELECT LAST(event_type) AS current_status FROM DeReg."Subscription$Events" WHERE stream_id = :subscription_idEMIT AS SELECT EVENT SubscriptionRenewed () WHERE current_status IN ('SubscriptionStarted', 'SubscriptionRenewed');Cancellation from active or suspended:
CREATE DECISION CancelSubscriptionFOR SubscriptionON COMMAND CancelSubscriptionSTATE AS SELECT LAST(event_type) AS current_status FROM DeReg."Subscription$Events" WHERE stream_id = :subscription_idEMIT AS SELECT EVENT SubscriptionCancelled ( reason := :reason ) WHERE current_status IN ('SubscriptionStarted', 'SubscriptionRenewed', 'SubscriptionSuspended');Suspension only from active states:
CREATE DECISION SuspendSubscriptionFOR SubscriptionON COMMAND SuspendSubscriptionSTATE AS SELECT LAST(event_type) AS current_status FROM DeReg."Subscription$Events" WHERE stream_id = :subscription_idEMIT AS SELECT EVENT SubscriptionSuspended ( reason := :reason ) WHERE current_status IN ('SubscriptionStarted', 'SubscriptionRenewed');Projections
Section titled “Projections”CREATE PROJECTION ActiveSubscriptions ASSELECT stream_id AS subscription_id, LAST(data.customer_id) AS customer_id, LAST(data.plan) AS plan, LAST(data.monthly_rate) AS monthly_rate, LAST(event_type) AS status, COUNT(*) FILTER (WHERE event_type = 'SubscriptionRenewed') AS renewal_countFROM DeReg."Subscription$Events"GROUP BY stream_id;
CREATE PROJECTION RevenueReport ASSELECT LAST(data.plan) AS plan, COUNT(DISTINCT stream_id) AS subscriber_count, SUM(data.monthly_rate) AS total_monthly_revenueFROM DeReg."Subscription$Events"WHERE event_type = 'SubscriptionStarted'GROUP BY data.plan;Execute and Observe
Section titled “Execute and Observe”EXECUTE Subscribe(subscription_id := 'SUB-001', customer_id := 'CUST-A', plan := 'Pro', monthly_rate := 29.99);
✓ SubscriptionStarted stream_id: SUB-001 seq: 1 customer_id: CUST-A plan: Pro monthly_rate: 29.99
EXECUTE Subscribe(subscription_id := 'SUB-002', customer_id := 'CUST-B', plan := 'Basic', monthly_rate := 9.99);
✓ SubscriptionStarted stream_id: SUB-002 seq: 1 customer_id: CUST-B plan: Basic monthly_rate: 9.99Renew one:
EXECUTE RenewSubscription(subscription_id := 'SUB-001');
✓ SubscriptionRenewed stream_id: SUB-001 seq: 2Suspend the other:
EXECUTE SuspendSubscription(subscription_id := 'SUB-002', reason := 'Payment failed');
✓ SubscriptionSuspended stream_id: SUB-002 seq: 2 reason: Payment failedTry to renew a suspended subscription — guard rejects:
EXECUTE RenewSubscription(subscription_id := 'SUB-002');
✗ REJECTED decision: RenewSubscription guard: current_status IN ('SubscriptionStarted', 'SubscriptionRenewed') state: current_status = 'SubscriptionSuspended' command: subscription_id = 'SUB-002'Cancel the suspended one:
EXECUTE CancelSubscription(subscription_id := 'SUB-002', reason := 'Customer churned');
✓ SubscriptionCancelled stream_id: SUB-002 seq: 3 reason: Customer churnedTry to cancel again — already cancelled:
EXECUTE CancelSubscription(subscription_id := 'SUB-002', reason := 'Duplicate');
✗ REJECTED decision: CancelSubscription guard: current_status IN ('SubscriptionStarted', 'SubscriptionRenewed', 'SubscriptionSuspended') state: current_status = 'SubscriptionCancelled' command: subscription_id = 'SUB-002' command: reason = 'Duplicate'Query Projections
Section titled “Query Projections”SELECT * FROM DeReg."ActiveSubscriptions";
+-----------------+-------------+-------+--------------+-----------------------+---------------+| subscription_id | customer_id | plan | monthly_rate | status | renewal_count |+-----------------+-------------+-------+--------------+-----------------------+---------------+| SUB-001 | CUST-A | Pro | 29.99 | SubscriptionRenewed | 1 || SUB-002 | CUST-B | Basic | 9.99 | SubscriptionCancelled | 0 |+-----------------+-------------+-------+--------------+-----------------------+---------------+SELECT * FROM DeReg."RevenueReport";
+-------+------------------+-----------------------+| plan | subscriber_count | total_monthly_revenue |+-------+------------------+-----------------------+| Basic | 1 | 9.99 || Pro | 1 | 29.99 |+-------+------------------+-----------------------+What This Demonstrates
Section titled “What This Demonstrates”- Multi-state IN guards — allowing transitions from multiple valid states
- Lifecycle enforcement — suspended subscriptions can be cancelled but not renewed
- FILTER clause in projections for per-event-type aggregation
- Revenue reporting from event data