Ledger-Core v1.0
Enterprise Financial Ledger Platform
- .NET 10
- C#
- ASP.NET Core
- EF Core
- Azure SQL Edge / SQL Server
- Redis
- RabbitMQ
- Docker
- Azure
- Clean Architecture
- MediatR
- CQRS
- DDD
Trade-off Analysis & Lessons Learned
What Worked
- Clean Architecture and MediatR made every command a testable pipeline without a database
- Append-only entries turned reconciliation into a proof, not a process
- The outbox table made exactly-once publishing a local transaction
- RowVersion OCC with a retry loop handled rare conflicts without pessimistic locks
What Failed
- v0.9 published events synchronously, coupling writes to RabbitMQ availability
- The initial rowversion strategy ignored index pressure and stalled batch writes
- Multi-currency rounding audits pushed full multi-currency support past v1.0
Architectural Iterations
- v0.9 synchronous event publishing replaced by outbox table and background dispatcher in v1.0
- v0.9 pessimistic locking replaced by RowVersion OCC plus retry loop after conflict-rate analysis
- PostgreSQL prototypes replaced by Azure SQL Edge with EF Core 10 after managed-SQL Edge parity testing
Lessons Learned
- Outbox tables turn distributed consistency into a local transaction
- Immutable append-only entries make reconciliation a proof, not a process
- OCC beats pessimistic locking when conflicts are rare but must be handled
Overview
Ledger-Core is an enterprise financial ledger platform built on .NET 10 with EF Core 10 against Azure SQL Edge / Microsoft SQL Server. Every financial entry is appended, never updated or deleted, so the journal is a single append-only log and reconciliation is a proof rather than a process.
The core invariant — "Balances are projections. Ledger entries are truth." — is enforced in the domain layer: balances are never stored, they are recomputed by replaying the entry log, which makes corruption detectable rather than silent. Commands flow through CQRS via MediatR on Clean Architecture, the domain is modeled as DDD aggregates, and events leave the system exactly once through a T-SQL outbox table with Redis idempotency keys on the consumer side. RowVersion optimistic concurrency control keeps conflicting concurrent writes rare-but-handled.
Architecture
The ledger service is a pure command pipeline — validate, apply, append, publish — which keeps the entire flow testable without a database. The diagram below is interactive: switch tabs to move from the system context down to the container and Azure deployment levels, and zoom with the buttons or the mouse wheel.
The Six Invariants
The platform's correctness contract is a closed set of invariants, each one enforced at the domain layer rather than trusted by convention:
- Immutability — ledger entries are append-only. There is no update or delete path; corrections are posted as reversal entries.
- Double-entry balance — every transaction posts a balanced set of legs; the signed sum of debits and credits is always zero.
- Balances are projections — no balance is ever stored as truth. Account balances are recomputed from the entry log on demand.
- Optimistic concurrency — concurrent writes to an aggregate are serialized by RowVersion; a losing writer gets a retriable exception, never a silent overwrite.
- Exactly-once effect boundary — events escape the system exactly once per committed state change, via the outbox table in the same local transaction as the entry.
- Audit provenance — every entry records its account, amount, reference, and causal parent, so the log is replayable from genesis and the audit trail is the product.
The LedgerTransaction Aggregate
The core invariant lives in the domain layer: a LedgerTransaction aggregate
owns LedgerEntry value objects, and entries are append-only:
public sealed class LedgerTransaction : AggregateRoot<Guid>
{
private readonly List<LedgerEntry> _entries = [];
public DateTimeOffset PostedAt { get; private set; }
public string Reference { get; private set; }
public void Post(IReadOnlyList<LedgerLeg> legs)
{
// Invariant 2: every transaction is balanced — signed legs sum to zero.
if (legs.Sum(leg => leg.Amount) != Money.Zero)
throw new LedgerDomainException("Unbalanced transaction rejected.");
// Invariant 1: entries are append-only — never updated, never deleted.
_entries.AddRange(legs.Select(leg => new LedgerEntry(
NextId(), leg.AccountId, leg.Amount, Reference)));
AddDomainEvent(new TransactionPosted(Id, PostedAt, Reference));
}
}
Because entries are immutable, the audit trail is the product: an auditor can replay the log from genesis and independently recompute every account balance. Reconciliation stops being a process and becomes a property of the data model.
RowVersion Optimistic Concurrency
Writes carry a T-SQL rowversion column on the aggregate row. When two
writers race, the loser gets a DbUpdateConcurrencyException and the command
is re-applied against the latest revision within a bounded retry loop:
for (var attempt = 0; attempt < MaxRetries; attempt++)
{
var transaction = await repository.LoadAsync(command.TransactionId, ct);
transaction.Post(command.Legs);
try
{
await repository.SaveAsync(transaction, ct); // rowversion checked
return;
}
catch (DbUpdateConcurrencyException)
{
// Conflict — reload the latest revision and re-apply the command.
}
}
throw new ConcurrencyExhaustedException(command.TransactionId);
v0.9 used pessimistic row locks; conflict-rate analysis showed conflicts are rare, so OCC removed the lock contention that stalled batch writes while keeping the rare conflict path explicitly handled.
T-SQL Outbox Table
Events are never published from the write path. The append transaction writes an outbox row in the same local transaction as the entry, and a background worker dispatches rows to RabbitMQ:
CREATE TABLE dbo.Outbox
(
Id uniqueidentifier NOT NULL PRIMARY KEY,
AggregateId uniqueidentifier NOT NULL,
EventType nvarchar(200) NOT NULL,
Payload nvarchar(max) NOT NULL,
CreatedAt datetime2 NOT NULL DEFAULT SYSUTCDATETIME(),
PublishedAt datetime2 NULL
);
CREATE INDEX IX_Outbox_Pending ON dbo.Outbox (CreatedAt)
WHERE PublishedAt IS NULL;
The worker polls pending rows, publishes to RabbitMQ, and marks
PublishedAt. EF Core 10 maps the outbox via a temporal-safe model — the
table is plain T-SQL with a filtered index, exactly as shown.
Redis Idempotency
Because delivery is at-least-once, every consumer derives an idempotency key from the message identity and stores it in Redis with a short TTL. A publish-then-crash-then-republish cycle is a no-op rather than a duplicate:
// SET if absent, bounded by the replay window.
var applied = await db.StringSetAsync(key, "1", expiry, When.NotExists);
if (!applied)
return; // duplicate delivery — already processed
This is what turned "exactly-once event publishing" from a slogan into a local transaction plus a retry loop.
Validation Metrics
Correctness is verified continuously rather than by inspection: 26/26 xUnit integration tests pass against the full containerized Docker topology — API, worker, Azure SQL Edge store, Redis, and RabbitMQ all running in the same compose network that ships to production. The suite covers the six invariants, OCC conflict paths, outbox redelivery, and idempotent consumer replay.
Deployment Topology
Production runs entirely inside Microsoft Azure: the Web API on an App Service Linux container, the outbox worker on Azure Container Apps with scale-to-zero, Azure SQL Edge for the ledger store and outbox table, Azure Cache for Redis, and RabbitMQ as a Container Apps add-on. Connection strings live in Key Vault; images are pushed by GitHub Actions through Azure Container Registry. See the Deployment tab above for the full topology.
Status
Production at v1.0.0, MIT-licensed and open source. The immutable entry
log keeps balances honest by construction, the T-SQL outbox plus Redis
idempotency covers the exactly-once boundary, and the 26/26 integration
suite is the gate between the ledger and its consumers.