Skip to content
Event Sourcing 6 min read

Modelling a Subscription Billing Engine with Event Sourcing: A C# Walkthrough

A step-by-step C# walkthrough of modelling a subscription billing engine with event sourcing: aggregates, events, and where the boundaries go.

A subscription billing engine is one of those domains that looks friendly until you start modelling it. The lifecycle is clear enough: trial, active, past due, paused, cancelled, reactivated. The business rules feel obvious once you name them. And then you sit down to write the first event, and the modelling decisions start to compound in ways you did not expect.

This post walks through how to model this domain in C#: where to draw aggregate boundaries, how to name events, and a common boundary mistake that makes the model harder to work with.

Start with the business requirements, not the data model

The temptation when starting a new domain is to reach for a schema. What tables do we need? What columns? That instinct is understandable; it is what most of us spent years doing. But in event sourcing, the right starting point is the business language, not the storage structure.

So: what does a subscription billing engine actually care about?

A customer starts a trial. The trial converts to a paid subscription, or it does not. A subscriber upgrades their plan mid-cycle. A payment fails, and the system needs to decide what to do about it. A customer pauses their subscription while travelling. A customer cancels, then changes their mind three weeks later and reactivates. An invoice gets issued, paid, or disputed.

These are not database operations. They are business facts: things that happened, that the business needs to reason about, that have consequences. Event sourcing asks you to capture these facts explicitly, which means the first question is not “what state does a subscription have?” but “what things happen to a subscription?”

That is a small shift in framing, but it changes almost every decision that follows.

Identifying the aggregate: what is a subscription, really?

An aggregate is a transactional boundary. It is the thing that must be consistent within a single operation. If you need two things to be in sync every time you make a change, they probably belong in the same aggregate. If two things can change independently, they probably do not.

A subscription has a lifecycle entirely its own. It starts when a customer signs up. It changes plan when the customer upgrades or downgrades. It pauses, cancels, reactivates. Every one of those operations has invariants that need to be enforced before anything is allowed to happen: you cannot reactivate a subscription that is already active, you cannot upgrade a cancelled subscription, you cannot downgrade to a plan that provides fewer seats than are currently in use.

The subscription is the aggregate. Its stream tells you one coherent story: everything that has ever happened to this particular subscription, in order.

In C#, the aggregate class holds private state derived from its event history, and uses that state to enforce invariants before raising new events:

public class Subscription
{
private SubscriptionStatus _status;
private PlanId _currentPlan;
private int _seatsInUse;
private DateTimeOffset? _trialEndsAt;
public static Subscription Create(SubscriptionId id, CustomerId customerId, PlanId planId, DateTimeOffset trialEndsAt)
{
var subscription = new Subscription();
subscription.Apply(new SubscriptionCreated(id, customerId, planId));
subscription.Apply(new TrialStarted(id, trialEndsAt));
return subscription;
}
public void UpgradePlan(PlanId newPlan, int newPlanSeats)
{
if (_status == SubscriptionStatus.Cancelled)
throw new InvalidOperationException("Cannot upgrade a cancelled subscription.");
if (newPlanSeats < _seatsInUse)
throw new InvalidOperationException($"Cannot downgrade to a plan with fewer seats than currently in use. In use: {_seatsInUse}.");
Apply(new PlanUpgraded(_currentPlan, newPlan));
}
public void Cancel(string reason)
{
if (_status == SubscriptionStatus.Cancelled)
throw new InvalidOperationException("Subscription is already cancelled.");
Apply(new SubscriptionCancelled(reason, DateTimeOffset.UtcNow));
}
public void Reactivate(PlanId planId)
{
if (_status != SubscriptionStatus.Cancelled)
throw new InvalidOperationException("Only cancelled subscriptions can be reactivated.");
Apply(new SubscriptionReactivated(planId, DateTimeOffset.UtcNow));
}
private void Apply(SubscriptionCreated e) => _status = SubscriptionStatus.Created;
private void Apply(TrialStarted e) { _status = SubscriptionStatus.Trial; _trialEndsAt = e.EndsAt; }
private void Apply(TrialConverted e) => _status = SubscriptionStatus.Active;
private void Apply(PlanUpgraded e) => _currentPlan = e.NewPlan;
private void Apply(SubscriptionCancelled e) => _status = SubscriptionStatus.Cancelled;
private void Apply(SubscriptionReactivated e) { _status = SubscriptionStatus.Active; _currentPlan = e.PlanId; }
}

