Why I Built SpendGauge Instead of a Clone — And What That Decision Taught Me About Product Thinking as an Engineer
There is a moment, near the end of every month, when you look at your bank balance and do a specific kind of mental arithmetic. You have some money left. You have some days left. What you need to know right now, without a spreadsheet, without a calculator is how much you can safely spend each day without blowing your budget before the month ends.
It sounds simple since it is not a complicated calculation but, I could not find an app that answered that exact question cleanly, in the way my brain wanted to ask it. Most budgeting apps tell you how much you have spent, a few tell you how much you have left but there is none that divided the remaining balance by the remaining days and put that single number in front of me the moment I opened the app.
So I built SpendGauge.
That is the product origin story, and it matters to the engineering story because the question I was trying to answer, how much can I safely spend today is not a simple read from a database, it requires knowing the budget amount, the current date, the end date of the budget period, and the total of all expenses in that period so far. It requires date arithmetic, accurate aggregation, and a schema that can answer that question efficiently across thousands of users. It forced me, from day one, to think about what the data meant rather than just what it looked like. Earlier this year I had built a todo app for a hobby one weekend this but spendgauge took more time as it was data intensive and sensitive compared to the todo.
What Makes a Financial Domain Different.
A todo app has items, with title, status, maybe a due date however, the data is almost meaningless on its own as its only job is to exist and to be toggled in short nobody's life changes based on whether you store the due date as a string or a timestamp. For a budgeting app things are different as data means something.
When a user records that they spent 4,500 Kenyan shillings on groceries on the 14th of March, that number is real. It connects to a real purchase, a real consequence, a real remaining balance for the rest of the month. If your schema allows that number to be stored incorrectly as a float that silently rounds, as a string that breaks aggregation, as a negative value that corrupts a sum you are not making a technical mistake in isolation. You are corrupting the one thing the user came to the app to trust.
Here is what that meant in practice for SpendGauge.
Decision One — How to Store Money
The first question the domain asked me was one I had never thought about: how do you store a monetary value in a database?
My instinct, shaped by every tutorial I had followed, was to reach for a float. A number is a number. amount: float("amount") and move on. Floating point numbers are stored in binary, and binary cannot represent most decimal fractions exactly. The number 0.1 in floating point is not 0.1, it is 0.1000000000000000055511151231257827021181583404541015625 add enough of these together and the accumulated error becomes visible. Two calculations that should produce the same result can produce different results depending on the order of operations since floating point arithmetic is not associative. For a todo app, this is irrelevant but for a financial dashboard showing a user their total monthly spending, you cannot have a balance that reads 9,999.9999999998 shillings when the real answer is 10,000.
I also considered storing amounts as integers as the amount in the smallest currency unit, so 4,500 shillings becomes 450000 cents. This gives fast integer arithmetic with no rounding. But it meant dividing by 100 on every read, converting on every display, and reasoning in cents everywhere in the codebase. For SpendGauge at this stage, that cognitive overhead was not worth the computational gain.
SpendGauge uses decimal with explicit precision:
amount: decimal("amount", { precision: 12, scale: 2 }).notNull()Twelve digits total, two after the decimal point. Postgres stores this as an exact numeric value so no binary approximation, no rounding. The maximum storable amount is 9,999,999,999.99 in whatever currency the user is working in, which is sufficient for personal budgeting. The two decimal places match how money is actually denominated and the notNull() constraint means an expense without an amount cannot exist.
The validation layer enforces that the amount is positive:
amount: z.string().refine(
(val) => parseFloat(val) > 0,
{ message: "Amount must be greater than zero" }
)A negative expense amount is not just invalid data. In the context of aggregating a user's monthly spending, it would silently reduce their total and produce a safe-to-spend figure that is higher than reality thus from the domain rule I had to validate since the consequence of getting it wrong was visible and specific.
Decision Two — The Category Problem
The natural first instinct for categories in a budgeting app is a categories table. Create a table with id, userId, name, color, and then reference it from expenses and budgets via a foreign key. Users can create their own categories, name them whatever they want, and delete the ones they do not use.I sketched this out but thinking from the finance domain I asked a question I could not easily answer. What happens to an expense when its category is deleted? In a todo app, deleting a list and cascading the delete to its items is acceptable since items are just text but, an expense is a financial record, it happened the money left the account, if a user deletes their "Groceries" category, what do you do with the twelve months of grocery transactions that reference it? By cascading the delete you lose the expense records that is data loss in a financial application. Set the category to null and keep the expenses now the user's historical reports have a mystery bucket of uncategorised spending that used to be categorised, and their year-over-year comparisons are broken. If you prevent deletion if expenses exist, categories accumulate forever and the user cannot clean up their taxonomy, if you allow deletion only after migrating the expenses to another category now you need a migration UI, which is a significant product surface for a feature that is fundamentally about data integrity.
Every option had a real cost, and the cost was paid in financial data accuracy.
So I asked a different question: does SpendGauge need user-defined categories at this stage, or does it need reliable, consistent categorisation across every user's data?
The answer was the second one, consistent categorisation is what makes reporting meaningful. If one user calls it "Food" and another calls it "Groceries" and a third calls it "Eating Out", you cannot build aggregate spending insights, you cannot benchmark patterns again, a single user who renames their category halfway through the year breaks their own historical comparison.
SpendGauge uses a Postgres enum for categories:
category: categoryEnum("category").notNull()The full set — food, transport, entertainment, bills, health, utilities, shopping, education, housing, insurance, savings, investments, debt, gifts, personal, other is defined at the schema level and is identical for every user. Nobody can create a category, rename one, or delete one. The enum is the contract between the user and the data model.
What this gives is referential integrity without a join as there is no categories table to query, no foreign key to resolve, no orphaned references to handle. An expense either has a valid category value or the database rejects the insert also, integrity is enforced by Postgres itself, not by application logic that someone can forget to call.
What you give up is flexibility. A user who wants a "Weddings" category cannot have it they approximate with "gifts" or "personal". That is a real product limitation and worth being honest about. The tradeoff was deliberate: financial history is more valuable than category flexibility, and a schema that enforces consistency is worth more to the product long-term than one that gives users freedom at the cost of integrity.
That decision and the reasoning behind it would never have appeared in a todo app. Todo items do not have categories that carry twelve months of financial history. The finance domain forced the question.
Decision Three — Date Ranges and the Half-Open Interval
The feature that motivated building SpendGauge in the first place is the daily safe-to-spend figure which requires knowing the total expenses within a budget period thus a date range query, however, date range queries on a timestamptz column have an edge case that will silently drop records if you do not think carefully about it.
The expenseDate column stores a full timestamp with timezone:
expenseDate: timestamp("expense_date", { withTimezone: true }).notNull()When a user records an expense for "today" and you store it as 2025-05-14T00:00:00Z, and then you run a query for expenses between May 1 and May 14, the behaviour depends entirely on how you express the upper bound. If you write expense_date <= '2025-05-14', Postgres interprets the right side as 2025-05-14 00:00:00+00 that is midnight on the 14th, which means an expense recorded at any point during May 14 after midnight would be excluded, thus the last day of your range silently drops records.
SpendGauge uses the half-open interval pattern: inclusive lower bound, exclusive upper bound shifted by one day.
const dateGte = (col: typeof expenses.expenseDate, val: string) =>
sql`${col} >= ${val}::date`
const dateLte = (col: typeof expenses.expenseDate, val: string) =>
sql`${col} < (${val}::date + INTERVAL '1 day')`So a query for May 1 to May 14 becomes:
WHERE expense_date >= '2025-05-01'::date
AND expense_date < '2025-05-15'::dateThis captures every transaction on May 14 regardless of what time was stored, with no function wrapping the column.
On matters performance, the expenseDate column is part of a composite index as shown below:
userCategoryDateIndex: index("idx_expenses_user_category_date").on(
table.userId,
table.category,
table.expenseDate
)This index reflects exactly how SpendGauge queries expenses: always scoped to a user, usually filtered by category, almost always within a date range. The index is designed around the domain's access pattern that is, what questions will users actually ask about their financial data and not around the schema in the abstract.
If you wrap the column in a function — DATE(expense_date) <= '2025-05-14' — Postgres cannot use this index for that condition. You lose the performance benefit on every date range query in the application. The half-open interval pattern keeps the column bare on both sides, the index is used for both conditions, and the results are correct at every day boundary. For the todo app sorted by created_at DESC never asked me to think about any of this.
What Product Thinking Actually Means for Engineers
Product thinking for an engineer is about understanding the domain you are building in well enough that your technical decisions are informed by what the data represents and what happens when it is wrong.
The amount field decision came from asking: what does it mean for this number to be incorrect? The category decision came from asking: what happens to a transaction when the thing it references is deleted? The date range decision came from asking: what does this user actually need to know, and what does the query for that look like at the boundary conditions?
None of those questions appear in a tutorial because tutorials choose domains where the questions are invisible. Clone projects teach you syntax and patterns how to wire a React form to an Express endpoint, how to hash a password, how to serve a static build, that knowledge is real and necessary but, it does not teach you how to think about the problem, because the thinking has already been done for you. The schema is given. The features are specified.
When you build in a domain that has rules, the questions are not optional. The domain hands them to you whether you are ready for them or not, and you have to work through them with your engineering knowledge and your understanding of what the user is actually trying to do.
That process repeated across every feature of SpendGauge, taught me more about how senior engineers think than any number of tutorials could have.
My Portfolio take
When you are applying for an engineering role, the person reviewing your work is asking one question: can this person think? A todo app and an e-commerce clone are evidence that you can follow instructions but, they are not evidence that you can reason about a problem domain and make defensible decisions under uncertainty.
SpendGauge is different. When I explain the decimal vs float decision, or the enum vs categories table reasoning, or the half-open interval and why it keeps the index usable, I am demonstrating a way of thinking that is immediately recognisable to senior engineers and engineering managers. I am showing that I understand the difference between data that is arbitrary and data that carries real consequences, and that my schema reflects that understanding.
That conversation is not possible with a todo app. A todo app shows you know how to build CRUD. SpendGauge shows you know why the CRUD looks the way it does.
The Real Reason to Build Something That Matters
Building SpendGauge made me a better engineer because the domain would not let me be a lazy one, every shortcut had a visible consequence, every schema decision had a downstream effect on the accuracy of the data users would trust. The domain created the pressure, and the pressure created the thinking.
If you are sitting with a portfolio of clones and a sense that you are practising the motions of engineering without doing the actual thing, the answer is not a new framework, it is not a harder algorithm or a more impressive tech stack, it is a domain that has rules. Pick a problem where the data means something and build toward that problem honestly.
The engineering questions will find you. They always do when the data matters.
This is article one of a 30-part series on the full engineering behind SpendGauge — from PostgreSQL schema design through Express architecture, React and Shadcn UI, Docker, CI/CD, and Kubernetes. The next article goes deep on the PostgreSQL schema itself: every table, every constraint, every index decision, and the reasoning behind each one.
Stack: PostgreSQL · Drizzle ORM · Express · Zod · React · Shadcn/UI · JWT · Docker · Kubernetes