One Core, Many Campaigns: Why Forking the Working Flow Per Tenant Always Drifts
When a product runs many campaigns or tenants, copying the working flow per campaign is the default, and the copies drift. A reliability fix lands in whichever copy somebody was looking at when the bug was reported. Eventually the same inbound event is handled differently depending on which campaign produced it, and triage begins with asking which one. This covers one core plus configuration as data, the test for whether a difference is data or a capability, and how to tell you have already forked.
One Core, Many Campaigns: Why Forking the Working Flow Per Tenant Always Drifts
The short answer. A platform that runs many campaigns, funds or tenants will be asked to launch the next one quickly, and the fastest way is to copy the flow that works and change the branding. That is the default, it happens without anybody deciding it, and the copies drift. The mechanism is specific: the next reliability fix lands in the copy whose bug was reported, because that is the minimal correct change. The other copies keep the bug, nothing goes red, and after a few rounds the same inbound payment event is handled differently depending on which campaign produced it. The alternative is one core that handles money, idempotency and the ledger, with every campaign difference expressed as configuration data rather than as code.
A donation platform runs a handful of campaigns. Each one has a form, a payment integration, a webhook handler, a receipt template and a thank-you flow.
The next campaign is launching in two weeks and needs different amount presets, different wording and a different destination fund. Somebody copies the folder, edits the strings, and ships on time. It is the right call under that deadline, by any reasonable measure available in that week.
Three months later the platform has six copies and a question nobody can answer: if a payment webhook arrives twice, which campaigns double-count it?
Forking feels safer because the risk it reduces is visible
The appeal of a fork is genuine and worth stating fairly, because dismissing it is how this advice gets ignored.
A fork has zero blast radius today. You cannot break campaign A by editing campaign B's copy. That property is felt immediately, in the deploy, by the person who has to press the button on a Friday. It is a real reduction in a real risk.
The same fork has unbounded blast radius later. You cannot fix campaign A by fixing campaign B's copy either, and that property is felt months later, during an incident, usually by somebody who was not in the room when the copy was made. The cost is deferred, diffuse, and lands on a different person.
That asymmetry is the whole story. Both properties follow from the same fact, isolation, and one of them is visible at the moment of decision while the other is not. The second copy is nearly free. The sixth copy is where the money went, and nothing happened between them that anybody would have logged as a decision.
Drift is not a decision, it is three mechanisms
Nobody chooses to diverge. Divergence arrives through ordinary good behaviour.
The reliability fix. A payment event is delivered twice and a donor is credited twice. Somebody investigates, finds the handler lacks a duplicate check, and adds one. They add it in the copy where the bug was reported, which is both the minimal change and the lowest-risk change, and both of those are virtues. The other five handlers still double-count. No test fails, because each copy has its own tests and each copy's tests pass. The platform now has one correct handler and five that are wrong in a way that only appears under a retry.
- A payment event is delivered twiceordinary provider behaviour, not an incident in itself
- A donor is credited twicereported against one campaign, because that is the one somebody noticed
- The duplicate check is added to that copythe minimal correct change, made by somebody behaving well
- Every test suite stays greeneach copy tests its own code, so nothing anywhere reports that five handlers still lack the check
- The same event is handled six different waystriage now begins with asking which campaign, and every statement about behaviour has to be qualified per copy
The urgent campaign change. Campaign four needs different receipt wording for a tax reason, with a deadline. Edit copy four. Copy four is now the one with the new wording and also one of the five that never received last month's duplicate check. The divergence is two-dimensional and nobody is tracking either axis.
The dependency bump. Someone upgrades the payment provider's client library across the repository and catches four copies out of six, because two of them had the import written slightly differently and did not come up in the search. Now two campaigns talk to the provider through an older client with different retry defaults.
The end state is the one that matters. The same inbound event, from the same provider, with the same payload, is handled differently depending on which campaign produced it. Incident triage therefore begins with "which campaign", and every statement anybody makes about system behaviour has to be qualified per copy. At that point there is no system. There are six systems sharing a logo and a database.
One core plus configuration as data
The structure that holds.
The core owns the consequential path. One code path for taking a payment, establishing idempotency, writing the ledger entry, issuing the receipt, handling retries and reconciling against the provider. One deploy of it, one test suite over it, one answer to how it behaves.
A campaign is a row, not a directory. Branding, copy, currency, amount presets, permitted payment methods, start and end dates, destination fund, receipt template identifier, and a small set of behaviour flags. Launching a campaign means inserting a row and uploading assets. It does not mean a code change and it does not mean a release.
Configuration is data, with everything that implies. Validated against a schema on write, versioned, with an audit trail recording who changed which field when, and deployable independently of code. A campaign manager changing amount presets should not be in your deployment pipeline, and your deployment pipeline should not be the audit trail for a business decision.
A difference is either data or a core capability. This is the rule that does the work. If campaign four needs behaviour the core cannot express, you add that capability to the core behind a configuration field. You do not fork. It is more work this week, every time, and it is the only thing that keeps the count at one.
Configuration as data has its own failure mode and it is worth naming rather than pretending otherwise. Flags multiply, and the number of combinations exceeds anything you have tested. Three mitigations keep it tolerable. Keep the flag count small enough to read in one screen. Give flags an expiry and a named owner, so a flag added for one campaign in one quarter does not survive as permanent branching logic. And test the configurations that actually exist rather than all the ones that could: a test that enumerates live campaign rows and runs the core against each is cheap, runs in continuous integration, and covers the combinations you are genuinely exposed to.
The test for whether a difference is configuration
Ask whether the difference changes the sequence of steps or only the values inside a step.
Different amount presets, different currency, different receipt template, different destination fund: same steps, different values. That is configuration.
A campaign that requires an eligibility check before the charge, where others do not, changes the sequence. That is a core capability. Add it to the core as a named, finite mode rather than as a boolean somebody introduces in a hurry, because booleans accumulate and modes can be enumerated and tested.
There is a disguised version of forking worth watching for. If you find a conditional on a campaign identifier inside core code, that is a fork which has moved into a single file. It has all the drift properties of a copied directory with the added advantage of being harder to count. The core should branch on capability and configuration, never on which tenant is calling.
How to tell whether you have already forked
Six checks, in increasing order of discomfort.
Search for a distinctive string from the payment handler. Any hit outside the tests beyond the first one is a second handler.
Find your most recent reliability fix and check every copy for it. This is the diagnostic that produces the silence in the room. If the fix exists in one place and the behaviour exists in six, you have five known-wrong paths in production right now.
Count the code paths that can write a ledger entry. The answer should be one.
Check whether launching a campaign requires a deploy. If it does, the code is your configuration store and you have a fork per campaign by construction.
Ask whoever is on call what their first triage question is. If it is "which campaign", they have already learned the answer the architecture diagram does not show.
Diff the dependency manifests and lock files across copies. Version skew between copies of the same flow means they are talking to the same provider with different behaviour, which is the hardest class of difference to reason about because none of it is in your code.
Unforking without a rewrite
You do not need a rewrite and you should not attempt one. Work inward from the money.
Pick the single most consequential behaviour, which is almost always the handling of the inbound payment event. Extract exactly that into one module, with one duplicate-detection mechanism and one ledger write, and leave everything else forked. Route all copies through it. Delete the per-copy versions of that behaviour so there is no path back. Then pick the next behaviour and repeat.
The direction matters. Strangle inward from the ledger rather than outward from the interface, because the interface differences are the legitimate ones. Branding and copy should stay per-campaign forever. The money path should never have been copied in the first place, and it is also the part where unification pays off fastest, because it is where duplicated bugs cost actual money.
The honest limit
Some forks are correct. A campaign operating under a different regulatory regime, with different record-keeping and different consumer protections, may genuinely be a different product. A copy made deliberately to freeze behaviour for an audit period is a legitimate artefact. And two offerings that share a brand and nothing else should not be forced into one core just because the marketing site is the same.
One core also means one blast radius, and that is a real cost rather than a rhetorical one. A bad deploy now touches every campaign at once, which the forked version did not. Consolidation therefore comes with an obligation: staged rollout, fast rollback, and the ability to disable a capability without a release. If you unify the code without acquiring that discipline, you have traded a slow chronic problem for a fast acute one.
And unification has a floor. If two campaigns genuinely differ in their money semantics, forcing them through one core produces a configuration surface that is harder to reason about than two codebases would be. The test is whether you can read the configuration and predict the behaviour. When you cannot, the abstraction has stopped paying and the honest move is to split deliberately, with the split documented, rather than to keep adding flags.
FAQ
Why do copied per-tenant flows drift even when nobody intends it? Because the next reliability fix lands in the copy whose bug was reported, which is the minimal and lowest-risk change and therefore the right one to make. The other copies retain the bug and no test fails, since each copy's tests pass against its own code. Add an urgent per-tenant change and a partial dependency upgrade and the divergence becomes multi-dimensional with nobody tracking any axis.
What should live in the core and what in configuration? The core owns the payment path, idempotency, the ledger write, receipt issuance, retries and reconciliation, with exactly one code path for each. Configuration holds branding, copy, currency, amount presets, permitted payment methods, dates, destination fund and template identifiers, as validated and versioned data. Launching a tenant should be a row, not a directory and a deploy.
How do I decide whether a difference is configuration or a code change? Ask whether it changes the sequence of steps or only the values inside a step. Different presets or templates are values, so they are configuration. A tenant needing an extra eligibility check before the charge changes the sequence, so it is a core capability and should be added as a named finite mode rather than a new boolean.
How can I tell whether my platform has already forked? Search for a distinctive string from the payment handler and count the hits outside tests. Then take your most recent reliability fix and check whether it exists in every copy, which is the check that usually settles the argument. Also count the code paths that can write a ledger entry, and ask on-call whether their first triage question is which tenant.
Is a conditional on the tenant identifier in shared code acceptable? No, it is a fork that has moved into one file, with the same drift behaviour and the added disadvantage of being harder to count. The core should branch on configuration values and named capabilities, never on which tenant is calling, because a tenant identifier in a condition cannot be enumerated, tested or retired.
What is the cost of consolidating onto one core? A single blast radius. A bad deploy reaches every tenant at once, which the forked arrangement avoided, so consolidation obliges you to add staged rollout, fast rollback and the ability to disable a capability without a release. Unify the code without acquiring that discipline and you have swapped a slow chronic problem for a fast acute one.
Tell us the system, the stakes, and the date that matters. You get a straight technical reply from the person who would lead the work, within 24 hours.
Bring us the program