CRM relaunch + legacy displacement
A CRM that had been growing for 15 years carried the whole sales operation, and the rules of the funnel were distributed across views, processors, creators, models and signals. This is what we did about it, and in which order.
The codebase as we found it
We opened the engagement with a technical review of the lead system rather than a proposal. We read the layers it runs through — views, processors, creators, models and signals — and marked every place that decides whether a lead may move from one stage of the funnel to the next. We built the entity graph, traced which side effects fire when a lead changes, and worked out how the existing consistency checks are applied.
Two findings set the shape of everything after. The stage of a lead is not stored as a stage: it is derived at read time from a combination of conditions on lead purpose, the product map and several other fields. And one funnel carries residential property, commercial property and investment products at once, so a rule written for one of them constrains the other two.
What that cost the sales floor
- A refused operation produced a runtime exception with no stated cause, so answering “why can this lead not move forward” meant reading Python.
- Adding a product line meant editing conditions in several layers, which is why the investment desk was not using the CRM at all and ran its pipeline outside it.
- Returning buyers accounted for about a third of deals, and every one of them had to be entered as a new lead with a new project attached, because there was no entity above the lead to attach a second purchase to.
- Data arrived late and incomplete, which made the CRM unusable as the source of truth for the questions management actually asks.
Funnel Engine: the funnel as a state machine
The first module we built collects the funnel into one declarative description: the stages, the transitions permitted between them, and the requirements for making each transition. The pattern is a finite state machine, which gives every rule that governs a lead exactly one place to live.
Requirements are expressed as guards of two kinds. A field guard states which data must be present to enter a stage: a lead entering “deposit paid” needs the deposit amount and the documents attached. An authority guard states who may perform the move, so a transition reserved for a head of sales refuses anyone else.
The engine returns structured rejection reasons. Instead of an exception surfacing somewhere up the stack, the caller receives the rule that blocked the move and the data that failed it, which is what makes the system explainable to the people who work in it.
Moving a live monolith onto it
Funnel Engine ships as a module inside the existing Python codebase rather than as a separate service. A separate service would have been cleaner on a diagram and would have introduced a network boundary in the middle of a transaction that a sales department depends on every hour of the working day.
The integration replaces validation rules in mutations, side effects and creators with calls into the engine, one call site at a time, until the engine is the single source of truth for every funnel. Around 95% of the change is localised in the new layer and its API; the remaining 5% touches the monolith directly and is reviewed together with the client’s engineers before each release.
Through all of it the product the sales floor uses stays as it was. The legacy lead system keeps working, the interface does not move under anybody, and the new CRM comes up beside it rather than in place of it.
Deal, Person and Company above the lead
The information architecture moves from lead-based to deal-based, with Person and Company as the entities above the deal. The new models stay backward compatible with the existing lead records, and we added the creators and processors needed to work with them, so both representations are valid at once during the migration.
The practical result is the one the sales floor asked for. Opening a client shows the history of their deals with the context of each, a company with four buyers inside it reads as one company, and a returning buyer is a second deal on a person who already exists.
Workflow Engine: trigger, condition, action
Automations had the same problem the funnel rules had — they were distributed through the code with no single description of what exists. The engine we built has an observer that watches changes and emits events, a queue, and an orchestrator that matches events to declared automations.
The automations themselves address the work that was eating manager time and getting skipped anyway: a call transcript appears and the lead fields are filled from it, with the manager accepting, editing or rejecting each field; a message arriving from a messenger is written into the lead history; documents dropped in raw are classified and filed. Each one is measured by time-to-task, which is the interval between the moment something is knowable and the moment it is recorded.
Configuring a funnel without a release
The editor gives the rules back to the people who own them. A head of sales names a funnel, sets the filter that decides which deals belong to it, lists its stages, and states the data requirements for entering each one.
This is what unblocks the rest. Launching a new product line used to mean fitting it into the structure of the existing code; it now means configuring a funnel. The investment desk needed stages of its own, including a long nurture stage that makes no attempt to sell for three months or more, and that arrangement is a configuration rather than an exception written into the codebase.
How the work runs
- Two-week increments, each reviewed by the client’s own architects before it ships.
- The legacy system stays read-only and free of regressions throughout, so nothing anybody depends on goes missing mid-migration.
- Our engineers work directly in the client codebase alongside the client’s team, which is how the 5% that touches the monolith gets reviewed by the people who know it best.