Network marketing software sits in a unique category: it combines e-commerce transaction processing with real-time genealogy tree management at scale. When a single sale can trigger commission payments to 12 upline members simultaneously, standard approaches break quickly. This guide covers what actually works at production scale.
The Core Data Model: Representing the Network Tree
MLM networks are trees (or forests) with up to millions of nodes. The naive approach — a simple parent_id column — makes ancestor traversal catastrophically slow on deep trees. Two better patterns:
Closure Table
-- Stores every ancestor-descendant pair with depth CREATE TABLE member_tree_paths ( ancestor_id UUID NOT NULL, descendant_id UUID NOT NULL, depth INT NOT NULL, PRIMARY KEY (ancestor_id, descendant_id), FOREIGN KEY (ancestor_id) REFERENCES members(id), FOREIGN KEY (descendant_id) REFERENCES members(id) ); CREATE INDEX idx_tree_descendant ON member_tree_paths(descendant_id, depth); CREATE INDEX idx_tree_ancestor ON member_tree_paths(ancestor_id, depth);
With a closure table, finding all direct upline members (depth 1 through N) for commission is a single indexed lookup instead of a recursive query. The write overhead on enrollment is acceptable: a new member at depth 12 inserts 12 rows into the closure table.
Materialized Path
Store a dotted-string path like UUID.UUID.UUID in a text column with a GIN index. Simpler to query for subtrees using LIKE 'path%', but less flexible for depth-specific queries. Good for binary tree (2-leg) plans, less ideal for matrix plans.
Commission Calculation: Sync vs Async
The temptation is to calculate commissions synchronously at checkout. Resist it. A single sale touching 12 upline members with mixed plan types, rank overrides, and holding tank logic can take 800ms+ — unacceptable in a payment flow.
The production pattern:
- At checkout, record the sale in an
eventstable:{ type: 'SALE', member_id, amount, product_id, timestamp } - Publish the event to a queue (Redis Streams or BullMQ)
- A dedicated commission worker consumes events asynchronously
- Worker uses the closure table to find all upline members in one query
- Calculates commission per member according to plan rules
- Writes to a
commission_ledgertable in a single transaction - Updates real-time rank qualifications
// Commission worker (simplified)
async function processCommissionEvent(event: SaleEvent) {
const { memberId, amount } = event;
// Get all upline members with their depths (levels)
const upline = await db.query(`
SELECT m.id, m.rank, tp.depth AS level
FROM member_tree_paths tp
JOIN members m ON m.id = tp.ancestor_id
WHERE tp.descendant_id = $1
AND tp.depth BETWEEN 1 AND 12
ORDER BY tp.depth
`, [memberId]);
const entries = upline.map(member => ({
memberId: member.id,
sourceEventId: event.id,
amount: calculateCommission(amount, member.rank, member.level),
type: 'LEVEL_COMMISSION',
status: 'PENDING',
}));
await db.commissionLedger.createMany({ data: entries });
}
Rank Qualification Engine
Most MLM plans tie commission percentages and bonuses to the distributor's current rank, which changes dynamically based on their own volume and their downline's volume. Rank changes must trigger retroactive commission recalculations for the period.
Use a weekly materialized view for rank computation instead of calculating rank on every request:
CREATE MATERIALIZED VIEW member_rank_snapshot AS
SELECT
m.id,
m.name,
COALESCE(SUM(o.amount) FILTER (WHERE o.created_at > now() - interval '30 days'), 0) AS personal_volume,
COALESCE(SUM(sub_o.amount) FILTER (WHERE sub_o.created_at > now() - interval '30 days'), 0) AS group_volume,
compute_rank(
COALESCE(SUM(o.amount) FILTER (...), 0),
COALESCE(SUM(sub_o.amount) FILTER (...), 0)
) AS current_rank
FROM members m
-- ... joins to orders and downline orders
GROUP BY m.id;
REFRESH MATERIALIZED VIEW CONCURRENTLY member_rank_snapshot;
Payout Ledger Design
Never store a running balance directly on the member record — you lose the audit trail and create race conditions. Instead, use an append-only ledger with a balance computed from the sum:
CREATE TABLE commission_ledger ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), member_id UUID NOT NULL REFERENCES members(id), type TEXT NOT NULL, -- 'LEVEL_COMMISSION', 'BONUS', 'PAYOUT', 'ADJUSTMENT' amount NUMERIC(12,4) NOT NULL, source_event_id UUID, description TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), CONSTRAINT positive_payout CHECK (type != 'PAYOUT' OR amount < 0) ); -- Balance = sum of all entries for a member CREATE VIEW member_balance AS SELECT member_id, SUM(amount) AS balance FROM commission_ledger GROUP BY member_id;
Payouts are negative entries. This design gives you a complete audit trail, makes fraud investigation trivial, and allows point-in-time balance queries for any historical date.
Fraud Prevention Signals
MLM platforms are frequent targets of self-referral fraud and enrollment manipulation. Key signals to monitor:
- Rapid sub-tree enrollment: More than N enrollments under a member in 24 hours without corresponding order volume
- Circular enrollment attempts: A member trying to enroll into their own downline — caught by closure table ancestor check
- Payment velocity: Multiple payouts to the same bank account or high-frequency small orders triggering maximum commission at minimum cost
- Device fingerprinting: Multiple member accounts operating from the same device
Route suspicious events to a manual review queue rather than auto-rejecting — false positives damage legitimate distributor trust.
Key Performance Benchmarks
For a 300,000-member network on a properly-indexed PostgreSQL instance:
- Upline lookup (12 levels): <5ms with closure table, ~800ms with recursive CTE
- Commission calculation per event: 50–120ms end-to-end (async worker)
- Rank snapshot refresh: 8 minutes for 300K members, run nightly
- Balance query per member: <3ms with a partial index on the ledger
The right data model does more for MLM platform performance than any amount of application-layer caching. Invest in the schema design upfront.