Domain-Centric Pitfalls — Consistency-Cluster Boundaries, Double-Entry Ledger, and the Notification Pipeline
The previous post covered the concepts of DDD and how they compare to .NET MVC. This post documents the pitfalls I encountered in the actual project: how to draw consistency-cluster boundaries, how to design a double-entry ledger, and how the notification pipeline enables cross-module communication.
The Cost of Drawing Consistency-Cluster Boundaries Wrong
The rules for an Aggregate sound straightforward — a group of things that must always be accessed together, with a root entity as the entry point. But actually drawing the boundary is easy to get wrong.
Article and Its Replies
Post and Comment were the first consistency-cluster design I tackled:
|
|
Key points:
- Outside code cannot operate on
Commentdirectly — it must go throughPost - Adding a comment:
post.add_comment(), notcomment_repo.create() - Removing a comment:
post.remove_comment(), whereCommentitself checks authorship, but the removal action is controlled byPost - The entire cluster is loaded and saved together
Comment has its own verify_authorship() method to confirm “are you the author of this comment?” But the “remove comment” action can only be performed by Post — Comment cannot remove itself from the list.
Exclusive Ownership of a Purse
Wallet has an invariant I did not think through at first: a wallet must belong to either a user or a brand — not both, and not neither.
|
|
Using factory methods (for_user(), for_brand()) instead of exposing the constructor directly means the caller does not need to know the rule “user_id and brand_id are mutually exclusive” — the API itself makes it impossible to create an invalid wallet.
The Vendor State Machine
Brand has three states: PENDING → ACTIVE → ARCHIVED. All transition rules live inside the entity:
|
|
At first I put the pre-checks for activate() inside the Service. Later I realized three different places were calling activate, and one of them forgot to check whether Shopify was installed. After moving the check into the entity, every call site is covered no matter what.
Another detail: ShopifyCredentials is a frozen value object:
|
|
It holds both OAuth-setup-phase fields (oauth_nonce) and post-installation fields (access_token), using the is_installed property to determine the state. Being frozen ensures nobody can directly modify access_token and bypass the installation flow.
Double-Entry Ledger
The project has a wallet and commission system that requires bookkeeping. At first I considered adding a balance field directly on the wallet and doing += amount for each transaction. But this has several problems:
- Concurrency — two transactions doing
+= amountat the same time can overwrite each other - No audit trail — how was the balance derived? There is no way to trace it back
- Refunds require manual back-calculation, which is error-prone
So we used double-entry bookkeeping: each transaction has a debit side and a credit side. The balance is not stored directly on the wallet — it is computed from the transaction records.
Transaction Entity
|
|
Key points:
debit_wallet_id≠credit_wallet_id: you cannot transfer to yourselfMoneyis a value object containing amount and currency- After creation, the only operation on a transaction is
void(), and only PENDING transactions can be voided SourceReferencetracks where this transaction came from (e.g. a Shopify order)
Money Value Object
|
|
Using cents instead of dollars avoids floating-point precision issues. The amount cannot be negative, and being frozen ensures immutability.
Balance Is Computed, Not Stored
This is the core concept of double-entry bookkeeping. The Wallet itself has no balance field:
|
|
The balance is computed at the repository’s infrastructure layer using SQL:
|
|
Only non-VOIDED transactions are counted. This means:
- The balance at any point in time can be recomputed from the transaction records
- Voiding a transaction does not require manually adjusting the balance, because voided transactions are automatically excluded
- The “balance does not match transaction records” problem cannot exist
Idempotency
Shopify orders may be processed multiple times due to webhook retries. The Transaction repository uses PostgreSQL’s INSERT ... ON CONFLICT DO NOTHING with a unique constraint:
|
|
The same Shopify order will produce only one transaction no matter how many times it is delivered. If the record already exists, the existing one is returned instead of raising an error.
The database layer also has corresponding check constraints:
CHECK (amount_cents >= 0)CHECK (debit_wallet_id != credit_wallet_id)
Both the domain layer and the database layer perform validation. The domain-level checks catch issues at the application-logic level; the database constraints are the last line of defense.
Commission Calculation: The Actual Bookkeeping Flow
AttributionService is responsible for calculating commission from Shopify orders and recording it in the ledger:
- Fetch the order from the Shopify API
- Calculate the user commission (e.g. 15%)
- Record a brand_wallet → user_wallet transaction
- Calculate the platform fee (e.g. 0.5%)
- Record a brand_wallet → platform_wallet transaction
Each transaction carries SourceReference(source_type="shopify_order", source_id=order_id), so duplicate processing does not result in duplicate entries.
The cursor mechanism uses the timestamp of the last transaction minus 7 days as the starting point, ensuring late-arriving orders are not missed while idempotency prevents duplicates.
The Notification Pipeline: How Modules Communicate
Bounded Contexts in DDD must not import each other directly. But modules inevitably need to notify one another — for example, after a brand is created, the ledger module needs to automatically create a wallet. How?
Design
The foundation layer provides an event infrastructure:
Base Entity — every domain entity has the ability to record events:
|
|
collect_events() is clear-on-read — calling it once clears the list, ensuring events are not processed twice.
EventPublisher Protocol — defines the interface for publishing events:
|
|
EventCollector — a request-scoped event collector that gathers all events within a single request and dispatches them together after the request completes.
EventBus — a multicast dispatcher that routes events to the corresponding handlers:
|
|
Concrete Notification Types
Each module defines its own events:
|
|
Events are frozen value objects — immutable, carrying only IDs, not entire entities. This forces the receiver to query the latest state itself, preventing stale snapshots.
Publishing Flow
In the Application Service, EventPublisher is an optional dependency:
|
|
Why optional? Because tests do not always need the event system — injecting None lets you test business logic in isolation.
The presentation layer (providers.py) is responsible for injecting the EventCollector into the Service:
|
|
RequestScope creates a new EventCollector at the start of each HTTP request and dispatches all collected events after the request completes.
Why Not Wire Directly
If BrandService were to import WalletService directly to create a wallet:
|
|
Then the Brands module would depend on the Ledger module. If you later want to split into microservices, or if the Ledger module changes its interface, Brands has to change too.
With events:
- The Brands module only cares about publishing the
BrandCreatedevent - The Ledger module subscribes to
BrandCreatedand decides for itself whether to create a wallet - The two modules have zero knowledge of each other
- They communicate only through the event data class, which lives in the foundation layer
Summary
| Problem | Pitfall | Solution |
|---|---|---|
| Consistency-cluster boundary | Logic scattered across Services, entity not protecting itself | Entity carries behavior; outside code operates only through the root |
| State machine | Multiple call sites for activate, some forgetting pre-condition checks | Transition logic lives in entity methods |
| Balance calculation | Storing a balance field directly causes concurrency and audit problems | Double-entry: balance is computed from transaction records |
| Duplicate transactions | Webhook retries produce duplicate commissions | Unique constraint + INSERT ON CONFLICT |
| Module coupling | Module A directly imports module B | Domain events: notify only through events |
The next post will document GCP runtime contracts, auth-bypass design, and CI/CD auto-deploy practices.