i need to learn about the double entry ledger in accounting. I need to understand how accounts work from a theory and practical way. And eventually point it to building accounting software, and Martin Fowler Accounting Patterns.
I recently got a job at a company that wants me to work on payment reconciliation for a letting company that handles apartments. Manages tenant payments, vendors and contractors payments, utilities, and payment to the owner.
I have created a learning path to guide you from core double-entry accounting theory all the way to mapping real property letting transactions into Martin Fowler's software design patterns.
We can easily adjust this plan to spend more or less time on the basics depending on your current background. To make sure we pitch things at the perfect level for your payment reconciliation project, let me ask: How comfortable are you with basic accounting concepts like debits, credits, and balance sheets, or would you prefer we start from scratch before diving into the software architecture?
I know a little bit but let's do a quick refresher of the accounting equation and debit/credit rules first.
| Account Type | Normal Balance | To Increase (+) | To Decrease (-) |
|---|---|---|---|
| Asset | Debit (Left) | Debit | Credit |
| Expense | Debit (Left) | Debit | Credit |
| Liability | Credit (Right) | Credit | Debit |
| Equity | Credit (Right) | Credit | Debit |
| Revenue | Credit (Right) | Credit | Debit |
Let's do a quick check to see how these rules feel to you before we map them into property management trust accounts and Martin Fowler's domain objects.
If a letting company pays $500 cash to a contractor for an apartment repair, how would this transaction be recorded under double-entry rules?
Debit Expense account for $500, and Credit Asset (Cash) account for $500
I have initiated a diagram to help visualize this exact flow of funds. The trust boundary is a vital architectural requirement when designing your database schema and reconciliation engine.
| Reconciliation Source | What It Represents | Source of Truth |
|---|---|---|
| Bank Statement | Actual cash sitting in the bank account | External Bank API / Statement |
| General Ledger Balance | Total balance of the Trust cash account | Internal General Ledger |
| Sum of Sub-Ledgers | Total cash owed to all tenants & owners combined | Sum of Individual Tenant & Owner Balances |
Why is a three-way reconciliation harder to program than a two-way bank reconciliation?
| Challenge | Two-Way Reconciliation | Three-Way Reconciliation |
|---|---|---|
| Data Sources | 2 sources (Bank Statement + General Ledger) | 3 sources (Bank + General Ledger + Sub-Ledgers) |
| Dimensionality | One-to-one transactional timeline match | One-to-many-to-many relationship mapping |
| Primary Cause of Desync | Timing differences (e.g., uncleared checks) | Timing differences + unallocated receipts + sub-ledger sync bugs |
| Database Integrity | Simple transactional logging | Complex relational invariants across distinct domain models |
strict constraint boundaries
what is this
| Boundary Layer | How It Enforces Invariants | Real-World Example |
|---|---|---|
| Database Schema | Foreign keys, unique constraints, and check constraints | Prevent a ledger entry from being created without a valid, existing AccountID |
| ACID Transactions | All-or-nothing database operations | Ensure a tenant payment debit to Cash and credit to Tenant Receivable both succeed or both roll back |
| Domain Model | Encapsulated business rules in objects | A Transaction object that throws an exception upon save if sum of debits does not equal sum of credits |
| Network/Isolation | Distinct database schemas or ledger bounds | Strict physical or logical isolation preventing trust cash from being written to operating cash accounts |
Continue to Martin Fowler's Accounting Patterns domain model
AccountingTransaction acts as the safety coordinator. When you create a transaction—such as recording a tenant's rent payment—the transaction object ensures that the sum of all its entries is exactly zero.| Adjustment Pattern | Core Mechanism | Pros | Cons |
|---|---|---|---|
| Reversal Adjustment | Post an equal and opposite transaction to zero out the error, then post the correct one | Perfect audit trail; extremely simple database writes | Clutters the ledger with 'canceling' entries |
| Replacement Adjustment | Link the old transaction to a new, corrected transaction | Keeps current ledger state clean and readable | Complex database relation tracking; harder to write queries for historic states |
Can you show me a concrete code example of how these objects are constructed?
export class Account {
readonly id: string;
readonly name: string;
private readonly entries: AccountingEntry[] = [];
constructor(id: string, name: string) {
this.id = id;
this.name = name;
}
// Fowler's primary rule: balance is derived by summing historical entries
getBalance(): number {
return this.entries.reduce((sum, entry) => sum + entry.amount, 0);
}
// Package-private helper used by the Transaction coordinator
addEntry(entry: AccountingEntry): void {
this.entries.push(entry);
}
getEntries(): readonly AccountingEntry[] {
return Object.freeze([...this.entries]);
}
}
export class AccountingEntry {
readonly amount: number; // Positive for Debit, Negative for Credit
readonly date: Date;
readonly account: Account;
constructor(amount: number, date: Date, account: Account) {
this.amount = amount;
this.date = Object.freeze(new Date(date.getTime()));
this.account = account;
Object.freeze(this);
}
}
export class AccountingTransaction {
readonly id: string;
readonly date: Date;
readonly entries: readonly AccountingEntry[];
private isPosted = false;
constructor(id: string, date: Date, builder: (tx: AccountingTransactionBuilder) => void) {
this.id = id;
this.date = Object.freeze(new Date(date.getTime()));
const txBuilder = new AccountingTransactionBuilder(this);
builder(txBuilder);
this.entries = Object.freeze(txBuilder.build());
this.validateBalanceInvariant();
}
private validateBalanceInvariant(): void {
const sum = this.entries.reduce((acc, entry) => acc + entry.amount, 0);
// Handle floating-point arithmetic safety in software engineering
if (Math.abs(sum) > 0.0001) {
throw new Error(`Transaction Unbalanced: Sum of entries must be 0. Current sum is 💲{sum}`);
}
}
post(): void {
if (this.isPosted) throw new Error("Transaction already posted");
for (const entry of this.entries) {
entry.account.addEntry(entry);
}
this.isPosted = true;
}
}
class AccountingTransactionBuilder {
private readonly transaction: AccountingTransaction;
private readonly entries: AccountingEntry[] = [];
constructor(transaction: AccountingTransaction) {
this.transaction = transaction;
}
addEntry(amount: number, account: Account): this {
const entry = new AccountingEntry(amount, this.transaction.date, account);
this.entries.push(entry);
return this;
}
build(): AccountingEntry[] {
if (this.entries.length < 2) {
throw new Error("A transaction must contain at least two entries");
}
return this.entries;
}
}
// Initialize our permanent accounts
const trustCashAccount = new Account("ACT-001", "Trust Cash");
const landlordPayableAccount = new Account("ACT-002", "Landlord Payable - Owner A");
// Create and post the transaction
const rentPaymentTx = new AccountingTransaction(
"TX-100",
new Date(),
(builder) => {
builder
.addEntry(1500, trustCashAccount) // Debit Cash +$1500
.addEntry(-1500, landlordPayableAccount); // Credit Liability -$1500
}
);
rentPaymentTx.post();
console.log(trustCashAccount.getBalance()); // Output: 1500
console.log(landlordPayableAccount.getBalance()); // Output: -1500
readonly properties and runtime JavaScript Object.freeze() calls. If a developer attempts to modify an account entry's amount after construction, the runtime throws an error. This immutability ensures a complete and tamper-proof audit log.// 1. Post a perfect Reversal Adjustment to cancel the mistake
const reversalTx = new AccountingTransaction(
"TX-100-REV",
new Date(),
(builder) => {
builder
.addEntry(-1500, trustCashAccount) // Credit Cash to undo debit
.addEntry(1500, landlordPayableAccount); // Debit Liability to undo credit
}
);
reversalTx.post();
// 2. Post the correct transaction
const correctRentTx = new AccountingTransaction(
"TX-101",
new Date(),
(builder) => {
builder
.addEntry(1400, trustCashAccount)
.addEntry(-1400, landlordPayableAccount);
}
);
correctRentTx.post();

