A backend service for recording and managing share transfers between investors with a strong focus on data integrity, auditability, and correctness under concurrency.
This service simulates the core responsibility of a Transfer Agent:
ensuring that ownership of shares is tracked accurately, immutably, and transparently.
Key guarantees:
- Investors cannot transfer shares they do not own
- All operations are fully auditable
- The system maintains a complete history, including failed attempts
- Data is protected against race conditions and double-spending
- Node.js (NestJS)
- MongoDB (Mongoose)
git clone https://github.com/alextondello/ledger-service.git
cd ledger-serviceyarndocker compose up -d
yarn migrateMONGO_URI=mongodb://root:rootpass@localhost:27017/app_dbyarn start:dev- POST /asset - Create a new asset
- Payload:
{ "symbol": "string", "total_supply": "number" }
- Payload:
- GET /asset/:symbol - Get asset by symbol
- POST /balance/initial - Create initial balance for an investor
- Payload:
{ "investor": "string", "asset_symbol": "string" }
- Payload:
- GET /balance/investor/:investorId/asset/:assetId - Get investor balance for specific asset
- POST /investor - Create a new investor
- Payload:
{ "name": "string", "document_id": "string" }
- Payload:
- GET /investor/:id - Get investor by ID
- GET /ledger/investor/:investorId/asset/:assetSymbol - Get ledger entries for investor and asset
- POST /transfer - Execute a transfer between investors
- Payload:
{ "from_investor": "string", "to_investor": "string", "asset_symbol": "string", "amount": "number", "idempotency_key": "string" }
- Payload:
- GET /transfer/:id - Get transfer by ID
Represents the share/asset being transferred.
{
"symbol": "BTC",
"total_supply": 21000000
}Stores investor identity:
{
"name": "Alice",
"document_id": "PassportNumber"
}Each transfer generates:
- 1 DEBIT entry (sender)
- 1 CREDIT entry (receiver)
{
"transfer_id": "ObjectId",
"type": "DEBIT | CREDIT",
"investor": "PassportNumber",
"asset_symbol": "BTC",
"amount": 100,
"balance_after": 900
}Represents the business-level operation:
{
"from_investor": "PassportNumber",
"to_investor": "PassportNumber",
"asset_symbol": "BTC",
"amount": 100,
"status": "SUCCESS | FAILED",
"failure_reason": "INSUFFICIENT_BALANCE | SELF_TRANSFER | UNKNOWN",
"idempotency_key": "IDEM123"
}{
"investor": "PassportNumber",
"asset_symbol": "BTC",
"balance": 1000
}
⚠️ Balances are not the source of truth.
They are a cached representation derived from the ledger.
The system is built around an append-only ledger:
- No updates
- No deletions
- Full historical trace
Balances are derived from ledger entries.
- Transfers are never modified
- Failed transfers are recorded
- Corrections must be done via new transactions, not edits
Each request uses an Idempotency-Key:
- Prevents duplicate transfers on retries
- Stores and replays previous responses
To prevent double-spending:
- Transfers run inside MongoDB transactions
- Balance updates use atomic conditional updates
Example:
db.balances.updateOne(
{
investor_id,
asset_id,
balance: { $gte: amount },
},
{
$inc: { balance: -amount },
},
);If no document is updated → insufficient funds
- Amount must be greater than zero
- Sender and receiver must be different
- Sender must have sufficient balance
- All transfer attempts are recorded (success or failure)
- Double-spending under concurrency
- Duplicate requests (idempotency)
- Self-transfers
- Invalid amounts (zero/negative)
- Missing investors or assets
- Partial failures (handled via transactions)
This system enables:
- Every transfer is recorded
- Every ledger movement is preserved
Balances can be recomputed using:
SUM(CREDITS) - SUM(DEBITS)
- Failed transfers are explicitly stored
- Includes failure reasons
- Improves performance for reads and validations
- Trade-off: requires strict consistency handling
Pros:
- Flexible schema
- Native transaction support
Cons:
- Not as strict as relational systems for financial ledgers
This design already follows event sourcing principles, simplified for clarity and speed of implementation.
- Single-currency / integer-based shares
- No fractional shares
- No external settlement system
- Trusted internal API (no authentication layer implemented)
This implementation prioritizes:
- Correctness over convenience
- Auditability over simplicity
- Safety over performance shortcuts
The design intentionally avoids common pitfalls such as:
- mutable balances without history
- lack of concurrency protection
- missing audit trail for failures
With more time, I would focus on:
- Expanding unit test coverage, especially end-to-end tests
- Implementing event streaming (using the outbox pattern) for external audit systems
- Improving indexing and pagination strategies
- Adding role-based access control (e.g., admin and auditor roles)
- Introducing rate limiting and abuse protection
- Building reconciliation jobs to verify ledger consistency against balances
- Supporting multi-asset atomic transfers
- Setting up monitoring and alerting
POST /transfer
Body:
{
"from_investor": "ID123",
"to_investor": "ID789",
"asset_symbol": "BTC",
"amount": 100,
"idempotency_key": "IDEM0001" <- Change this for every request
}Responses:
Success
{
"transfer_id": "string",
"status": "SUCCESS"
}Failure
{
"transfer_id": "string",
"status": "FAILED",
"reason": "INSUFFICIENT_BALANCE"
}