Loading...
Loading...
A cooperative marketplace isn't just an e-commerce platform with group buying. It's a financial system with shared liability, regulated lending, and members who trust each other but not the platform. Here's how we built for that.
Field Note — Naija Co-op Hub
---
Cooperative societies in Nigeria operate at a scale that would surprise most Western engineers. A single cooperative might have 10,000 members, a collective savings pool in the hundreds of millions of naira, and a loan book that rivals a small microfinance bank. Yet until recently, most of these cooperatives ran on Excel, WhatsApp groups, and paper ledgers.
Naija Co-op Hub was built to unify this fragmented ecosystem into a single digital platform: marketplace, escrow, wallets, loans, bulk deals, and messaging. But building software for cooperatives is not like building software for consumers. The trust model is different. The regulatory exposure is different. The security requirements are different.
This is the story of how we approached that.
---
In a typical e-commerce platform, trust is between buyer and seller, mediated by the platform. The platform holds money in escrow, releases it when the buyer confirms receipt, and takes a commission. If the platform fails, the buyer and seller are out of luck, but the platform's liability is limited.
In a cooperative, trust is between members, mediated by the cooperative itself — and the platform is just a tool the cooperative uses. If the platform mishandles member funds, the cooperative's leadership is accountable to the members, not the platform. This means the platform has to be transparent, auditable, and secure in ways that go beyond typical SaaS compliance.
Members need to see:
All of this, for 10,000+ members, across hundreds of cooperatives, with zero cross-tenant data leakage.
---
We chose a monolithic Django backend over microservices. The reasoning was operational: the team was small (four engineers), the domain was tightly coupled (a loan affects a wallet, which affects the marketplace), and Django's admin interface gave cooperative managers a built-in backoffice without extra frontend work.
But "monolith" doesn't mean "spaghetti." We enforced modular boundaries at the app level:
cooperative/ # Cooperative metadata, membership, roles
marketplace/ # Listings, orders, escrow
wallet/ # Individual and group wallets, transactions
loans/ # Loan applications, scoring, repayments
messaging/ # Member-to-member and broadcast messaging
payments/ # Paystack integration, webhooks, reconciliationEach app owns its models, its API endpoints, and its business logic. Apps communicate through explicit service interfaces, not direct model imports. A marketplace order doesn't touch the wallet models directly; it calls wallet.services.debit_wallet() and wallet.services.credit_escrow().
This is the service layer pattern inside a monolith. It gives us the organizational clarity of microservices without the operational overhead.
Multi-tenancy was the hardest infrastructure decision. We considered three approaches:
1. Separate databases per cooperative: Maximum isolation, but operational nightmare. 500 cooperatives means 500 databases to backup, monitor, and migrate.
2. Schema-per-tenant: Better than separate databases, but still complex. Schema migrations across hundreds of tenants are slow and error-prone.
3. Row-level security (RLS) in a shared database: One database, one schema, but PostgreSQL's RLS policies enforce that queries only return rows the current tenant owns.
We chose RLS. Here's why:
CREATE POLICY cooperative_isolation ON marketplace_listing
USING (cooperative_id = current_setting('app.current_cooperative_id')::UUID);Every database connection sets app.current_cooperative_id at the start of the request. From that point on, every query — whether from Django ORM, raw SQL, or the admin interface — is automatically scoped to that cooperative. A bug that forgets to filter by cooperative_id doesn't leak data; the database enforces the boundary.
The tradeoff is that RLS policies have to be meticulously tested. A misconfigured policy — USING (true) instead of USING (cooperative_id = ...) — exposes everything. We wrote a comprehensive test suite that verifies RLS policies for every model, simulating cross-tenant access attempts and confirming they fail.
The escrow system is the most security-critical component. When a member buys something from the marketplace, their money goes into escrow, not directly to the seller. The seller ships the product. The buyer confirms receipt. Only then does the escrow release.
But what if the buyer never confirms? Or disputes the quality? We built a time-locked release mechanism:
class EscrowTransaction(models.Model):
status = models.CharField(choices=EscrowStatus.choices, default=EscrowStatus.PENDING)
auto_release_at = models.DateTimeField(null=True, blank=True)
dispute_window_hours = models.IntegerField(default=72)
def can_release(self):
if self.status == EscrowStatus.CONFIRMED:
return True
if self.status == EscrowStatus.PENDING and timezone.now() >= self.auto_release_at:
return True # Auto-release after dispute window
return FalseThe dispute window is configurable per cooperative. Some cooperatives trust their members and set a 24-hour window. Others, dealing with higher-value goods, set 7 days. The platform enforces the window but doesn't dictate it.
If a dispute is raised, the escrow is frozen and a resolution workflow begins. The cooperative's elected dispute resolution committee — not the platform — makes the final call. The platform just records the decision and executes the transfer.
The loan module integrates with a third-party credit scoring API for initial risk assessment, but the final approval is always human — the cooperative's loan committee. The platform provides the data: member savings history, repayment record, marketplace transaction volume, group contribution consistency. The humans make the call.
This is important. Automated loan approval would be faster, but in a cooperative, lending is a social decision as much as a financial one. A member with a thin credit file might be well-known and trusted by the group. An algorithm would reject them; the committee might approve them. The platform supports both paths.
The loan state machine is explicit and auditable:
DRAFT → SUBMITTED → UNDER_REVIEW → COMMITTEE_APPROVED → DISBURSED → ACTIVE → REPAID
↓ ↓
REJECTED COMMITTEE_REJECTEDEvery state transition is recorded with the actor (member, committee member, system), timestamp, and reason. A member can see exactly why their loan was rejected and what they need to improve.
Member communication happens through WebSockets, not polling. When a cooperative manager sends a broadcast message, it reaches all online members instantly. When a member sends a direct message, the recipient gets a push notification.
We use Django Channels with Redis as the channel layer. Messages are persisted in PostgreSQL for auditability — a cooperative can't claim "we never sent that notice" when the message is in the database with a timestamp and delivery receipt.
The security model for messaging is simple: you can only message members of your own cooperative. RLS enforces this at the database level. The WebSocket connection authenticates via JWT and sets the cooperative context before allowing any message operations.
---
Escrow is simple in concept and complex in execution. The edge cases are where security lives:
Every edge case required a policy decision, not just a code change. We worked with cooperative lawyers to define the policies before writing the code.
WebSockets don't scale horizontally as easily as HTTP. Every connected member holds an open connection, and Django Channels with Redis can handle thousands but not tens of thousands of concurrent connections on a single node.
Our solution was pragmatic: we shard by cooperative. Each cooperative's members connect to a specific Channels worker group. If one cooperative has a massive event (annual general meeting, emergency announcement), it saturates its own workers without affecting other cooperatives. We can also scale individual cooperative shards independently.
---
Trust is the most important feature in financial platforms. Not the UI, not the speed, not the feature set. Members need to believe their money is safe. Every design decision — RLS, escrow time locks, audit trails, human-in-the-loop loan approvals — serves that belief.
Local payment integration requires deep understanding of regional banking. Paystack is excellent, but Nigerian banking has quirks: NUBAN validation, BVN checks, interbank transfer delays, USSD fallback paths. We spent weeks understanding these before writing a line of integration code. A payment that works in test mode but fails in production because of a BVN mismatch destroys trust instantly.
Community platforms need strong moderation tools from day one. Cooperatives are communities, and communities have conflicts. Dispute resolution, message moderation, and member reporting can't be afterthoughts. We built moderation workflows in month three, and they've been used weekly since.
---
Naija Co-op Hub is live at [naijacoophub.com](https://naijacoophub.com)