The aggregate does not return data. It enforces rules, raises events, and its internal state is entirely derived from those events. Nothing leaks out except through the event stream.

The events: capturing intent, not state transitions

Every event in this domain should read like a business fact. Not SubscriptionStatusUpdated(Status: "cancelled"). Not SubscriptionModified. Those names hide the intent. When someone reads the event history six months from now, they should not need to decode what happened; the event name should tell them directly.

Here is the event set for the subscription aggregate:

public record SubscriptionCreated(SubscriptionId SubscriptionId, CustomerId CustomerId, PlanId PlanId);
public record TrialStarted(SubscriptionId SubscriptionId, DateTimeOffset EndsAt);
public record TrialConverted(SubscriptionId SubscriptionId, PlanId PlanId, DateTimeOffset ConvertedAt);
public record PlanUpgraded(PlanId PreviousPlan, PlanId NewPlan);
public record PlanDowngraded(PlanId PreviousPlan, PlanId NewPlan, string Reason);
public record PaymentFailed(SubscriptionId SubscriptionId, string Reason, DateTimeOffset FailedAt);
public record SubscriptionPaused(SubscriptionId SubscriptionId, DateTimeOffset PausedAt);
public record SubscriptionResumed(SubscriptionId SubscriptionId, DateTimeOffset ResumedAt);
public record SubscriptionCancelled(string Reason, DateTimeOffset CancelledAt);
public record SubscriptionReactivated(PlanId PlanId, DateTimeOffset ReactivatedAt);

Notice what these events do not contain: computed values, status fields derived from other state, formatted strings. Each event records the minimum facts needed to describe what happened. The current status of the subscription is not stored on the subscription; it is a conclusion you draw by replaying these events in order.

The contrast with SubscriptionStatusUpdated(Status: "cancelled") is worth sitting with. That collapsed event tells you what state the system moved to, but not why, not from what, and not with what context. Two cancellations with different reasons look identical in the stream. That information is gone forever. The named, intent-carrying event keeps it.

A common aggregate boundary mistake: invoices inside the subscription

A natural first instinct when modelling this domain is to place invoice events inside the subscription aggregate. InvoiceGenerated, InvoicePaid, InvoiceOverdue all raised on the subscription stream. It feels reasonable: an invoice is caused by a subscription, so surely it belongs to the subscription?

The problem becomes clear as the aggregate grows.

Invoices have their own identity. An invoice for a renewal cycle is a distinct business entity. It can be issued, paid, disputed, voided, reissued. It has a lifecycle that is separate from whether the subscription itself is active. Different parts of the system need to reason about invoices independently: the billing service cares about payment status, the support team cares about dispute history, the finance team cares about revenue recognition. None of those consumers need to load the entire subscription event stream to get the invoice facts.

When the Subscription aggregate carries invoice events, the Apply methods end up doing two different jobs: rebuilding subscription lifecycle state, and rebuilding invoice state. That is the signal. When an aggregate is doing two different jobs, the boundary is drawn in the wrong place.

The corrected model separates them. The invoice events, for reference:

public record InvoiceIssued(InvoiceId InvoiceId, SubscriptionId SubscriptionId, Money Amount, DateTimeOffset DueDate);
public record InvoicePaid(InvoiceId InvoiceId, DateTimeOffset PaidAt);
public record InvoiceDisputed(InvoiceId InvoiceId, string Reason, DateTimeOffset DisputedAt);

And the Invoice aggregate:

