On October 22, I picked up a referral program halfway through. A first version applied the referral discount by writing it straight into the order line. I refused it. I wanted the discount to go through the voucher system we already have, and the referral to stay tracked.
Some context. I run backend engineering at GoodBarber, an app platform, and our customers pay for their apps through a back office that is a PHP monolith. A referral program has to touch the coupon field at checkout, the payment confirmation, invoices and credit notes, vouchers, the credits we give apps, and renewals, which a cron of our older CMS creates. All of that lives in the monolith. The question was where the referral itself should live.
Between 14:37 and 15:55 that day, I went around five answers.
Five places for the same code
A new table in the shared database, with its model. The obvious answer, and the one I liked least. Our billing models are old, and I was afraid of adding one next to them and breaking something I wouldn’t see break.
The key/value table we already have. It stores attributes per user and per app, and already holds more than rights. The sponsor would get a referral_code row, the referee a referral row holding a small JSON state. No migration. What we'd lose is uniqueness in the database: it would become a SELECT ... FOR UPDATE on the sponsor's row during the reservation, to be proven under concurrency.
A separate service with its own database, built like many of our Python services. The rules would be real constraints again, in a database of their own. But the monolith would still call it at three points: the page that shows the code, the checkout that reserves it, and an endpoint to grant rewards. Reserving a code would become a network call in the middle of the payment path. “One use only” would be split across two systems: the service reserves, the monolith applies the voucher, and if one of them fails, something has to undo the other. And it would be one more thing to deploy, host, monitor and back up, for the first version of a program.
A signed code, with nothing stored up front. The code would carry the sponsor’s app and a signature, and only its use would be stored. The code gets longer, and “which app gets the reward” becomes a computed rule.
The voucher table we already had. The sponsor’s code becomes a voucher. The referee types it at checkout like any promo code, and a cron checks how the code is used and grants the rewards. That one was my call, and it’s the one we shipped.
On the way there, I came back once to the first option with the word KISS and a warning about the model. The voucher table removed the question, since it needed neither a new table nor a migration.
What the voucher table already knew
Most of the rules already had a column. The maximum number of uses is a field the voucher validation already counts. “First subscription only” is a flag the checkout already enforces. The sponsor’s app is the voucher’s owner. Each use is a row in the usage table, which switches to paid when the invoice is created. The cron only has to read paid uses that are old enough to be validated.
When I asked a colleague to review the branch, it had seven commits on top of master: 17 files, 1,654 lines added, 14 removed, and no migration. The program went live on October 23.
Its rules changed three times in the following days, and its texts four. Each rule change was a constant or a cron, plus the texts in twenty languages.
What it cost
The voucher table was never meant to tell one kind of voucher from another. Without a new column, the only thing that marks a referral code is its shape, and other code writes into that table too. A tenant guard in our ORM also had opinions about a code that belongs to one app and is used by another. Those are a separate post.
The questions I still have
- For a program whose rules you expect to change, would you have paid for a separate service from day one?
- When you can’t add a column, is a code’s shape an acceptable marker, or a debt you’ll pay the day someone asks for custom codes?
- Rewards in the payment path or in a cron? We chose a cron, to limit what could break a payment.
- What would make you move it out of the monolith: a number of rules, a second product using it, a team that owns it?
The last time a small feature needed half of your monolith, where did you put it?
Originally published at https://dev.to on November 3, 2026.