Skip to content
kc@kumarChandrachooda.com:~$ cd /blog/when-private-fields-become-column-names && read --section="top" 0%
.NET

When Private Fields Become Column Names

Mapping a fully encapsulated aggregate to relational tables with Property of T of a string - eight tables for one aggregate, eighteen columns, seventeen of them named after private C# fields.

By Kumar Chandrachooda 01 Dec 2025 6 min read
Column headers each carrying a leading underscore glyph

Part 7 left Sales with three tables, forty lines of mapping code, and no way to ask the database a question. Postsale takes the other road entirely, and the bill arrives in a form you can read straight off \d postsale."ChangeRequests".

The aggregate being mapped is the one from Part 4: ten private fields, one public Id, no getters. EF Core has to reach all of it, and the only handle it has is a string.

Four mapping mechanisms in one file

// src/Postsale/.../Data/EF/Configs/ReservationChangeRequestConfig.cs:13-30
public void Configure(EntityTypeBuilder<ReservationChangeRequest> builder)
{
    builder.HasKey(x => x.Id);
    builder.HasOne<ReservationToChange>("_reservationToChange");
    builder.Property<DateTime>("_newTravelDate");

    builder.Property<UserId>("_requester")
        .HasConversion<Guid?>(x => x == null ? null : x.Value,
            x => x == null ? null : new (x.Value))
        .IsRequired();

    builder.Property<bool>("_isFeasible");
    builder.OwnsOne<RequiredPayment>("_paymentRequiredToApplyChange", p =>
    {
        p.OwnsOne(x => x.Deadline);
    });

Four distinct techniques appear here, and they are the module's teaching content.

Property<T>("_field") maps a private field that has no property at all. _newTravelDate, _isFeasible, _isActive, _completionStatus all arrive this way. Nothing about the C# type declares that these are persisted; the fact lives entirely in a string in a different assembly.

HasConversion for a wrapper value object. UserId is a record UserId(Guid Value), and the converter round-trips it to a nullable Guid column with explicit null handling on both legs. This is the standard answer to the strongly-typed-id problem, and it is worth noting that Sales — which invented more strongly-typed ids than Postsale, with OfferId, ReservationId, AirlineOfferId and DeadlineId — never needs a converter, because it serialises the ids into a document instead.

OwnsOne / OwnsMany on a private field name, nesting three levels deep. _newCost is a ReservationCost which owns a TotalCost Money which owns Amount and Currency, flattening into _newCost_TotalCost_Amount and its siblings.

HasOne<T>("_field") for a real child entity, giving _reservationToChange and _changeToApply their own tables and foreign keys.

The fiddliest passage is the flight segment mapping, where the nesting reaches four levels — aggregate, owned collection, owned reference, converted value object:

// ReservationChangeRequestConfig.cs:49-69
builder.OwnsMany<FlightSegment>("_newTravel", t =>
{
    t.OwnsOne(x => x.FlightTime);
    t.Property(x => x.Date).IsRequired();
    t.OwnsOne(x => x.SourceAirport, a =>
    {
        a.Property(x => x.Code)
            .HasConversion(x => x.Value,
                x => new(x))
            .IsRequired();
    });
    t.OwnsOne(x => x.TargetAirport, a =>
    {
        a.Property(x => x.Code)
            .HasConversion(x => x.Value,
                x => new(x))
            .IsRequired();
    });
    t.OwnsOne(x => x.FlightTime);
});

Note lines 51 and 67. t.OwnsOne(x => x.FlightTime) appears twice. EF's builder is idempotent so it compiles and behaves identically, but it is a copy-paste artefact — and the identical twenty-line block, duplicated line included, repeats verbatim in ReservationChangeToApplyConfig and ReservationToChangeConfig. Three copies of one flight-segment mapping. An OwnsFlightSegments<TOwner>() helper would have collapsed all three.

Nothing configures the backing-field access mode

There is no UsePropertyAccessMode, no HasField, no [BackingField] attribute anywhere in Postsale. The mapping works because EF Core 6 defaults to PropertyAccessMode.PreferFieldDuringConstruction, and because Property<T>("_name") matching a field binds the field directly.

That is convenient and it is undocumented in the code. A future upgrade that changed the default, or a rename of _isActive to _open, would be caught — at model-build time, loudly, at startup. That is genuinely better than Sales' failure mode, where the same rename changes the JSON shape silently. Postsale's magic strings fail fast; Sales' field names fail invisibly. Neither has a compiler helping.

And the escape hatch is the same one. ReservationChangeRequest has a private parameterless constructor at line 25, and that is what EF materialises through, writing private fields directly. Reconstitution bypasses every invariant here exactly as it does in Sales. Same hole, different door.

Eight tables for one aggregate

The Postsale_Initial migration creates eight tables:

postsale."ChangeRequests"
postsale."ChangesToApply"
postsale."ReservationSnapshots"
postsale."ChangeRequests__newTravel"
postsale."ChangesToApply_NewTravelSegments"
postsale."PaymentDeadlineChange"
postsale."ReservationSnapshots_CurrentPayments"
postsale."ReservationSnapshots_CurrentTravel"

Eight tables, one aggregate root, two supporting entities. Sales stored four aggregate roots in two tables. The 3-versus-8 ratio is the whole persistence comparison in one number, and it costs about 190 lines of mapping code across four configs against Sales' 40.

Then the main table:

-- postsale."ChangeRequests", from Postsale_Initial
Id                                                uuid    not null
_changeToApplyId                                  uuid
_completionStatus                                 integer
_isActive                                         boolean not null
_isFeasible                                       boolean not null
_newTravelDate                                    timestamp without time zone not null
_requester                                        uuid    not null
_reservationToChangeReservationId                 uuid
_newCost_TotalCost_Amount                         numeric
_newCost_TotalCost_Currency                       integer
_newCost_RefundableCost_Amount                    numeric
_newCost_RefundableCost_Currency                  integer
_newCost_Id                                       uuid
_paymentRequiredToApplyChange_PaymentId           uuid
_paymentRequiredToApplyChange_Deadline_Id         uuid
_paymentRequiredToApplyChange_Deadline_DueDate    timestamp without time zone
_paymentRequiredToApplyChange_Deadline_Fulfilled  boolean
_paymentRequiredToApplyChange_Payed               boolean

Eighteen columns. Seventeen of them carry a leading underscore, because seventeen of them are named after a private C# field. Id is the only one a DBA would recognise as a business concept.

That is the sharpest single consequence of encapsulation plus relational mapping, and it deserves to be stated bluntly: renaming _isActive to _open is a breaking database migration. The whole point of making a field private was to keep it changeable without consulting anyone. Instead it has been published — to the schema, to the backup, to the DBA, to whatever reporting tool someone points at this database next year, and to the read model in Part 9, which selects those columns by name.

Encapsulation was supposed to hide implementation detail. Here it exported it one layer further out than public properties would have.

Where persistence bent the model back

Now the direction that matters most, because it is easy to miss: the mapping did not merely reflect the model. It changed it.

Look at _newCost_Id in that column list. Postsale's ReservationCost is a value object — a total cost and a refundable cost — and value objects have no identity by definition. Its Sales twin, NewTotalCost, is a two-member record with no id at all. Postsale's version carries a Guid Id, and the domain service dutifully mints one when mapping in:

new ReservationCost(reservationDto.CurrentCost.TotalCost, reservationDto.CurrentCost.RefundableCost, Guid.NewGuid())

That identity exists because OwnsOne wanted something to key on. A persistence concern reached back into a value object and gave it an identity it has no business having, and you can see the whole story in a two-line diff against the Sales copy.

It is not the only case. FlightTime's getters became { get; private set; } in Postsale where Sales has { get; } — changed purely so EF could materialise them. Several value objects grew private parameterless constructors for the same reason. Part 17 walks all sixteen duplicated concepts, and this is the pattern that keeps recurring: where the twins differ, persistence is usually why.

To be fair to the choice: relational mapping bought Postsale things Sales cannot have. ExistsActiveForGivenUser is a single AnyAsync translated to SQL. GetByPaymentId and GetByDeadlineId filter on nested owned-type columns and return one row. Sales needed a promoted column, a side table and an in-memory dictionary to answer those same three questions. Postsale's correlation problems are solved by the database; Sales' are solved by hand. That is a real and substantial win, and it is the reason the eight tables are not simply overhead.

The scorecard, third entry

Sales — jsonb Postsale — encapsulated relational
Tables 3 8
Mapping code ~40 lines, no domain knowledge ~190 lines, deep domain knowledge
Model shape change no migration migration
Query by non-key promoted column / side table / dictionary native EF.Property<> predicates
Invariants on reconstitution none none
Rename a private field silent JSON drift startup failure, and a schema migration
Concurrency control Version token none
Audit columns yes none
The database reveals one opaque document your private field names

Next, CQRS arrived as a consequence — because the write model exposes nothing, the read side could not go through it, so a second POCO and a second DbContext were mapped onto the same physical table.