public class Invoice
{
private InvoiceId _invoiceId;
private InvoiceStatus _status;
private Money _amount;
private SubscriptionId _subscriptionId;
public static Invoice Issue(InvoiceId id, SubscriptionId subscriptionId, Money amount, DateTimeOffset dueDate)
{
var invoice = new Invoice();
invoice.Apply(new InvoiceIssued(id, subscriptionId, amount, dueDate));
return invoice;
}
public void MarkPaid(DateTimeOffset paidAt)
{
if (_status == InvoiceStatus.Paid)
throw new InvalidOperationException("Invoice is already paid.");
Apply(new InvoicePaid(_invoiceId, paidAt));
}
public void Dispute(string reason)
{
if (_status != InvoiceStatus.Issued)
throw new InvalidOperationException("Only issued invoices can be disputed.");
Apply(new InvoiceDisputed(_invoiceId, reason, DateTimeOffset.UtcNow));
}
private void Apply(InvoiceIssued e) { _invoiceId = e.InvoiceId; _status = InvoiceStatus.Issued; _amount = e.Amount; _subscriptionId = e.SubscriptionId; }
private void Apply(InvoicePaid e) => _status = InvoiceStatus.Paid;
private void Apply(InvoiceDisputed e) => _status = InvoiceStatus.Disputed;
}

Invoice is its own aggregate, with its own stream, with SubscriptionId as a reference, not a parent. The subscription stream tells the subscription’s story. The invoice stream tells the invoice’s story. They are linked by a shared identifier, not by nesting.

The events are all still there. The two aggregates can be replayed independently, projections can consume from either or both, and each model is simpler for having a single concern. That is one of the things that makes event sourcing forgiving when you get the model wrong: the facts do not disappear. You can remodel, replay, rebuild.

What derived state looks like, and what it does not

The subscription aggregate’s current state is entirely derived by replaying events through Apply methods. You should never be storing computed values on events. Things like SubscriptionActiveForNDays, formatted plan names, or running totals belong in projections, not in events.

Here is a straightforward read model projection that builds a billing summary across both event streams:

public class BillingSummaryProjection
{
public SubscriptionId SubscriptionId { get; private set; }
public PlanId CurrentPlan { get; private set; }
public SubscriptionStatus Status { get; private set; }
public List<InvoiceSummary> Invoices { get; } = new();
public void Apply(SubscriptionCreated e) { SubscriptionId = e.SubscriptionId; CurrentPlan = e.PlanId; }
public void Apply(TrialConverted e) { Status = SubscriptionStatus.Active; CurrentPlan = e.PlanId; }
public void Apply(PlanUpgraded e) => CurrentPlan = e.NewPlan;
public void Apply(SubscriptionCancelled e) => Status = SubscriptionStatus.Cancelled;
public void Apply(InvoiceIssued e) =>
Invoices.Add(new InvoiceSummary(e.InvoiceId, e.Amount, InvoiceStatus.Issued, e.DueDate));
public void Apply(InvoicePaid e)
{
var invoice = Invoices.FirstOrDefault(i => i.InvoiceId == e.InvoiceId);
if (invoice is not null) invoice.Status = InvoiceStatus.Paid;
}
}

The projection consumes events from both streams. It computes what the read model needs. The events themselves stay clean: facts only, no derived state, no formatting, no caching of totals that will be computed downstream anyway.

Conclusion

Domain modelling with event sourcing is a design skill before it is a technical one. The events you choose, the boundary you draw around your aggregate, the names you give to things: these decisions compound over time. A poorly named event becomes a source of confusion for every engineer who reads the history twelve months from now. A misdrawn aggregate boundary means your stream carries two different concerns, and untangling it costs real time.

The good news is that event sourcing makes mistakes survivable. If you get the boundary wrong, the events are still there. You can remodel, replay, rebuild. The history is not lost. That is one of the underappreciated strengths of the pattern: you are not locked into the model you started with, because the raw facts are always recoverable.

If you want to build on this kind of model without managing the infrastructure yourself, that is exactly what Hapnd is for. The beta is open at hapnd.dev. Push your reducers and projections; Hapnd handles the storage, streaming, and scaling.