No Network Required: How Client Interfaces Keep Modules Honest
In a previous post I covered why a modular monolith was the right architectural call for ClickNBack and what “explicit domain boundaries” means in practice. The modular structure answers the question of vertical isolation—how each domain owns its own behavior. But it raises a natural follow-on: when purchases needs to know about a merchant, or verify that a user account is active before crediting cashback, how does it get that information without importing internals from another module?
The answer is a pattern I rarely see discussed at the level it deserves. It also pays for itself the moment you write your first unit test.

The Problem with Direct Cross-Module Imports
The obvious path is to import what you need. The purchases module needs to check if a merchant is active before confirming a purchase? Import from merchants.repositories. It’s direct, readable, and the seed of a problem you’ll spend weeks undoing later.
Every direct import is a coupling that the module boundary was designed to prevent. When the merchants module updates its repository interface, purchases breaks too—not because anything changed in how purchases work, but because of an indirect dependency through a shared import. When you eventually want to extract merchants to a separate service, you don’t change one file; you trace every import across the codebase and rewrite every call site. Worse: your unit tests for PurchaseService now require a live database, because the dependency is a concrete class that only knows how to query Postgres.
The coupling is invisible today and expensive tomorrow. That’s the worst kind.
The Clients Pattern
In app/purchases/clients/, every foreign module that purchases depends on has a corresponding abstract client: MerchantsClientABC, UsersClientABC, WalletsClientABC. Each defines only what the purchases module needs—not what the foreign module can do in general, but precisely what it will be asked for from this specific calling context.
The concrete implementation today queries the shared database directly. Its docstring makes the intent explicit: “Replace with an HTTP client if this module is ever extracted to a separate service.” The abstract interface never changes. The concrete class does. The PurchaseService receives a MerchantsClientABC via dependency injection and calls its methods; whether the call resolves through a local query or a remote endpoint is a detail the service never sees and doesn’t need to.
That’s the design insight worth internalizing: the interface is the contract, and the contract doesn’t encode topology. The code that depends on a MerchantsClientABC is agnostic to where merchants live and how they’re fetched.
What This Changes for Testing
The payoff arrives immediately in the test suite. A unit test for the purchase confirmation flow doesn’t need a database, a seeded merchant record, or a working session. You inject a mock that implements MerchantsClientABC and return whatever the test scenario requires. The service logic runs in pure Python—fast, deterministic, reliable.
This is not a minor convenience. It’s the difference between a test suite that runs in seconds and one that requires database state management, fixture cleanup, and still fails intermittently when two tests collide on shared state. Tests that require infrastructure aren’t unit tests; they’re light integration tests wearing the wrong label—and they’re significantly harder to maintain as the codebase scales.
Batch Loading as a Module Boundary Discipline
There’s a secondary payoff worth naming. When a listing endpoint in purchases needs to enrich each result with data from merchants—say, attaching the merchant name to each purchase in the response—the clients pattern shapes how that enrichment is implemented.
The naive approach is one lookup per item: fetch a page of purchases, then loop and call get_merchant_by_id once per row. That’s an N+1 query pattern. The clients pattern encourages a different design: the MerchantsClientABC exposes a get_merchants_by_ids batch method that resolves a full page in one WHERE id IN (...) query, regardless of page size. The query performance is explicit in the interface design, not an emergent accident of how the loop ran.
The Extraction Path Is Pre-Built
When the moment comes to extract merchants to a separate service, the migration is surgical. The concrete MerchantsClient is replaced with an implementation that calls an HTTP endpoint instead of querying locally. The composition root is updated to inject the new class. Nothing else changes—not the service, not the background jobs, not the tests.
The monolith deferred the network overhead until it was justified, while the interface enforced a discipline that keeps deferral reversible. That’s the compounding return on writing explicit abstractions before you technically need them: they make future architectural changes cost linear, not exponential.