Click the Castor logo or press Ctrl Alt T to change theme.
# Working with the Library **Learn how to integrate Castor Ledgering into your application.** Castor Ledgering is designed as a **companion library**—it works alongside your existing application code. You keep your domain models (Users, Orders, Invoices), and the ledger handles the money. The key insight: **The ledger has its own identifiers, separate from yours.** Your application has a `User` with ID `abc-123`. The ledger has an `Account` with ID `11111111...`. You link them together using **external identifiers**. This separation gives you: - **Flexibility**: Change your application's IDs without touching the ledger - **Clarity**: Financial data is separate from business data - **Safety**: The ledger's immutable identifiers prevent accidental changes ## Core Principles Everything in Castor Ledgering follows these principles: 1. **Separate Identity**: The ledger maintains its own 128-bit identifiers, independent of your application. 2. **External References**: Use `externalId` fields to link ledger entities to your application entities. 3. **Immutability**: All domain objects are immutable and readonly. Once created, they never change. 4. **Type Safety**: Value objects prevent invalid states at compile time. You can't accidentally create a negative amount. 5. **Command Pattern**: You don't call methods like `account.debit()`. Instead, you create commands like `CreateTransfer` and execute them. ## Setting Up the Ledger Before you can create accounts and transfers, you need to set up the ledger. There are two options: ### Option 1: In-Memory Storage (Development/Testing) Perfect for unit tests and local development: ```php use Castor\Ledgering\StandardLedger; use Castor\Ledgering\Storage\InMemory\AccountCollection; use Castor\Ledgering\Storage\InMemory\TransferCollection; use Castor\Ledgering\Storage\InMemory\AccountBalanceCollection; $ledger = new StandardLedger( accounts: new AccountCollection(), transfers: new TransferCollection(), accountBalances: new AccountBalanceCollection(), ); ``` This stores everything in memory. Fast, but data is lost when the process ends. ### Option 2: Database Storage (Production) For production, use a real database (PostgreSQL recommended): ```php use Castor\Ledgering\StandardLedger; use Castor\Ledgering\Storage\Dbal\AccountRepository; use Castor\Ledgering\Storage\Dbal\TransferRepository; use Castor\Ledgering\Storage\Dbal\AccountBalanceRepository; use Castor\Ledgering\Storage\Dbal\TransactionalLedger; use Doctrine\DBAL\DriverManager; // Use a dedicated connection for the ledger (see warning below) $ledgerConnection = DriverManager::getConnection([ 'url' => 'postgresql://user:pass@localhost/ledger', ]); // Wrap the ledger in a transaction manager $ledger = new TransactionalLedger( connection: $ledgerConnection, ledger: new StandardLedger( accounts: new AccountRepository($ledgerConnection), transfers: new TransferRepository($ledgerConnection), accountBalances: new AccountBalanceRepository($ledgerConnection), ), ); ``` The `TransactionalLedger` wrapper ensures that all operations are atomic—either everything succeeds, or nothing changes. > [!TIP] > **Use transactions in production** > > Always use `TransactionalLedger` in production. It ensures that if you execute multiple commands together, they either all succeed or all fail. No partial updates. > [!WARNING] > **Isolation levels and database connections** > > `TransactionalLedger` sets the transaction isolation level to `REPEATABLE_READ` before each operation. This is the minimum level of isolation required for the consistency guarantees this library provides. It does **not** reset the isolation level back to its original value afterward, for performance reasons. > > This means any subsequent queries on the same connection will also run under `REPEATABLE_READ`, which may not be what your application expects. If your application code relies on `READ_COMMITTED` (the PostgreSQL default), it will silently get the wrong isolation level. > > **Use a dedicated connection for the ledger.** Even when the database is the same, a separate connection avoids this problem entirely: > > ```php > // Your application's connection (uses the default isolation level) > $appConnection = DriverManager::getConnection([ > 'url' => 'postgresql://user:pass@localhost/myapp', > ]); > > // A dedicated connection for the ledger > $ledgerConnection = DriverManager::getConnection([ > 'url' => 'postgresql://user:pass@localhost/myapp', > ]); > ``` > > This also has a design benefit: two separate connections force you to think about the boundary between your application code and your ledger code, much like you would with a network partition in a distributed system. > > The isolation level is configurable via the constructor, but we only recommend `REPEATABLE_READ` (the default) or `SERIALIZABLE`. Using `READ_COMMITTED` or `READ_UNCOMMITTED` does not provide enough safety for financial operations. ### Combining Decorators You can combine multiple decorators for different behaviors. For example, to have both transactions and idempotency: ```php use Castor\Ledgering\IdempotentLedger; use Castor\Ledgering\Storage\Dbal\TransactionalLedger; $ledger = new IdempotentLedger( new TransactionalLedger( connection: $connection, ledger: new StandardLedger( accounts: new AccountRepository($connection), transfers: new TransferRepository($connection), accountBalances: new AccountBalanceRepository($connection), ), ), ); ``` This gives you: - **Atomicity** from `TransactionalLedger` (all-or-nothing operations) - **Idempotency** from `IdempotentLedger` (safe retries without duplicate errors) Perfect for distributed systems where you need both transactional guarantees and retry safety. ## Generating Identifiers Every account and transfer needs a unique 128-bit identifier. How you generate these identifiers has a significant impact on database performance, so it's worth understanding the options before you start creating accounts. ### Why This Matters for Relational Databases If you've ever used random UUIDs as primary keys in PostgreSQL or MySQL, you might have noticed that performance degrades as your tables grow. That's not a coincidence — it's a consequence of how B-tree indexes work. When you insert a row with a random UUID, the database has to place it somewhere in the middle of the index. The target leaf page is probably not in memory, so the database reads it from disk, modifies it, and writes it back. Worse, the page may already be full, forcing a **page split** — the database has to allocate a new page, redistribute entries, and update parent pointers. Multiply that by millions of inserts and your index becomes fragmented, your buffer cache thrashes, and write latency climbs. Sequential identifiers solve this problem entirely. When each new ID is larger than the last, the database always appends to the rightmost leaf page — the one that's almost certainly already in memory. No random I/O, no page splits, no fragmentation. Insert performance stays constant as the table grows. This is exactly what `TimeOrderedMonotonic` gives you: identifiers that are always increasing, so your database indexes stay compact and fast. There's a second benefit: **cursor-based pagination**. Because the identifiers are time-ordered, you can paginate through results using the ID itself as a cursor (`WHERE id > :last_seen_id ORDER BY id LIMIT 20`). This is far more efficient than offset-based pagination, which forces the database to skip over rows it has already counted. Offset pagination also breaks when rows are inserted or deleted between pages. ### The IdentifierFactory Interface The library provides an `IdentifierFactory` interface with a single method: ```php use Castor\Ledgering\IdentifierFactory; interface IdentifierFactory { public function create(): Identifier; } ``` You call `create()` every time you need a new identifier. The factory encapsulates the generation strategy, so your application code doesn't need to know or care about the algorithm behind it. ### TimeOrderedMonotonic — The Recommended Implementation `TimeOrderedMonotonic` produces identifiers that are structurally similar to [ULIDs](https://github.com/ulid/spec). Each identifier is 128 bits laid out like this: ``` ┌──────────────────────────┬──────────────────────────────────────┐ │ 48-bit timestamp (ms) │ 80-bit random/counter │ │ (6 bytes, BE) │ (10 bytes) │ └──────────────────────────┴──────────────────────────────────────┘ ``` The first 6 bytes encode the current time in milliseconds since the Unix epoch, packed big-endian. The remaining 10 bytes are a random component that provides uniqueness. The **monotonic** part is the key property: when two identifiers are generated within the same millisecond, the factory doesn't pick a new random value. Instead, it increments the previous random component by one. This guarantees that every identifier is strictly greater than the last, even under high throughput. If the clock happens to go backward (for example, after an NTP correction), the factory detects this and keeps using the last observed timestamp while continuing to increment. This ensures that identifiers never go backward, no matter what the system clock does. Creating a factory is straightforward: ```php use Castor\Ledgering\TimeOrderedMonotonic; $factory = new TimeOrderedMonotonic(); $id1 = $factory->create(); $id2 = $factory->create(); $id3 = $factory->create(); // $id1 < $id2 < $id3 (lexicographic byte ordering) ``` > [!NOTE] > **On random component overflow** > > In the extraordinarily unlikely event that more than 2^80 identifiers (~1.2 x 10^24) are generated within a single millisecond, the factory throws an `\OutOfBoundsException`. In practice, this is physically impossible — you would need to generate roughly a trillion identifiers per nanosecond to hit this limit. ### Injecting the Factory Into the Ledger `StandardLedger` accepts an `IdentifierFactory` in its constructor. It uses it internally when it needs to generate identifiers on its own (for example, when expiring pending transfers). If you don't provide one, it defaults to `TimeOrderedMonotonic`: ```php use Castor\Ledgering\StandardLedger; use Castor\Ledgering\TimeOrderedMonotonic; // Explicit (recommended — makes the dependency visible) $factory = new TimeOrderedMonotonic(); $ledger = new StandardLedger( accounts: $accounts, transfers: $transfers, accountBalances: $accountBalances, identifiers: $factory, ); ``` You should also use the same factory when constructing commands in your application code: ```php $ledger->execute( CreateAccount::with( id: $factory->create(), ledger: 1, code: 100, ), CreateTransfer::with( id: $factory->create(), debitAccountId: $debitAccountId, creditAccountId: $creditAccountId, amount: 5000, ledger: 1, code: 1, ), ); ``` Using the same `TimeOrderedMonotonic` instance across your application ensures that all identifiers are globally monotonic within that process. ### Testability `TimeOrderedMonotonic` accepts a `Clock`, which makes it easy to control in tests. If you're already using `FixedClock` in your test suite, you can pass it to the factory to get deterministic, reproducible identifiers: ```php $clock = FixedClock::at(1_700_000_000); $factory = new TimeOrderedMonotonic($clock); // All identifiers will have the same timestamp prefix $id1 = $factory->create(); $id2 = $factory->create(); // Same ms, so random is incremented ``` ### When Is Identifier::random() Still Appropriate? `Identifier::random()` is not deprecated and remains perfectly valid for: - **Unit tests** where ordering and index performance are irrelevant - **External identifiers** (`externalIdPrimary`, `externalIdSecondary`) that are not used as primary keys - **One-off scripts** or throwaway identifiers The rule of thumb: if the identifier will be stored as a primary key or indexed column in a database, use `IdentifierFactory`. For everything else, `Identifier::random()` is fine. ## Linking the Ledger to Your Application This is where the magic happens. You use **external identifiers** to connect ledger entities to your application's domain model. ### Example: Linking Accounts to Users Let's say you have a `User` in your application. You want to create a ledger account for them. ```php use Castor\Ledgering\CreateAccount; use Castor\Ledgering\Identifier; use Castor\Ledgering\AccountFlags; // Your application's user $user = $userRepository->find('abc-123'); $userId = $user->getId(); // 'abc-123' // Create a ledger account for this user $accountId = Identifier::fromHex('11111111111111111111111111111111'); $ledger->execute( CreateAccount::with( id: $accountId, ledger: 1, // USD code: 100, // Checking account flags: AccountFlags::DEBITS_MUST_NOT_EXCEED_CREDITS, externalIdPrimary: Identifier::hashOf($userId), // Link to user! ), ); ``` Now the ledger account is linked to your user. Later, you can find the account by user ID: ```php // Find the ledger account for a user $account = $accounts ->ofExternalIdPrimary(Identifier::hashOf($userId)) ->first(); if ($account === null) { // User doesn't have a ledger account yet } ``` > [!NOTE] > **Working with different ID formats** > > The ledger uses 128-bit identifiers (16 bytes). Here's how to convert your application's IDs: > > **For string IDs** (user IDs, order IDs, etc.): > ```php > $externalId = Identifier::hashOf($userId); // Uses MD5 hashing > ``` > > **For UUIDs** (already 128-bit): > ```php > // If your UUID is in hex format (with or without hyphens) > $externalId = Identifier::fromHex($uuid); > > // If your UUID is in binary format > $externalId = Identifier::fromBytes($uuidBytes); > ``` > > **For ULIDs or other 128-bit identifiers**: > ```php > // Convert to bytes first, then use fromBytes() > $externalId = Identifier::fromBytes($ulid->toBytes()); > ``` > > The `hashOf()` method is deterministic—the same input always produces the same identifier. ### Example: Linking Transfers to Orders Same idea for transfers. Link them to your application's orders, invoices, or transactions: ```php use Castor\Ledgering\CreateTransfer; // Your application's order $order = $orderRepository->find('order-12345'); $orderId = $order->getId(); $ledger->execute( CreateTransfer::with( id: Identifier::fromHex('33333333333333333333333333333333'), debitAccountId: $customerAccountId, creditAccountId: $merchantAccountId, amount: 5000, // $50.00 ledger: 1, code: 1, // Payment externalIdPrimary: Identifier::hashOf($orderId), // Link to order! ), ); ``` Later, you can find the transfer by order ID: ```php // Find the transfer for an order $transfer = $transfers ->ofExternalIdPrimary(Identifier::hashOf($orderId)) ->first(); ``` ### Using Multiple External References Each entity has **three external reference fields**: - **externalIdPrimary**: Primary reference (e.g., user ID, order ID) - **externalIdSecondary**: Secondary reference (e.g., transaction ID, invoice ID) - **externalCodePrimary**: Numeric code for categorization You can use all three to link to different parts of your application: ```php $ledger->execute( CreateTransfer::with( id: $transferId, debitAccountId: $debitAccountId, creditAccountId: $creditAccountId, amount: 1000, ledger: 1, code: 1, externalIdPrimary: Identifier::hashOf($orderId), // Order externalIdSecondary: Identifier::hashOf($invoiceId), // Invoice externalCodePrimary: 42, // Your app's category code ), ); ``` This gives you maximum flexibility for querying and reporting. ## Querying Data The library uses the **Reader pattern** for querying. Think of it like a fluent query builder—you chain methods to filter and retrieve data. ### Querying Accounts ```php // Find a single account by ID $account = $accounts->ofId($accountId)->first(); // Returns Account|null $account = $accounts->ofId($accountId)->one(); // Returns Account or throws // Find by external ID (your application's ID) $account = $accounts->ofExternalIdPrimary($userId)->first(); $account = $accounts->ofExternalIdSecondary($customerId)->first(); // Get multiple accounts at once (OR query) $accountList = $accounts->ofId($id1, $id2, $id3)->toList(); // Filter by ledger $usdAccounts = $accounts->ofLedger(1)->toList(); // All USD accounts // Filter by code (account type) $checkingAccounts = $accounts->ofCode(100)->toList(); // Combine filters (AND query) $customerUsdAccounts = $accounts ->ofLedger(1) // USD ->ofCode(100) // Checking accounts ->toList(); ``` > [!TIP] > **All `of*()` methods accept multiple arguments** > > You can pass multiple values to any `of*()` method for OR queries: > > ```php > // Find accounts with any of these IDs > $accounts->ofId($id1, $id2, $id3)->toList(); > > // Find accounts with any of these codes > $accounts->ofCode(100, 200, 300)->toList(); // Checking, savings, or loans > > // Find accounts on any of these ledgers > $accounts->ofLedger(1, 2)->toList(); // USD or EUR > ``` > > When you chain methods, they work as AND conditions: > ```php > // USD accounts that are EITHER checking OR savings > $accounts->ofLedger(1)->ofCode(100, 200)->toList(); > ``` **When to use `first()` vs `one()`:** - Use `first()` when the account might not exist (returns `null`) - Use `one()` when you expect it to exist (throws exception if not found) ### Querying Transfers ```php // Find a single transfer by ID $transfer = $transfers->ofId($transferId)->first(); // Find by external ID (your application's order ID, etc.) $transfer = $transfers->ofExternalIdPrimary($orderId)->first(); // Find all transfers for an account $debits = $transfers->ofDebitAccount($accountId)->toList(); $credits = $transfers->ofCreditAccount($accountId)->toList(); // Pagination (for large result sets) $page = $transfers ->ofDebitAccount($accountId) ->slice(offset: 0, limit: 20) // First 20 transfers ->toList(); // Count without loading all the data $count = $transfers->ofDebitAccount($accountId)->count(); ``` > [!TIP] > **Use pagination for large result sets** > > If an account has thousands of transfers, don't load them all at once. Use `slice()` to paginate: > > ```php > $pageSize = 50; > $pageNumber = 2; > > $transfers = $transfers > ->ofDebitAccount($accountId) > ->slice(offset: $pageNumber * $pageSize, limit: $pageSize) > ->toList(); > ``` ### Querying Balance History ```php // Get all balance snapshots for an account (requires HISTORY flag) $balances = $accountBalances ->ofAccountId($accountId) ->toList(); // Get the most recent balance snapshot $latest = $accountBalances ->ofAccountId($accountId) ->slice(offset: 0, limit: 1) ->first(); ``` Remember: Balance history is only available for accounts with the `HISTORY` flag enabled. ## Working with Value Objects All the basic types in Castor Ledgering are **value objects**—immutable, type-safe wrappers around primitive values. Here's how to use them: ### Identifier ```php use Castor\Ledgering\Identifier; use Castor\Ledgering\TimeOrderedMonotonic; // Generate a time-ordered identifier (preferred for primary keys) $factory = new TimeOrderedMonotonic(); $id = $factory->create(); // From hexadecimal string (32 hex characters = 128 bits) $id = Identifier::fromHex('0123456789abcdef0123456789abcdef'); // From raw bytes (16 bytes = 128 bits) $id = Identifier::fromBytes($binaryData); // Zero identifier (useful for optional fields) $id = Identifier::zero(); // Check equality if ($id1->equals($id2)) { // Same identifier } // Access raw bytes $bytes = $id->bytes; // string (16 bytes) ``` See [Generating Identifiers](#generating-identifiers) for a detailed explanation of why `TimeOrderedMonotonic` is the recommended way to create identifiers. ### Amount Always use the smallest currency unit (cents for USD, pence for GBP, etc.): ```php use Castor\Ledgering\Amount; // Create from integer (cents) $amount = Amount::of(1000); // $10.00 $amount = Amount::of(50); // $0.50 // Zero amount $amount = Amount::zero(); // Arithmetic $sum = $amount1->add($amount2); $diff = $amount1->subtract($amount2); // Throws if result would be negative // Comparison $cmp = $amount1->compare($amount2); // -1, 0, or 1 if ($amount->isZero()) { // Amount is zero } // Access the raw value $cents = $amount->value; // int ``` > [!WARNING] > **Always use integers, never floats** > > Never use floating-point numbers for money. They have rounding errors that can cause your books to be off by pennies. > > ```php > // ❌ Don't do this > $amount = 10.50; // Floating point - BAD! > > // ✓ Do this > $amount = Amount::of(1050); // 1050 cents = $10.50 - GOOD! > ``` ### Code ```php use Castor\Ledgering\Code; // Create from integer $ledger = Code::of(1); // USD $code = Code::of(100); // Account type // Access value $value = $code->value; // int ``` Codes are just integers, but wrapping them in a `Code` object makes your code more type-safe. ## Error Handling When something goes wrong, the ledger throws exceptions. Here's how to handle them: ```php use Castor\Ledgering\ConstraintViolation; use Castor\Ledgering\ErrorCode; try { $ledger->execute($command); } catch (ConstraintViolation $e) { // Check what went wrong using the errorCode property match ($e->errorCode) { ErrorCode::AccountAlreadyExists => { // You tried to create an account with an ID that already exists // This is usually fine - accounts are idempotent }, ErrorCode::AccountNotFound => { // You referenced an account that doesn't exist // Create it first! }, ErrorCode::DebitsExceedCredits => { // Overdraft! The account doesn't have enough funds // This happens when DEBITS_MUST_NOT_EXCEED_CREDITS is set }, ErrorCode::LedgerMismatch => { // You tried to transfer between accounts with different ledger codes // Can't transfer USD to EUR directly! }, default => throw $e, // Unknown error, re-throw }; } ``` ### Common Error Codes - **AccountAlreadyExists**: Account with this ID already exists - **AccountNotFound**: Referenced account doesn't exist - **DebitsExceedCredits**: Overdraft prevented by `DEBITS_MUST_NOT_EXCEED_CREDITS` flag - **CreditsExceedDebits**: Overpayment prevented by `CREDITS_MUST_NOT_EXCEED_DEBITS` flag - **LedgerMismatch**: Accounts have different ledger codes - **TransferAlreadyExists**: Transfer with this ID already exists - **PendingTransferNotFound**: Referenced pending transfer doesn't exist - **PendingTransferExpired**: Pending transfer has timed out - **AccountClosed**: Account is closed (has `CLOSED` flag) > [!NOTE] > **Handling duplicate operations** > > The ledger throws `AccountAlreadyExists` and `TransferAlreadyExists` errors when you try to create duplicates. This is intentional—it lets you decide how to handle them. > > **Option 1: Use the IdempotentLedger decorator (recommended)** > > For automatic idempotent behavior, wrap your ledger with `IdempotentLedger`: > > ```php > use Castor\Ledgering\IdempotentLedger; > > $ledger = new IdempotentLedger( > new StandardLedger($accounts, $transfers, $accountBalances) > ); > > // Safe to retry - won't throw if account already exists > $ledger->execute($createAccount); > $ledger->execute($createAccount); // No error! > ``` > > This is perfect for retry scenarios and distributed systems where you need operations to be safe to retry. > > **Option 2: Manual error handling** > > If you need more control, catch the errors manually: > > ```php > try { > $ledger->execute($createAccount); > } catch (ConstraintViolation $e) { > if ($e->errorCode === ErrorCode::AccountAlreadyExists) { > // Already exists, that's fine > return; > } > throw $e; > } > ``` > > Both patterns make retries safe without worrying about duplicates. > [!WARNING] > **Batch operations and storage backends** > > When executing multiple commands in a single batch, the behavior differs between storage backends if an error occurs mid-batch: > > **With DBAL storage (TransactionalLedger)**: > - All operations are wrapped in a database transaction > - If any command fails (e.g., 3rd account in a batch of 5), the entire transaction rolls back > - No partial state is persisted—it's all-or-nothing > > **With in-memory storage**: > - No transaction support—changes are written immediately > - If a command fails mid-batch, previous commands remain in memory > - This creates partial state that cannot be rolled back > > **Example scenario**: > ```php > // Batch of 5 accounts, 3rd one already exists > $ledger->execute( > CreateAccount::with(id: $id1, ...), // ✓ Succeeds > CreateAccount::with(id: $id2, ...), // ✓ Succeeds > CreateAccount::with(id: $id3, ...), // ✗ Already exists! > CreateAccount::with(id: $id4, ...), // Never executed > CreateAccount::with(id: $id5, ...), // Never executed > ); > ``` > > - **DBAL**: Transaction rolls back → accounts 1 & 2 are NOT created > - **In-memory**: No rollback → accounts 1 & 2 ARE created (partial state) > > **With IdempotentLedger**, this becomes more subtle: > - The duplicate error is suppressed silently > - DBAL still rolls back the transaction (accounts 1 & 2 lost) > - In-memory keeps accounts 1 & 2 (partial state persists) > - Your application may not realize anything went wrong > > **Recommendation**: Always use `TransactionalLedger` with DBAL storage in production for atomic batch operations. Use in-memory storage only for testing where you control the data. ## Best Practices Here are some tips for using Castor Ledgering effectively: ### 1. Use TimeOrderedMonotonic for Primary Keys Always use `IdentifierFactory` to generate identifiers that will be stored in the database: ```php // ✓ Good: Time-ordered, monotonic (optimal for database indexes) $factory = new TimeOrderedMonotonic(); $accountId = $factory->create(); // ✓ Good: Deterministic external ID from your application's ID $externalId = Identifier::hashOf($userId); // ❌ Bad: Random (causes index fragmentation at scale) $accountId = Identifier::fromHex(bin2hex(random_bytes(16))); ``` ### 2. Use External IDs for Lookups Always query by external ID, not ledger ID: ```php // ✓ Good: Query by your application's ID $account = $accounts->ofExternalIdPrimary($userId)->first(); // ❌ Bad: Query by ledger ID (you'd have to store it somewhere) $account = $accounts->ofId($accountId)->first(); ``` ### 3. Leverage Account Flags Let the ledger enforce business rules: ```php // ✓ Good: Ledger prevents overdrafts automatically CreateAccount::with( flags: AccountFlags::DEBITS_MUST_NOT_EXCEED_CREDITS, // ... ) // ❌ Bad: Checking balance in application code (race conditions!) if ($this->getBalance($accountId) >= $amount) { $ledger->execute($transfer); // Might still fail! } ``` ### 4. Enable History Selectively Only enable the `HISTORY` flag on accounts that need it: ```php // ✓ Good: Only customer accounts need history CreateAccount::with( code: 100, // Customer account flags: AccountFlags::HISTORY, // ... ) // ❌ Bad: Enabling history on everything (wastes storage) CreateAccount::with( code: 999, // Temporary control account flags: AccountFlags::HISTORY, // Unnecessary! // ... ) ``` ### 5. Use Transactions Wrap multiple commands in a transaction for atomicity: ```php // ✓ Good: All or nothing $ledger->execute( $createAccount1, $createAccount2, $createTransfer, ); // ❌ Bad: Separate executions (partial failures possible) $ledger->execute($createAccount1); $ledger->execute($createAccount2); $ledger->execute($createTransfer); // Might fail, leaving orphaned accounts ``` ### 6. Handle Duplicate Operations The ledger throws errors for duplicates. Handle them for idempotent retries: ```php // ✓ Good: Handle duplicates explicitly try { $ledger->execute( CreateAccount::with(id: $accountId, /* ... */), ); } catch (ConstraintViolation $e) { if ($e->errorCode === ErrorCode::AccountAlreadyExists) { // Already exists, that's fine return; } throw $e; } // ❌ Bad: Letting duplicates propagate as errors $ledger->execute( CreateAccount::with(id: $accountId, /* ... */), ); // Throws on retry! ``` This pattern makes retries safe without creating duplicates. ### 7. Never Use Floats for Money Always use integers (smallest currency unit): ```php // ✓ Good $amount = Amount::of(1050); // $10.50 // ❌ Bad $amount = 10.50; // Floating point - rounding errors! ``` ## What's Next? Now that you know how to integrate the library, learn the powerful features: - **[Preventing Overdrafts and Overpayments](guides/preventing-overdrafts-and-overpayments.html)** - Use account flags to enforce constraints - **[Automatic Balance Calculations](guides/automatic-balance-calculations.html)** - Let the ledger calculate amounts for you - **[Two-Phase Payments](guides/two-phase-payments.html)** - Implement pre-authorizations and escrow - **[Loan Management System](guides/loan-management.html)** - See everything working together in a complete system Or dive deeper into the reference: - **[Domain Model](domain-model.html)** - Complete reference for all entities and value objects
Castor ecosystem