Skip to content
kc@kumarChandrachooda.com:~$ cd /blog/the-aggregate-as-one-column && read --section="top" 0%
.NET

The Aggregate as One Column

Sales stores four aggregate roots and twenty-five value objects in three tables, by serialising whole aggregates into jsonb - and the reflection resolver that makes reconstitution work bypasses every invariant on the way in.

By Kumar Chandrachooda 30 Nov 2025 6 min read
A table row whose single cell holds a nested brace structure

The first thing that strikes you about Sales' Sales_Initial migration is how short it is. Four aggregate roots. Roughly twenty-five value objects. Nine domain events, two lifecycle transitions, a fee policy and six specifications. And three tables: Offers, Reservations, DeadlineRegistryEntries.

Part 5 closed the design half of this series. This is where the persistence arc opens, and the three strategies get scored against each other over the next five parts. Sales goes first because it is the most aggressive: an aggregate is one row, and the row has one meaningful column.

Three columns and a discriminator

// src/Sales/GroupFlights.Sales.Infrastructure/Data/Models/OfferDbModel.cs:12-19
internal class OfferDbModel : DbModel
{
    public static readonly string OfferDraftType = nameof(OfferDraft);
    public static readonly string OfferType = nameof(Offer);

    public Guid Id { get; set; }
    public string Type { get; set; }
    public string Object { get; set; }
}
// src/Sales/.../Data/EF/Configs/OfferDbModelConfiguration.cs:9-15
builder.HasKey(x => x.Id);
builder.Property(x => x.Version).IsConcurrencyToken();
builder.Property(x => x.Object).HasColumnType("jsonb").IsRequired();
builder.Property(x => x.Type).IsRequired();

That is the entire mapping. Fifteen lines of IEntityTypeConfiguration across three files — call it forty lines of mapping code for the whole module — and none of it knows a single thing about the domain. There is no OwnsOne, no HasConversion, no Property<T>("_field"). The persistence layer knows there is a Guid, a string discriminator and a document.

The discriminator does real work. Recall Part 3: OfferDraft becomes Offer, UnconfirmedReservation becomes Reservation. Those are different classes sharing an identity, so one table per aggregate family with a Type column is exactly the right shape — the lifecycle transition becomes existingDraft.Type = OfferDbModel.OfferType; and one row survives, keeping its id. The design of the model and the design of the schema line up cleanly here, and that is not an accident.

The base class adds three more columns to every row: a Version GUID marked IsConcurrencyToken(), plus CreateDateUtc and UpdateDateUtc. Sales is the only module in the estate with either optimistic concurrency or audit columns. Postsale has neither.

The serializer, and the note in it

Round-tripping a rich aggregate through JSON is where the work moved. Domain models do not have public setters — that is rather the point of them — so a general-purpose serializer will read them and refuse to write them back.

// src/Sales/.../Data/Json/ComplexObjectSerializer.cs:8-44
internal static class ComplexObjectSerializer
{
    //NOTE: To rozwiazanie tworzy nadmiarowe node'y w JSONie z suffixem "k__BackingField";
    //Sa dwie znane mi opcje rozwiazania tego: atrybut [DataMember] na modelu lub czyszczenie JSONa recznie.
    public static string SerializeToJson<T>(T obj)
    {
        var settings = new JsonSerializerSettings();
        settings.ConstructorHandling = ConstructorHandling.AllowNonPublicDefaultConstructor;
        settings.ContractResolver = new ContractResolverWithPrivates();

        return JsonConvert.SerializeObject(obj, settings);
    }
    …
    internal class ContractResolverWithPrivates : DefaultContractResolver
    {
        protected override IList<JsonProperty> CreateProperties(Type type, MemberSerialization memberSerialization)
        {
            var props = type.GetProperties(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance)
                .Where(p => p.CanWrite)
                .Select(p => base.CreateProperty(p, memberSerialization))
                .Union(type.GetFields(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance)
                    .Where(p => p.Name != "_domainEvents")
                    .Select(f => base.CreateProperty(f, memberSerialization)))
                .ToList();
            props.ForEach(p => { p.Writable = true; p.Readable = true; });
            return props;
        }
    }
}

