A Setter on the Ledger
Inflow's Wallet guards its transfers behind a private HashSet and then exposes a public setter for it. Assigning an empty list zeroes any balance with no exception, no version bump and no trace - and Entity Framework never needed the setter.
The usual complaint about ORMs in a domain model is that they force you to compromise it — public parameterless constructors, virtual properties, settable ids, a List<T> where you wanted an immutable collection. The complaint is mostly folklore now; modern EF Core will happily materialise a private field behind a read-only property and never touch your invariants. But the folklore outlives the constraint, and the compromises get written anyway, defensively, for a database that stopped asking for them years ago.
Part 5 looked at what a transfer records. This part is about how the wallet holds them, and it is the one place in Inflow's Wallets module where persistence appears to have reached into the domain and taken something.
Four lines
internal class Wallet : AggregateRoot<WalletId>
{
private HashSet<Transfer> _transfers = new();
public OwnerId OwnerId { get; private set; }
public Currency Currency { get; private set; }
public IEnumerable<Transfer> Transfers
{
get => _transfers;
set => _transfers = new HashSet<Transfer>(value);
}
public DateTime CreatedAt { get; private set; }
// ...
}
(src\Modules\Wallets\Inflow.Modules.Wallets.Core\Wallets\Entities\Wallet.cs, branch master.)
Look at what surrounds it. OwnerId, Currency and CreatedAt all have private set. The two mutating methods, AddFunds and DeductFunds, validate, append, and increment the version. The constructor is duplicated — a private parameterless one for the materialiser, a public one for the domain. This is a carefully closed class.
And the collection that holds every penny it owns has a public setter.
wallet.Transfers = new List<Transfer>() sets the balance to zero. No exception, because the setter validates nothing. No version bump, because IncrementVersion() is called from AddFunds and DeductFunds and not from here — so the optimistic concurrency token is unchanged and the write will not be rejected. No event, because this module never publishes domain events. No trace of any kind: the transfer rows are simply orphaned in memory, the graph EF is tracking loses them, and the next SaveChangesAsync writes the consequence.
Wallet is internal, so the blast radius is one module — but Wallets.Core\Extensions.cs carries eight InternalsVisibleTo attributes, Application carries seven and Infrastructure six. Every file in the module, every test project, and one assembly that does not exist can perform that assignment. It is not reachable from outside Wallets and it is reachable from everywhere inside it.
Entity Framework did not ask for this
The usual justification would be “the ORM needs it”. It does not, and the repository contains the proof in its own generated snapshot:
modelBuilder.Entity("...Wallets.Entities.Transfer", b =>
{
b.HasOne("...Wallets.Entities.Wallet", null)
.WithMany("Transfers")
.HasForeignKey("WalletId")
// ...
});
modelBuilder.Entity("...Wallets.Entities.Wallet", b =>
{
b.Navigation("Transfers");
});
(Wallets.Infrastructure\EF\Migrations\WalletsDbContextModelSnapshot.cs.)
WithMany("Transfers") is the string-based overload, which resolves through EF Core's backing-field convention: given a property Transfers, it finds _transfers and writes to the field directly. The materialiser never calls the setter. Delete it and the module still loads wallets, still saves them, still passes its integration tests. It is a defensive compromise against a requirement that has not existed since EF Core 1.1.
The prescription is the textbook one — Encapsulate Collection. Drop the setter, expose IReadOnlyCollection<Transfer>, and let the two methods that are already the only legitimate mutators stay the only mutators:
// Illustrative.
private readonly HashSet<Transfer> _transfers = new();
public IReadOnlyCollection<Transfer> Transfers => _transfers;
One line shorter, and the aggregate's most important invariant — the ledger is append-only — becomes a property of the type rather than a habit of the callers.
The other half of the same line
There is a second problem hiding in the same declaration, and it is quieter.
_transfers is a HashSet<Transfer>. A HashSet gives you set semantics — no duplicates — but only against whatever equality the element type defines. Transfer is an abstract class that overrides neither Equals nor GetHashCode, and so do IncomingTransfer and OutgoingTransfer. So the set falls back to Object's reference equality, and two distinct Transfer instances with the same TransferId are, to this collection, two different elements.
The irony is that the estate does implement value equality — one type away. TypeId, the base class of TransferId, has Equals, GetHashCode, == and != all written out by hand. The type that carries identity knows how to compare itself; the type that has the identity does not, and the set is keyed on the latter.
The practical effect today is nil, because AddFunds and DeductFunds both mint a fresh TransferId and a fresh instance, so a collision cannot arise. The effect is on what the declaration claims. A reader sees HashSet and infers “duplicate transfers are impossible here”; the truth is “duplicate object references are impossible here”, which is a guarantee about memory, not about the ledger. A List<Transfer> would carry the same real guarantee and make no promise it cannot keep. If the set semantics are wanted, the fix is an Equals/GetHashCode pair on Transfer delegating to Id — six lines, and then the collection means what it says.
And the type it is exposed as
One more detail in the same four lines, smaller than the setter and in the same family.
The property type is IEnumerable<Transfer>. That is the weakest useful collection interface: it promises enumeration and nothing else — no count without walking, no indexing, and, in the general case, no guarantee that enumerating twice yields the same elements. Every consumer in the module immediately re-strengthens it. CurrentAmount() calls .OfType<T>().Sum(...) twice over the same sequence. AsDetailsDto calls .Select(...).OrderByDescending(...).ToList(). WalletTests calls .Count() and .Single().
Because the backing field is a materialised HashSet, all of that is safe today — enumerating it repeatedly is cheap and stable. But the declared type is the contract, and the declared type permits a lazily-evaluated sequence. The setter makes that concrete: wallet.Transfers = someQuery.Where(...) compiles, and although the setter's new HashSet<Transfer>(value) happens to force enumeration immediately, that is an implementation detail of a body a caller cannot see.
IReadOnlyCollection<Transfer> says exactly what is true — a stable, counted, non-mutable collection — in the same number of characters. The gap between what a member promises and what it is backed by is where the next reader's assumptions go to die, and this one is closable by changing one word and deleting three lines.
Where the rest of the model gets it right
It matters that this is the only deformation, because the contrast is what makes it worth a whole article.
Everything else in Wallets.Core reaches PostgreSQL without giving anything up. Five value objects (OwnerName, TaxId, TransferName, TransferMetadata, plus the shared Amount and Currency) are mapped with .HasConversion(x => x.Value, x => new T(x)). Two strongly-typed ids go the same way. Two type hierarchies — Owner into individual and corporate, Transfer into incoming and outgoing — are mapped with HasDiscriminator<string>("Type") in the configuration classes, so the domain classes carry no discriminator column, no Type property, no persistence artefact at all. builder.Ignore(x => x.Events) keeps the aggregate-root base class's event list out of the schema. The domain project cannot even reference EF Core; part 1 showed the single ProjectReference that guarantees it.
That is a genuinely clean persistence story, and it is the reason the setter reads as an anomaly rather than a house style. A model that has resisted the database in eleven places and yielded in one has yielded to a habit, not to a constraint.
The habit is worth naming, because it is the durable lesson here rather than the specific line: if you are adding a member to a domain type for the benefit of a framework, check whether the framework still needs it. The mapping conventions that once forced public setters have been able to write private fields for the better part of a decade. Most of the compromises in most models I read are inherited from a version of the tooling nobody in the room has used.
There is one more piece of the aggregate base class left, and it is the field that the setter conspicuously fails to touch. Next, the version that counts to two.