Designing a Real-Time Automated Expense Categorization System
A production-grade system design walkthrough for classifying personal finance transactions — groceries, dining, transportation, and beyond — into categories the instant they happen, at the scale of millions of transactions per minute.
Introduction & History
Every swipe, tap, and UPI push is really two events fused into one — the money moves, and somewhere downstream a decision has to be made about what that money was actually spent on. This chapter unpacks why that second question turned into an interesting distributed-systems problem.
Every time you swipe a card, tap to pay, or move money through UPI, a small decision has to be made somewhere downstream: what was this money spent on? Was it groceries, a restaurant bill, a cab ride, or a subscription renewal? For a human looking at a bank statement, this decision feels trivial — you glance at a merchant name and instantly know. But for a system that has to make this decision automatically, correctly, and within a second or two, for hundreds of millions of transactions flowing in from banks, card networks, and payment processors around the clock, this becomes one of the more interesting distributed systems and machine learning problems in consumer fintech.
Expense categorization did not start as a real-time problem. In the early 2000s, personal finance tools like Quicken and early versions of Mint relied on nightly batch jobs. A transaction would land in a database, and once a day a job would run, look at merchant descriptions, apply a set of hand-written rules, and tag transactions with categories. Users would see their categorized spending only the next morning. This was acceptable because banks themselves only settled and reported transactions in batches, often with a full day of lag.
Two things changed this world. First, payment rails became faster. Card authorization networks, UPI, and open banking APIs started exposing transaction events within seconds of them happening, not the next business day. Second, user expectations changed. People now expect their finance app to behave like a notification-driven experience — a purchase happens, and within moments a push notification says “₹450 spent at Café Delight — Dining” complete with a budget update. Batch categorization overnight simply cannot deliver this experience anymore.
This shift — from nightly batch classification to streaming, sub-second, automated categorization — is what this tutorial is about. We will design a system that ingests transaction events as they occur, classifies them into categories using a combination of deterministic rules and machine learning models, handles ambiguity and low-confidence predictions gracefully, and scales to the transaction volume of a large consumer finance app serving tens of millions of users.
Along the way we will cover the architecture, the internal working of the classification engine, data flow, trade-offs, scalability, reliability, security, observability, deployment, the data layer, the API and microservice boundaries, relevant design patterns, common mistakes, and how large real-world companies like Mint (Intuit), Plaid, Revolut, and Cred have approached similar problems.
Nightly batch categorization
Personal finance tools like Quicken and early Mint tag transactions once a day using hand-written rules over merchant descriptions. Users see their categorized spending the next morning — acceptable because banks themselves settle in batches with a day of lag.
Faster payment rails arrive
Card authorization networks, UPI, and open banking APIs start exposing transaction events within seconds of them happening rather than the next business day, making it technically possible to categorize in real time.
Notification-driven finance apps
Consumer expectations flip. A purchase happens and users expect an instant push notification with the merchant name, amount, category, and a budget update — batch categorization overnight cannot deliver that experience anymore.
Streaming pipelines + ML classifiers become the default
Kafka-style event streams paired with a rule-first, ML-second classifier become the standard architecture across neobanks, aggregators like Plaid, and rewards apps — independently converged on because the cost/latency/explainability constraints are the same regardless of company size.
Personalization + explainability become table stakes
Confidence thresholding, per-user override rules, and traceable categorization decisions move from research features to product baselines, because reward-optimization apps and budgeting apps both surface the category directly in the user experience.
Think of this system as a very fast, very consistent postal sorting office. Every letter (transaction) arrives with a label written by someone else (the merchant description from the bank), and the sorting office’s job is to read that label — even when it is smudged, abbreviated, or in a different alphabet — and drop the letter into the correct bin (category) before the next batch of trucks leaves. Unlike a postal worker, this sorting office never sleeps, never gets tired, and has to make a decision in well under a second, thousands of times per second.
1.1 Why This Problem Is Harder Than It Looks
On the surface, expense categorization looks like a simple lookup problem: match a merchant name to a category, done. In practice, several forces make it genuinely difficult at scale. Merchant descriptions arriving from banks are notoriously inconsistent — the same physical store might appear as “AMAZON.IN”, “AMZN MKTP IN”, or a truncated, encoding-mangled variant depending on which payment processor and acquiring bank handled the transaction. A single merchant name can also represent multiple real-world categories: a large supermarket chain that also sells electronics and clothing under one storefront cannot be categorized purely by name — the amount, time of day, or item-level detail (when available) often matters more than the merchant string itself.
There is also a strong element of personalization baked into what “correct” even means. Two different users spending money at the same coffee shop chain might categorize it differently — one treats it as a daily commute expense because they work from the café, another treats it strictly as dining out. A well-designed system has to balance a global, statistically sound default categorization against each individual user’s own preferences and past corrections, without those two forces fighting each other in confusing ways.
Finally, the real-time constraint changes the engineering calculus substantially compared to a batch system. A batch job that runs once nightly can afford to do expensive joins, wait for slow external enrichment calls, and retry failures leisurely. A real-time system serving a push notification within a couple of seconds of a purchase has none of that luxury — every millisecond of added latency in the enrichment or classification path is a millisecond the user notices, or worse, a delay that causes the notification to arrive so long after the purchase that it feels disconnected from the moment, undermining the entire point of building it in real time in the first place.
Architecture & Components
At a high level, the system is a streaming pipeline with a machine learning classification core, sitting between the “transaction ingestion” world and the “user-facing” world. Let’s lay out the major building blocks before diving into how data moves through them.
2.1 Core Components
- Transaction Ingestion Gateway: Receives raw transaction events from banks, card networks (Visa/Mastercard/RuPay), UPI switches, and open banking aggregators (like Plaid-style connectors). Normalises them into a common event schema.
- API Gateway: Public-facing entry point for mobile/web clients and partner banks pushing webhook-based transaction notifications. Handles authentication, rate limiting, and request routing.
- Event Stream (Message Broker): A durable, partitioned log (such as Kafka or Pulsar) that decouples ingestion from processing and allows multiple consumers to process the same transaction stream independently.
- Enrichment Service: Augments a raw transaction with merchant metadata — cleaned merchant name, merchant category code (MCC), geolocation, and historical purchase context for that user.
- Rule Engine: A fast, deterministic layer that applies high-confidence rules (for example, MCC code 5411 almost always means groceries) before anything touches a machine learning model.
- ML Classification Service: A model-serving layer that takes enriched transaction features and predicts a category along with a confidence score, for transactions the rule engine could not confidently resolve.
- Feature Store: A low-latency store of precomputed features — user spending history, merchant embeddings, category priors — needed by the ML model at inference time.
- Feedback & Correction Service: Captures user corrections (“this was actually Transportation, not Dining”) and feeds them back for retraining and for immediate per-user override rules.
- Category Assignment Store: The system of record that persists the final category for each transaction, along with confidence and the source of the decision (rule vs. model vs. user override).
- Notification & Budget Service: Consumes categorized transaction events to push real-time notifications and update budget dashboards.
- Model Training Pipeline: An offline/batch pipeline that retrains the classification model periodically using labelled and corrected data.
- Load Balancer: Distributes incoming traffic across stateless service instances of the API gateway and classification service.
2.2 Why a Two-Tier Classification Approach
A common mistake is to assume every transaction needs a machine learning model. In practice, a large fraction of transactions — often 60 to 80 percent in mature systems — can be categorized deterministically using the Merchant Category Code the payment network already attaches to the transaction, or using a merchant name that the system has seen thousands of times before. Only the long tail of ambiguous, new, or poorly labelled merchants genuinely needs a model. This two-tier design (rules first, model second) keeps latency low and cost down, while still handling the hard cases intelligently.
Why not send every transaction straight to the ML model for consistency? The answer is about latency, cost, and explainability. Rule-based decisions are near-instant, cheap, and easy to explain to a user (“categorized as Groceries because the merchant code indicates a supermarket”). Reserving the model for ambiguous cases keeps p99 latency low and keeps inference infrastructure costs proportional to actual difficulty rather than total volume.
Internal Working
Let’s go one level deeper into how a single transaction is actually classified, since this is where most of the interesting engineering and machine learning decisions live.
3.1 Feature Extraction
When a transaction arrives, the enrichment service builds a feature vector before any decision is made. Typical features include:
- Merchant Category Code (MCC): A four-digit code assigned by card networks describing the merchant’s primary business (5411 for grocery stores, 5812 for restaurants, 4121 for taxis and rideshare, and so on).
- Cleaned merchant name: Raw descriptors from banks are messy — “SQ *CAFE DELIGHT MUM” needs to be normalised to “Cafe Delight” using text normalisation and a merchant name resolution service.
- Transaction amount and currency: Amount ranges correlate with category — a ₹150 transaction at an ambiguous merchant is more likely dining than a ₹15,000 one.
- Geolocation: Latitude and longitude of the point of sale, when available, help disambiguate merchants with generic names.
- Time of day and day of week: A 8 AM transaction is statistically more likely to be commute-related; a 9 PM transaction near a residential area is more likely dining or groceries.
- User’s historical category distribution: If this specific user has categorized “Cafe Delight” as Dining twenty times before, that personal history is a very strong signal.
- Merchant embedding: A dense vector representation of the merchant name learned from a large corpus of transaction descriptions, capturing semantic similarity between merchants even when exact names differ.
3.2 The Rule Engine Layer
The rule engine evaluates a prioritised list of deterministic checks:
- User override rules: If the user has previously and explicitly corrected this exact merchant to a category, that mapping always wins. This is the highest-priority rule because it represents ground truth for that specific user.
- Exact merchant match: If the cleaned merchant name matches a curated, high-confidence merchant-to-category mapping table (built from millions of prior transactions and manual review), assign that category directly.
- MCC-based mapping: If no merchant-specific match exists, but the MCC code maps unambiguously to a category (a small number of MCCs are genuinely ambiguous, like MCC 5999 “miscellaneous retail”), assign the category from the MCC mapping table.
- Fallback to ML: If none of the above produce a confident match, the transaction is routed to the ML classification service.
3.3 The Machine Learning Classifier
For the transactions that reach the model, the system typically uses a gradient-boosted tree model (such as XGBoost or LightGBM) or a lightweight transformer-based text classifier operating on the merchant description, depending on the maturity of the platform. Gradient-boosted trees are popular here because they handle a mix of categorical (MCC, day-of-week) and numeric (amount, historical ratios) features extremely well, train fast, and are cheap to serve at low latency. A more advanced setup layers a small transformer-based text encoder on top of the merchant description string, generating an embedding that is then fed into the tree model or a shallow neural classifier alongside the other structured features.
The model outputs a probability distribution over categories (Groceries, Dining, Transportation, Entertainment, Utilities, Shopping, Health, Travel, and so on) and the system picks the top category along with its confidence score.
3.4 Confidence Thresholding and Human-in-the-Loop
A model prediction is not automatically trusted. The system applies a confidence threshold, typically tuned per category since some categories are inherently harder to distinguish (Dining vs. Entertainment for a bar-restaurant hybrid, for instance):
Auto-assign, notify user
Confidence comfortably above threshold: assign the category, push the notification, and update the budget with no additional prompt.
Auto-assign, soft-confirm
Assign but flag the transaction with a subtle “categorized automatically — tap to confirm” affordance in the UI, encouraging a lightweight correction.
Uncategorized + explicit prompt
Assign a default “Uncategorized” or “Other” bucket and prompt the user directly to pick a category, which becomes a strong training signal.
How do you avoid the model becoming overconfident on merchants it has never seen? This is addressed through calibration — techniques like Platt scaling or isotonic regression applied after training so that a reported confidence of 0.9 genuinely corresponds to roughly 90 percent empirical accuracy. Without calibration, tree-based models especially tend to output overconfident probabilities, which would push borderline predictions above the auto-assign threshold incorrectly.
The rule engine and ML model work like a triage desk in a busy clinic. Most patients (transactions) have an obvious, well-documented condition (merchant), and a nurse (rule engine) can process them in seconds using a checklist. Only the genuinely unclear cases get escalated to a doctor (the ML model), who has more tools available but also takes longer and costs more per consultation.
3.5 Concurrency and Consistency Within a User’s History
Because per-user historical features (like “this user’s last twenty transactions at this merchant were all categorized as Dining”) directly influence classification decisions, the system needs to guarantee that transactions for the same user are processed in a consistent order, and that a read of a user’s history during feature extraction reflects a stable, coherent view rather than a half-updated one. This is handled by partitioning the event stream by user ID, so a single consumer instance owns the complete, ordered stream of events for any given user at any point in time — eliminating the possibility of two concurrent processes racing to update the same user’s feature aggregates and producing an inconsistent result.
Within the feature store itself, updates to a user’s rolling category distribution use atomic increment operations rather than a full read-modify-write cycle, both for performance and to avoid lost updates under concurrent access. For the rarer case where a correction needs to retroactively adjust historical aggregates — for example, if a user bulk-corrects fifteen old transactions from one merchant — the system queues these as a background reconciliation job rather than attempting the adjustment synchronously in the hot path, since retroactive corrections are not latency-sensitive in the way live categorization is.
3.6 Handling Multi-Category and Split Transactions
Not every transaction fits neatly into a single bucket. A large supermarket receipt might legitimately span groceries, household goods, and personal care items in a single charge. Most systems handle this pragmatically by assigning a single dominant category per transaction based on the merchant’s primary business classification, while offering users the ability to manually split a transaction into multiple category line items after the fact — a feature that trades a small amount of upfront automation for correctness in the minority of cases where a single-category assumption breaks down, without complicating the core real-time classification logic for the vast majority of transactions that genuinely are single-category.
Data Flow & Lifecycle
Let’s trace a single transaction end-to-end, from the moment a card is swiped to the moment the user sees a categorized entry in their app.
- A user pays ₹620 at a supermarket. The card network authorizes the payment and, within seconds, sends a transaction notification to the bank, which forwards it (directly or via an open banking aggregator) to the ingestion gateway as a webhook or streamed event.
- The ingestion gateway validates the payload, normalises it into the internal transaction schema, and publishes it onto a partitioned topic in the event stream, partitioned by user ID so that all of one user’s transactions are processed in order by the same consumer.
- The enrichment service consumes the event, resolves the merchant name, pulls the user’s historical spending features from the feature store, and attaches geolocation and time-based features.
- The rule engine evaluates the enriched transaction. In this example, the MCC code 5411 unambiguously maps to Groceries, so the transaction is assigned instantly without touching the ML model.
- The final category assignment is written to the category assignment store, an event is emitted for downstream consumers, and the notification service pushes “₹620 at FreshMart — Groceries” to the user’s phone, typically within one to three seconds of the original swipe.
- The budget service increments the user’s “Groceries” spend for the month and checks whether this pushes them past a configured budget threshold, triggering an additional alert if so.
- If the user later taps the transaction and changes the category, that correction is captured by the feedback service, immediately creates a user-specific override rule for that merchant, and is queued as a labelled training example for the next model retraining cycle.
4.1 Handling Late and Out-of-Order Events
Not every transaction arrives cleanly. Some banks send a “pending” authorization event followed by a separate “settled” event hours or days later, sometimes with a different, more accurate merchant description. The system treats the transaction record as mutable within its lifecycle: an initial category assignment is made on the pending event for immediate user feedback, and the record is re-evaluated and potentially recategorized when the settled event arrives, with the user notified only if the category actually changes.
Card networks routinely send an initial authorization with a generic descriptor like “POS PURCHASE” and a settlement a day or two later with the full merchant name. A well-designed categorization pipeline treats the first classification as provisional, keeps a transaction state machine (pending, settled, corrected), and silently upgrades the category on settlement rather than surfacing every intermediate change to the user as a jarring notification.
Advantages, Disadvantages & Trade-offs
No architecture is free. The one described here makes a very deliberate bet on rules-plus-ML hybrid over either extreme, and it is worth naming the trade-offs that bet involves.
| Aspect | Advantage | Trade-off / Disadvantage |
|---|---|---|
| Real-time classification | Immediate, engaging user experience; timely budget alerts | Higher infrastructure cost and engineering complexity than nightly batch jobs |
| Rule-first, ML-second design | Low latency and cost for the majority of transactions | Requires maintaining and curating large rule and MCC mapping tables |
| ML classification for ambiguous cases | Handles novel and messy merchant descriptions gracefully | Model drift over time as merchant naming patterns evolve; requires retraining pipeline |
| Confidence thresholding | Reduces visible misclassification; builds user trust | Increases “Uncategorized” bucket size, which some users find annoying |
| User feedback loop | Continuously improves accuracy; personalises to each user | Feedback can be sparse or noisy; users may mislabel out of habit |
The central trade-off in this system is between accuracy and latency. Every additional signal (deeper user history, geolocation lookups, a heavier model) improves classification accuracy but adds milliseconds of latency and infrastructure cost. Most production systems converge on a design where the p50 latency path is almost entirely rule-based and extremely cheap, while a smaller fraction of traffic absorbs a higher latency, higher accuracy ML path — a classic example of optimising for the common case while still handling the tail correctly.
What happens if you get the confidence threshold wrong? Set it too low, and users see confidently-wrong categorizations, which erodes trust faster than an honest “Uncategorized” label. Set it too high, and too many transactions get dumped into “Uncategorized,” which feels like the system isn’t working at all. Getting this right usually requires an offline evaluation against a held-out labelled dataset combined with an online A/B test measuring the correction rate per threshold.
Performance & Scalability
At the scale of a large personal finance app, the system needs to comfortably handle bursts of millions of transaction events per minute — think of the load spikes during festival shopping seasons, salary-day spending, or a major e-commerce sale event, where transaction volume can jump five to ten times the normal baseline within minutes.
6.1 Horizontal Scaling of Stateless Services
The ingestion gateway, enrichment service, rule engine, and ML classification service are all designed to be stateless and horizontally scalable. Each service instance can process any transaction independently, so scaling out simply means adding more instances behind the load balancer. Auto-scaling policies are typically driven by consumer lag on the event stream (how far behind the latest published message the consumers are) rather than raw CPU usage alone, since lag is a more direct signal of whether the pipeline is keeping up with real-world transaction volume.
6.2 Partitioning Strategy
The event stream is partitioned by user ID. This achieves two things simultaneously: it spreads load evenly across many partitions (assuming a reasonably uniform distribution of transactions per user), and it guarantees ordering of transactions within a single user’s history, which matters because per-user historical features and override rules depend on strict chronological processing.
6.3 Batching at the ML Layer
Rather than invoking the ML model once per transaction, the classification service uses micro-batching: it collects transactions arriving within a small window (for example, 20 to 50 milliseconds) into a batch and runs a single vectorised inference call. This dramatically improves GPU or CPU utilisation for the model server without meaningfully increasing per-transaction latency, since the batching window is far smaller than the acceptable end-to-end latency budget.
6.4 Caching Hot Merchant Lookups
A small number of merchants (large supermarket chains, popular ride-hailing apps, common utility billers) account for a disproportionate share of all transactions. Their merchant-to-category mappings are cached in-memory across all rule engine instances, avoiding a database round-trip for the overwhelming majority of lookups.
How would you handle a 10x traffic spike during a flash sale without over-provisioning permanently? This is a good place to discuss predictive auto-scaling based on known calendar events (paydays, festivals) combined with reactive auto-scaling on consumer lag, plus load shedding: if the ML service becomes a bottleneck, transactions can temporarily fall back to a coarser rule-only categorization with a background reprocessing pass once load subsides, trading a small, temporary accuracy dip for sustained availability.
6.5 Cost Optimisation at Scale
At the volume of hundreds of millions of transactions per day, small per-transaction inefficiencies compound into significant infrastructure spend, so cost optimisation becomes a genuine architectural concern rather than an afterthought. Because the rule engine resolves the large majority of transactions, the ML classification service — typically the most expensive component per transaction, especially if GPU-backed — only needs to be provisioned for a fraction of total volume, which is itself a major cost lever baked directly into the two-tier design described earlier.
Beyond that structural choice, teams commonly apply a few further techniques: right-sizing model complexity so that a smaller, well-tuned gradient-boosted tree model handling the bulk of ambiguous cases is preferred over a much larger neural network unless the accuracy gain clearly justifies the extra serving cost; using spot or preemptible compute instances for the offline model training pipeline, which can tolerate interruption far more easily than the live serving path; and tiering the feature store so that only the hottest, most frequently accessed user and merchant features live in the most expensive in-memory tier, with colder, less frequently accessed historical features pushed to cheaper storage and pulled in only when needed.
6.6 Capacity Planning
Capacity planning for this kind of pipeline typically starts from a peak transactions-per-second target derived from historical spikes (salary days, major sale events, festival seasons) with a safety margin, then works backward to size each stage of the pipeline independently, since each stage has a different cost profile per unit of throughput. The ingestion gateway and rule engine, being cheap and stateless, are typically over-provisioned generously relative to baseline load, since the cost of extra headroom there is low. The ML classification service is sized more tightly against its actual measured share of traffic, with auto-scaling handling the remaining variance, since over-provisioning an expensive tier for a rare peak is a much larger sustained cost than briefly relying on auto-scaling and graceful degradation during that peak.
High Availability & Reliability
A financial application cannot silently drop a transaction. Even if categorization is delayed, the transaction itself must never be lost, since users rely on the app to reflect their actual spending accurately.
7.1 Durable Event Log as the Source of Truth
Using a durable, replicated message broker as the backbone of ingestion means that even if every downstream consumer (enrichment, rule engine, ML service) crashes simultaneously, the raw transaction events remain safely persisted and replicated across broker nodes. Consumers simply resume from their last committed offset once they recover, guaranteeing no data loss.
7.2 Graceful Degradation of the ML Path
If the ML classification service becomes unavailable or overloaded, the system should not block or fail the entire pipeline. Instead, transactions that would have gone to the model fall back to an “Uncategorized” state with the raw transaction still visible to the user, and a background job re-attempts classification once the service recovers. The user experience degrades gracefully — they see their spending immediately, just without a category for a short period — rather than the app appearing broken.
7.3 Multi-Region Considerations
For apps operating across regions with data residency requirements, the category assignment store and feature store are often deployed regionally, with the event stream also regionalised per data residency zone, while the model artifacts themselves (which contain no personal data) are replicated globally so that the same model version serves every region consistently.
Large-scale payment platforms design their fraud and categorization pipelines with the explicit principle that a downstream ML service outage should never block a transaction from being recorded and shown to the user. The categorization can always be backfilled later; a missing or delayed transaction record cannot be, because user trust in the core ledger is non-negotiable.
Security
Financial transaction data is among the most sensitive data a consumer application handles, and the categorization pipeline touches every single transaction, making security a first-class design concern rather than an afterthought.
8.1 Data Protection
- Encryption in transit: All communication between banks, the ingestion gateway, internal services, and clients uses TLS 1.2 or higher.
- Encryption at rest: Transaction records, merchant details, and user override rules stored in the category assignment store and feature store are encrypted at rest using envelope encryption with keys managed by a dedicated key management service.
- Tokenisation of sensitive fields: Card numbers or account identifiers are never stored in the categorization pipeline directly; a tokenised, non-reversible reference is used instead, with the actual mapping held only in a tightly access-controlled vault service.
- PII minimisation in the ML pipeline: Features fed into the model are stripped of directly identifying information; user identity is represented only as an internal, rotating pseudonymous ID within model training data.
8.2 Access Control
Every internal service authenticates to every other service using short-lived, mutually authenticated credentials (mTLS with service identity, or signed short-lived tokens), following a zero-trust model rather than relying on network location as a proxy for trust. Engineers accessing production category assignment data for debugging go through an audited, time-boxed access request process rather than standing access.
8.3 Abuse and Data Integrity
The ingestion gateway validates that incoming transaction events come from an authenticated, allow-listed source (a specific bank integration or aggregator partner), preventing spoofed transaction events from being injected into a user’s history. Idempotency keys on every transaction event ensure that network retries or duplicate webhook deliveries do not create duplicate transactions or double-counted budget spend.
How do you prevent a malicious actor from injecting fake transactions to manipulate a user’s budget alerts? The answer combines authenticated, allow-listed ingestion sources, signed payloads from banking partners that are cryptographically verified before processing, and anomaly detection on unusual patterns (a sudden burst of transactions from a source, or amounts inconsistent with the account’s typical behavior) that can quarantine suspicious events for manual review before they reach the user-facing store.
8.4 Regulatory and Compliance Considerations
A system that touches every transaction of every user is squarely within the scope of financial data protection regulation, and design decisions need to account for this from the start rather than being retrofitted later. Data residency requirements in many jurisdictions require that transaction-level data for a user never leave their home region’s infrastructure, which directly shapes the multi-region deployment approach discussed later in this tutorial. Consent management matters too: since categorization relies on analysing the full content of a user’s transaction history, the system needs clear, auditable records of what data processing the user has consented to, particularly when that data might also be used in aggregate, anonymised form to improve the shared classification model across all users.
Right-to-be-forgotten style requirements also intersect directly with the machine learning pipeline, not just the raw data store. A user’s transaction history can influence the model through the training pipeline, which means a genuinely complete data deletion process has to account for whether individually identifiable influence remains embedded in a trained model — in practice, most systems address this by ensuring only aggregated, anonymised statistics (never raw individual records) ever enter model training, so that deleting the raw record from the operational stores is sufficient without needing to retrain or “unlearn” from already-trained models.
Monitoring, Logging & Metrics
Because this system directly affects what users see about their own money, observability needs to answer two distinct questions continuously: is the system healthy, and is the system accurate?
9.1 System Health Metrics
- End-to-end latency: p50, p95, and p99 latency from transaction ingestion to category assignment, tracked separately for the rule-only path and the ML path.
- Consumer lag: How far behind real-time each stage of the pipeline is running, which is the earliest warning sign of a capacity problem.
- Error rates: Failed enrichment lookups, model inference timeouts, and downstream store write failures, each tracked independently.
- Throughput: Transactions processed per second at each stage, compared against provisioned capacity.
9.2 Model and Categorization Quality Metrics
- Correction rate: The percentage of auto-categorized transactions that users manually correct, tracked per category and per confidence bucket — the single most important business-facing accuracy signal.
- Uncategorized rate: The percentage of transactions falling below the confidence threshold, which should trend down over time as the model and merchant mapping tables improve.
- Model drift indicators: Statistical distance between the feature distribution of live traffic and the training data distribution, flagging when the model may need retraining sooner than scheduled.
- Category distribution shift: Sudden, unexplained changes in the proportion of transactions assigned to a given category, which often indicates a bug in a rule or a broken merchant mapping rather than a genuine behavior shift.
9.3 Tracing and Debugging
Every transaction carries a trace ID through the entire pipeline, from ingestion through enrichment, rule evaluation, and (if applicable) ML inference, allowing engineers to reconstruct exactly why a specific transaction received a specific category — which rule fired, or what the model’s top three predicted categories and their probabilities were. This traceability is essential both for debugging and for answering user-facing questions like “why was this categorized as Entertainment?”
Distributed tracing here works like a flight recorder on an aircraft. You hope you never need to open it, but when a user disputes a categorization or an engineer needs to understand a sudden spike in “Uncategorized” transactions, having a complete, ordered record of every decision point for that transaction turns a multi-hour investigation into a five-minute lookup.
Deployment & Cloud Architecture
The system is deployed as a set of independently deployable microservices running in containers orchestrated by Kubernetes (or a managed equivalent), which allows each component to scale, deploy, and fail independently.
10.1 Deployment Strategy
New versions of the rule engine or ML classification service are rolled out using a canary deployment strategy: a small percentage of traffic (often starting at 1 to 5 percent) is routed to the new version, correction-rate and error-rate metrics are compared against the stable version over a defined observation window, and the rollout proceeds automatically only if quality metrics remain within acceptable bounds. This is especially important for ML model deployments, where a subtly worse model can pass all standard health checks while still degrading categorization accuracy.
10.2 Model Deployment as a Separate Lifecycle
The ML model artifact is versioned and deployed independently of the serving code that hosts it. This separation allows the training pipeline to publish a new model version to a model registry, and the serving infrastructure to pick it up through a controlled rollout, without requiring a full service redeployment for every model update — model updates might happen weekly, while service code changes far less often.
10.3 Infrastructure as Code and Environments
The entire infrastructure — Kubernetes manifests, message broker topic configuration, autoscaling policies, and network rules — is defined declaratively and version-controlled, allowing a full staging environment that mirrors production to be spun up for testing rule changes and model versions before they touch real user data.
Context
A newly retrained model can improve headline accuracy on the offline test set while quietly regressing on a small but visible subset of live traffic — a regression that only becomes obvious after real users start correcting it, at which point trust has already eroded.
Decision
Every new model version runs in shadow against live traffic for a defined observation window before it is allowed to make user-visible decisions. Predictions are logged and compared against the current production model on the same live inputs, so any regression is caught before rollout, not after.
Consequences
Roughly doubles the inference cost during the shadow window, which is accepted because the alternative — catching regressions through user complaints — is significantly more expensive in both engineering time and user trust.
Consumer fintech companies commonly maintain a shadow deployment for new categorization models: the new model runs in parallel with the production model on live traffic, its predictions are logged but never shown to users, and its accuracy is compared against the production model over days or weeks before it is trusted to make real decisions.
Databases, Caching & Load Balancing
The data layer for this system has to serve two very different workloads: an extremely high-throughput write path for incoming transactions and category assignments, and a low-latency read path for the mobile app and budgeting views.
11.1 Choosing the Category Assignment Store
The category assignment store needs to support extremely high write throughput (every transaction generates at least one write), fast point lookups by transaction ID and by user ID with a time range (for displaying a user’s transaction history), and eventual analytical queries for aggregating monthly spend per category. A wide-column or document-oriented NoSQL database (such as Cassandra or DynamoDB-style stores) is typically favoured here over a traditional relational database, because it scales writes horizontally far more naturally and the access patterns are largely key-based rather than requiring complex joins.
11.2 The Feature Store
The feature store needs sub-10-millisecond read latency, since it sits directly in the hot path of every classification decision. An in-memory key-value store (such as Redis) backed by a durable store for recovery is the standard choice, holding precomputed aggregates like “this user’s category distribution over the last 90 days” or “this merchant’s global category distribution,” refreshed by a streaming or scheduled batch job rather than computed on the fly per request.
11.3 Caching Layers
| Cache | Purpose | Invalidation Strategy |
|---|---|---|
| Merchant mapping cache | Avoid repeated lookups for frequent merchants | Time-based expiry plus explicit invalidation on manual mapping updates |
| User override cache | Fast access to a user’s personal correction rules | Write-through cache, updated immediately on correction |
| Model prediction cache | Skip re-inference for identical merchant plus amount-range combinations seen recently | Short TTL (minutes), since context can shift quickly |
11.4 Load Balancing
A layer-7 load balancer sits in front of the API gateway and the ML classification service, distributing traffic using a least-connections or consistent-hashing strategy. Consistent hashing is particularly useful in front of the ML service when request-level caching is in play, since it increases the likelihood that requests for the same merchant land on an instance with a warm cache.
Why not use a relational database for the category assignment store, given how naturally “transactions” fit a relational model? This is a good discussion point: relational databases offer strong consistency and simple joins, but at the write volume of millions of transactions per minute across a huge user base, horizontal write scaling and operational simplicity of a distributed NoSQL store usually outweigh the convenience of joins, especially since the query patterns here are overwhelmingly key-based rather than relational.
APIs & Microservices
The system is decomposed into a small number of well-bounded microservices, each owning its own data and communicating primarily through the event stream rather than synchronous calls, to avoid tight coupling and cascading failures.
12.1 Key API Endpoints
| Endpoint | Purpose |
|---|---|
POST /transactions/ingest | Receives a raw transaction event from a bank or aggregator partner (typically an authenticated webhook) |
GET /transactions/{id}/category | Fetches the current category assignment and confidence for a specific transaction |
PATCH /transactions/{id}/category | Records a user correction to a transaction’s category |
GET /users/{id}/spending-summary | Returns aggregated spend per category for budgeting views, typically served from a precomputed rollup rather than live aggregation |
GET /transactions/{id}/explanation | Returns the reasoning trace behind a categorization decision, for transparency and debugging |
12.2 Synchronous vs. Asynchronous Communication
User-facing reads (fetching a transaction’s category, viewing a spending summary) are synchronous request-response APIs, since a user is actively waiting for a response. Everything in the classification pipeline itself — ingestion through category assignment — is asynchronous and event-driven, since no human is blocked waiting on any single stage, and asynchronous processing is what allows the pipeline to absorb load spikes without cascading backpressure into the client-facing APIs.
Why route the correction API through the feedback service rather than writing directly to the category store? Centralising corrections through a dedicated service ensures every correction consistently triggers the same set of downstream effects — updating the override cache, emitting a training-data event, and updating the category store — rather than relying on every caller to remember to do all three, which is a common source of subtle bugs in systems with multiple write paths to the same data.
Design Patterns & Anti-patterns
A short catalogue of the reusable patterns this system leans on, and the anti-patterns it deliberately avoids.
13.1 Patterns Used
Rule engine cascade
The rule engine’s ordered evaluation (user override, exact merchant match, MCC mapping, ML fallback) is a textbook chain of responsibility, where each handler either resolves the request or passes it further down the chain.
Category-as-event sequence
Category assignments are treated as a sequence of events (initial assignment, recategorization on settlement, user correction) rather than simple in-place mutations, preserving a full audit trail of how a transaction’s category evolved.
Trip on ML failure
Calls from the rule engine to the ML classification service are wrapped in a circuit breaker, so that if the ML service is failing or slow, the system trips the breaker and falls back to the “Uncategorized” path rather than piling up timeouts.
Split write vs read paths
Writes (transaction ingestion and category assignment) flow through the event-driven pipeline, while reads (spending summaries) are served from separately optimised, precomputed rollup tables — the two paths are deliberately not the same code path.
Rule table evolution
As the merchant mapping table grows and needs restructuring, new merchant categories are introduced incrementally alongside the old table, with traffic gradually shifted over, rather than a risky big-bang rewrite of the mapping data.
Safe model rollout
New model versions run in parallel with production on live traffic, predictions logged but never surfaced to users, until quality metrics prove the new model is at least as good as the old one.
13.2 Anti-patterns to Avoid
- Synchronous ML calls in the critical ingestion path: Calling the model synchronously as part of the initial transaction write blocks ingestion throughput on model latency — a classic anti-pattern that couples an unrelated concern (accuracy) with an unrelated one (durability).
- A single monolithic “categorize” function: Combining rules, feature extraction, and model inference into one large function makes it impossible to test, scale, or deploy each piece independently, and makes tracing why a decision was made far harder.
- Silent model retraining without shadow evaluation: Deploying a newly retrained model directly to production without first shadow-testing it against live traffic risks a model regression reaching every user simultaneously.
- Treating “Uncategorized” as a failure state to hide: Some teams try to force every transaction into a category even at low confidence to avoid showing “Uncategorized,” which paradoxically damages user trust more than an honest, low-confidence label with an easy correction affordance.
The circuit breaker pattern here is much like a household electrical fuse. When something downstream draws too much current (the ML service is overwhelmed), the fuse trips and cuts the connection before the whole house (the ingestion pipeline) is damaged, rather than letting the fault cascade backward into everything else that depends on power.
Best Practices & Common Mistakes
A distilled checklist of the practices that consistently produce a healthier categorization platform — and the mistakes that most reliably undermine one.
Best Practices
- Keep the deterministic rule layer as the first line of defence, and treat the ML model as a specialist for the genuinely ambiguous tail, not a universal default.
- Calibrate model confidence scores explicitly rather than trusting raw softmax or tree-ensemble outputs, since uncalibrated confidence leads directly to threshold misconfiguration.
- Make every categorization decision explainable and traceable — store which rule or model version made the decision, and expose this to support and debugging tooling.
- Treat user corrections as a first-class product signal, not just training data — a correction should immediately update that user’s experience, not wait for the next model training cycle.
- Design for partial failure everywhere: a slow or down ML service should degrade the experience gracefully rather than blocking transaction visibility entirely.
- Version merchant mapping tables and model artifacts independently, and maintain the ability to roll either back quickly if a deployment introduces regressions.
Common Mistakes
- Ignoring data drift: Merchant naming conventions and consumer spending patterns shift constantly (new merchant brands, new payment aggregator prefixes); a model trained once and never revisited degrades silently over months.
- Over-relying on MCC codes alone: MCC codes are notoriously inconsistent across different acquiring banks and card networks — the same physical merchant type can be coded differently depending on which processor handled the transaction, so MCC should be one strong signal among several, not the sole source of truth.
- Under-investing in merchant name normalisation: A large fraction of categorization errors trace back not to the model being wrong, but to messy, inconsistent raw merchant strings never being cleaned properly before reaching either the rule engine or the model.
- Not separating “system confidence” from “user-facing certainty”: Showing a category with no visual distinction between “we’re 99% sure” and “we’re 55% sure and guessing” removes the user’s ability to know when to double check.
How would you detect that your model has started drifting before users start complaining? This is a good opportunity to discuss monitoring the correction rate trend over time per category, comparing the live feature distribution against the training distribution using a statistical distance metric, and setting up automated alerts when either metric crosses a threshold, rather than relying purely on user complaints as the detection mechanism.
Real-World / Industry Examples
Several well-known fintech products have publicly discussed approaches conceptually aligned with this design, each adapting the core ideas to their own scale and constraints.
15.1 Personal Finance Aggregators
Early personal finance aggregation products popularised rule-based categorization using MCC codes and curated merchant lists as the primary mechanism, only later layering machine learning on top to handle the long tail of unrecognised merchants — a direct precursor to the rule-first, ML-second design described in this tutorial.
15.2 Open Banking Data Platforms
Open banking connectivity platforms that sit between banks and consumer apps typically offer categorization as a value-added service on top of raw transaction data, maintaining massive, continuously updated merchant mapping databases shared across all their downstream client applications, since a single well-maintained mapping table serving millions of end users is far more efficient than every fintech app rebuilding merchant intelligence independently.
15.3 Neobanks and Card-Native Apps
Card-native banking apps that control the entire payment rail — issuing the card, authorizing the transaction, and owning the app experience — have an advantage here: they can categorize transactions at authorization time using richer, real-time merchant data directly from the card network, often achieving categorization latency of well under a second because there is no intermediary bank-to-aggregator hop.
15.4 Credit Card Reward and Insights Apps
Apps that focus heavily on card rewards optimisation rely on very precise category classification, since reward multipliers (extra points on dining, extra cashback on groceries) are directly tied to category accuracy, making the confidence-thresholding and user-correction feedback loop described in this tutorial especially business-critical for that category of product — a miscategorized transaction there isn’t just a UX annoyance, it directly costs the user money in missed rewards.
Rule-based pioneers
Established the pattern of MCC + curated merchant lists as the primary mechanism, with ML layered on later to handle unrecognised merchants.
Categorization-as-a-service
Open banking connectivity platforms offer categorization on top of raw transaction data, sharing a single well-maintained merchant taxonomy across thousands of client apps.
Rail-native neobanks
Categorize at authorization time using richer, real-time merchant data directly from the card network, achieving sub-second latency without an aggregator hop.
Rewards-driven precision
Card rewards optimisation apps invest heavily in category accuracy because reward multipliers turn misclassifications into direct financial cost for the user.
A widely used pattern among mature personal finance platforms is maintaining a shared, centrally curated merchant taxonomy team whose sole job is continuously cleaning and expanding the merchant-to-category mapping table, treating it as a living data asset with the same rigor as production code, complete with review processes and rollback capability, rather than a static lookup table that quietly rots over time.
15.5 Lessons From These Approaches
Looking across these different products, a few consistent lessons emerge that apply regardless of the specific business model. First, merchant data quality is consistently the single highest-leverage investment a team can make — far more categorization errors trace back to messy or missing merchant metadata than to model architecture choices, which is why so many mature platforms invest in dedicated merchant taxonomy curation rather than treating it as purely an engineering problem to be solved once. Second, every one of these products eventually converges on some version of the rule-first, ML-second hybrid design, independently arriving at the same architecture because the underlying constraints — cost, latency, and explainability — are the same regardless of company size. Third, the feedback loop from user corrections back into the system is treated as a product feature in its own right, not just a data pipeline detail, because the speed and friction of correcting a miscategorized transaction directly shapes how much users trust the automation in the first place.
FAQ, Summary & Key Takeaways
The questions that come up most often when engineers first encounter this design, followed by the ideas worth carrying away.
Why not just use a single large language model for every transaction?
A general-purpose large model could technically classify merchant descriptions, but at the volume and latency requirements of millions of transactions per minute, the cost and latency overhead of invoking a large model for every single transaction — including the 70 to 80 percent that a simple rule could resolve instantly — makes it impractical as the default path. A hybrid approach reserves heavier models for the genuinely ambiguous cases.
How is this different from fraud detection, which also classifies transactions in real time?
Fraud detection optimises for catching a small number of dangerous outliers with very low tolerance for false negatives, often willing to accept more false positives (blocking legitimate transactions) as a trade-off. Expense categorization optimises for overall labelling accuracy across the entire distribution of normal spending, with a much higher tolerance for an individual mistake since the consequence of a wrong category is inconvenience, not financial loss.
How often should the ML model be retrained?
This depends on how quickly merchant naming patterns and the user base evolve, but a common cadence is weekly to monthly incremental retraining using freshly collected corrections and newly seen merchants, with a more thorough full retraining and architecture review on a quarterly basis.
What happens to a user’s data if they delete their account?
Category assignment records tied to that user, along with their personal override rules and any features derived from their transaction history, need to be purged from the category store and feature store in line with data retention policy, while any anonymised, aggregated signal already folded into a trained model version is generally retained since it can no longer be traced back to the individual.
Taken together, the ideas in this tutorial describe a system that is as much about disciplined engineering trade-offs as it is about machine learning sophistication — the hardest part of building real-time expense categorization at scale is rarely the model itself, and far more often the surrounding architecture that keeps it fast, cheap, explainable, and gracefully degradable under real-world load.
Key Takeaways
- Real-time expense categorization is fundamentally a streaming systems problem wrapped around a machine learning classification core, not just a modelling problem alone.
- A rule-first, ML-second design keeps the common case fast and cheap while still handling the ambiguous long tail intelligently.
- Confidence thresholding and honest “Uncategorized” states build more user trust than forcing every transaction into a guessed category.
- Graceful degradation of the ML path is essential — a financial app must never lose or hide a transaction just because a downstream model is unavailable.
- User corrections are both an immediate personalisation signal and long-term training data, and the system should treat them as such on both timelines.
- Observability needs to track both system health (latency, throughput, errors) and categorization quality (correction rate, drift) as equally important, continuously monitored signals.
- Event-driven, asynchronous processing with a durable log as the source of truth is what allows the pipeline to absorb massive load spikes without losing data or blocking user-facing reads.
Read this system back at the highest level and it is really a bet on discipline: keep the deterministic path fast and boring, keep the intelligent path narrow and honest about its uncertainty, and make sure neither one can pull the other down. Almost every serious real-world implementation of expense categorization ends up rediscovering the same shape, because the underlying constraints — billions of transactions, sub-second latency, real money in real budgets — do not leave much room for anything more clever.