Object-Oriented C# · Lesson 26

An aggregate that owns its mutations

Protect a small cart’s invariants and return independent report snapshots.

How it works

An aggregate groups related state behind operations that preserve its rules. Instead of exposing a mutable dictionary and expecting every caller to update it correctly, a cart can own that dictionary and provide a focused Add operation. The method validates inputs before changing state, so successful operations maintain the model's invariants.

Encapsulation also applies to reads. A caller may need a current quantity, a total, or a report snapshot without needing direct access to the internal dictionary. Returning prepared immutable strings in a new array is a simple snapshot for this example. A larger application may use immutable line records, but the ownership principle is the same: expose what consumers need without handing them unrestricted mutation of the model.

Walk through the example. The cart accepts two A units and one B unit, then creates a sorted snapshot. Adding three more A units changes the live cart's total quantity to six and its current A quantity to five. The earlier snapshot still reports A with two and B with one. An attempt to add zero units throws before mutation, leaving the total unchanged at six.

Watch the boundary. A private field does not help if a public property returns that same mutable dictionary. Likewise, a snapshot containing mutable line objects can still share their internal state. Check the complete public surface and the depth of each copy. Validate all fields before any update so a rejected operation does not leave a partially modified aggregate.

Practice independently. Add a Remove operation that rejects removing more units than are present and removes an empty line when its quantity reaches zero. Test valid removal, excessive removal, an unknown code, and a final empty cart. Preserve earlier snapshots through every mutation. Finally separate the report's display ordering from the cart's identity comparer so presentation changes cannot alter which products count as the same line.

A working example

using System;
using System.Collections.Generic;
using System.Linq;
using System.Globalization;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.RegularExpressions;

var cart = new Cart();
cart.Add("A", 2); cart.Add("B", 1);
string[] snapshot = cart.Snapshot();
cart.Add("A", 3);
Console.WriteLine(cart.TotalQuantity);
Console.WriteLine(cart.Quantity("A"));
Console.WriteLine(string.Join(",", snapshot));
try { cart.Add("B", 0); }
catch (ArgumentOutOfRangeException) { Console.WriteLine("rejected"); }
Console.WriteLine(cart.TotalQuantity);
sealed class Cart
{
    private readonly Dictionary<string, int> quantities = new(StringComparer.Ordinal);
    public int TotalQuantity => quantities.Values.Sum();
    public int Quantity(string code) => quantities.TryGetValue(code, out int value) ? value : 0;
    public void Add(string code, int quantity)
    {
        if (string.IsNullOrWhiteSpace(code)) throw new ArgumentException("Code required", nameof(code));
        if (quantity <= 0) throw new ArgumentOutOfRangeException(nameof(quantity));
        quantities[code] = Quantity(code) + quantity;
    }
    public string[] Snapshot() => quantities.OrderBy(x => x.Key, StringComparer.Ordinal).Select(x => $"{x.Key}:{x.Value}").ToArray();
}

Expected output

6
5
A:2,B:1
rejected
6
Try this
Keep mutations behind validated operations, and expose independent snapshots for stable reports.