Read it slowly, because every line here costs something later.

  • AllowNonPublicDefaultConstructor tells Newtonsoft it may call a private parameterless constructor. That is why sixteen types in Sales.Domain carry one — Offer.cs:21, OfferDraft.cs:19, Reservation.cs:22, Client.cs:13, FlightTime.cs:10, PassengersData.cs:10 and ten more. Those constructors have no domain meaning at all. They exist for the serializer, and a reader who does not know that will spend real time wondering what a parameterless Passenger is supposed to represent.
  • Properties and fields are collected, public and non-public, because a rich model holds most of its state in private backing collections.
  • props.ForEach(p => { p.Writable = true; p.Readable = true; }) is the decisive line. Every member of the object graph is forced writable, whatever the C# declaration said. { get; } becomes settable. readonly becomes settable.
  • _domainEvents is excluded by string name. Rename that field in DomainEventsSource and the pending event queue starts being persisted into jsonb, silently, with no round-trip test anywhere in the estate to notice.

And the //NOTE: at the top, translated: "This solution creates redundant nodes in the JSON with the suffix k__BackingField. There are two options I know of for solving it: a [DataMember] attribute on the model, or cleaning the JSON manually."

That is the author documenting a known defect in the persisted format, naming both fixes, and shipping neither. Auto-property backing fields are collected by the field pass and the corresponding properties by the property pass, so every auto-property lands in the document twice under two names. It works — Newtonsoft resolves both on the way back — but the stored representation is roughly double what it needs to be, and it embeds a C# compiler naming convention in a Postgres column.

To be fair, the note is the honest move. A comment that says “I know, here are the two fixes” is worth more to a reader than silence, and this is a teaching repository where the reader is the point.

Reconstitution bypasses every invariant

Put the two settings together and the consequence is unavoidable.

PassengersData refuses to be constructed with fewer than ten passengers. FlightTime rejects minutes above sixty. Email validates against a regex. OfferDraft throws ArgumentNullException on a null source, client or requested travel. Every one of those rules fires when the domain creates the object.

None of them fire when the database does. The private parameterless constructor is invoked, and then every member — public, private, readonly, computed-backing — is written directly by reflection. A row edited by hand in psql, a document written by an older version of the code, a field renamed in a refactor: all of them deserialise into an object the domain would have refused to build.

Here is the part worth sitting with, because it is easy to score cheaply: Postsale has exactly the same hole. Its aggregate has a private parameterless constructor too (ReservationChangeRequest.cs:25), and EF Core materialises through it, writing private fields directly. Neither strategy validates on reconstitution. Both need a constructor that bypasses the model's own rules, and they reach it by different mechanisms. Whatever else separates document persistence from relational persistence, invariant enforcement on read is not it — that is a property of ORMs and serializers in general, not of this choice.

Where they differ is detectability. Rename a private field in Postsale and the model fails to build at startup, because Property<T>("_isActive") is a string that no longer resolves. Rename a private field in Sales and the JSON shape changes silently; old documents deserialise with that member left at its default, and nothing anywhere says a word.

Concurrency, and a catch block with nothing in it

// src/Sales/.../Data/EF/SalesDbContext.cs:37-58
private void UpdateVersions()
{
    foreach (var change in ChangeTracker.Entries())
    {
        try
        {
            var versionProp = change.Member(nameof(DbModel.Version));
            versionProp.CurrentValue = Guid.NewGuid();

            if (change.State == EntityState.Added)
            {
                var createDateProp = change.Member(nameof(DbModel.CreateDateUtc));
                createDateProp.CurrentValue = DateTime.UtcNow;
            }
            else
            {
                var updateDateProp = change.Member(nameof(DbModel.UpdateDateUtc));
                updateDateProp.CurrentValue = DateTime.UtcNow;
            }
        } catch { }
    }
}

catch { } — no exception type, no filter, no log line, no comment. It is there because DeadlineRegistryEntry is tracked by the same DbContext and does not inherit DbModel, so change.Member(nameof(DbModel.Version)) throws for it on every single save. An exception is being used as a type test, once per registry row per save, and swallowed. change.Entity is DbModel would have been the check.

The second detail: DateTime.UtcNow is called directly, twice, in an estate whose ADR 02 mandates IClock and where thirty files honour it. The one place a test would most want deterministic time — the persisted audit columns — is the one place it cannot have it.

The scorecard, opened

This is the first entry in a running scorecard the next four parts fill in.

Sales — aggregate as jsonb
Tables for the module 3 (two aggregate families plus one registry)
Mapping code 3 configs, ~40 lines, zero domain knowledge
Model shape change no migration required
Invariants on reconstitution none
Refactoring safety field renames silently change the document; one exclusion by string
Concurrency control Version GUID token
Audit columns yes, via DateTime.UtcNow
Query by anything but the key see the next part

That last row is where the strategy is actually decided, and it is not close.

Next, the queries you cannot write — a promoted column, a side table, an in-memory dictionary, and six update methods that look the row up without its id.