Pattern A. Custom Modules Reference Implementation
Custom entity types, hook_entity_presave for commission calculation, hook_cron for batch payouts. Working at $1M GMV on a 1,500-distributor Drupal 10 site.
When this pattern fits
Strict filter. A single comp plan, binary or unilevel only. Network under $2M GMV with realistic 12-month projection staying below it. Deep Drupal expertise on the team, meaning at least one developer who can read core entity API source and not be slowed down by it. Drupal Commerce already in production for the storefront side. If any of those is false, jump to Pattern B (the SaaS bridge); the custom-module path's complexity isn't worth it without those preconditions.
Entity model
Three custom entities. Distributor, Commission, GenealogyEdge. Distributor extends User, which gives you Drupal's session and access-control model for free. Commission references Distributor and the Commerce order, with cycle and amount. GenealogyEdge stores parent-child relationships separately from the Distributor entity itself, which keeps the upline-downline tree query-friendly and avoids the recursive-self-reference traps that surface around 1,000 nodes.
// distributor.module: minimal entity declaration
function distributor_entity_info() {
return [
'distributor' => [
'label' => t('Distributor'),
'fields' => [
'sponsor_id' => 'reference:distributor',
'leg' => 'list_string:left|right',
'rank' => 'string',
'wallet_balance' => 'decimal',
'enrolled_at' => 'datetime',
],
],
'commission' => [
'label' => t('Commission'),
'fields' => [
'distributor_id' => 'reference:distributor',
'order_id' => 'reference:commerce_order',
'amount' => 'decimal',
'cycle' => 'date',
'level' => 'integer',
],
],
];
}
Commission calculation on order completion
The commission calc walks the upline when a Commerce order transitions to completed state. Bound the depth (8 levels is reasonable for binary, configurable for unilevel) and cache the upline path aggressively; without those two precautions, every order completion hits the database with a recursive query that scales badly past 1,000 distributors.
function distributor_commerce_order_presave(EntityInterface $order) {
if ($order->getState()->getId() !== 'completed') return;
$distributor_id = $order->getCustomerId();
$upline = distributor_get_upline($distributor_id, depth: 8);
foreach ($upline as $level => $sponsor_id) {
$rule = CompPlanRule::forLevel($level);
$amount = $rule->calculate($order, $sponsor_id);
Commission::create([
'distributor_id' => $sponsor_id,
'order_id' => $order->id(),
'level' => $level,
'amount' => $amount,
'cycle' => date('Y-m-d', strtotime('this Sunday')),
])->save();
}
}
Performance footnote
For binary plans on a 1,500-distributor production deployment, commission calculation per order takes 30 to 80ms once the upline is cached. The first calculation in a session is slower (300 to 500ms) because it hits the database for the upline tree. Cache invalidation is tied to the hook_entity_update handler on Distributor; sponsor changes flush the cache for that subtree.
Order completion to commission writes uses a single transaction. If the calculation throws, the order completion rolls back, which is the correct behavior for production but worth knowing about when debugging.
Where this pattern falls down
Three predictable walls. Hybrid plans are the first; the abstraction in CompPlanRule starts breaking when you need binary fast-start plus unilevel residual on the same order, with rank-advancement bonuses overlaying both. The second is multi-currency; doing FX conversion and country-specific tax inside the entity hooks is doable but adds enough complexity that you start wishing the platform was a SaaS that handled it. The third is mobile distributor experience; native iOS and Android apps that talk to the Drupal entities are a substantial separate engineering project that isn't part of this pattern.
When any of those three surfaces, the custom-module path has run its course. The migration to Pattern B (Drupal Commerce front, SaaS MLM operations back) is a 4 to 6 week project at the scale where these walls hit; it's not painful if you start the migration before the operational pressure forces it.
Pattern A in production
We've seen this pattern run cleanly through $1.5M GMV on Drupal 10. Above that, every team we've watched eventually moved to Pattern B for the comp plan flexibility and to free their Drupal team to work on the editorial and storefront layer rather than maintaining commission code.