When a Feature Looks Simple, Write Down Its Invariants First
One of the things I've learned while building FinLedger is that a feature can look simple from the UI while being surprisingly complicated underneath.
Take something like editing a transaction.
At first glance:
Tap transaction
↓
Edit amount
↓
Save
Easy.
But the moment real data is involved, the questions start:
- What if the amount changes?
- What if the transaction type changes?
- What happens to the current balance?
- What happens to the transaction history?
- What if the record no longer exists?
- What if the user saves the same data without making changes?
These aren't UI questions.
They're business-rule questions.
Start with invariants
An invariant is something that should remain true regardless of which operation the user performs.
For a transaction system, examples might look like:
Amount must be valid
A transaction must belong to a valid entity
A balance must be calculated consistently
A deleted record must not leave invalid references
The exact rules depend on the application, but writing them down changes how you implement the feature.
Instead of starting with:
"Which Compose components do I need?"
you start with:
"What must always be true after this operation?"
That's a much better starting point.
Think about the operation as a state transition
A useful way to reason about changes is:
Before
↓
Operation
↓
After
For example:
Before:
Outstanding = ₹5,000
Edit transaction:
₹5,000 → ₹7,000
After:
Outstanding = ₹7,000
Now consider a more complicated case:
Before:
Outstanding = ₹5,000
Repayment:
₹2,000
After:
Outstanding = ₹3,000
The important question isn't simply how to update a number.
It's whether the operation preserves all the relationships and history the application expects.
Don't let the UI define your business logic
This is where architecture becomes important.
A screen might collect:
amount
type
date
note
But it shouldn't be responsible for deciding what those values mean to the entire application.
A cleaner flow is something like:
Compose UI
↓
User event
↓
ViewModel
↓
Business logic
↓
Repository
↓
Database
The UI collects intent.
The business layer determines what that intent means.
The repository handles persistence.
That separation makes the feature easier to test and reason about.
Edge cases expose weak designs
The happy path rarely tells you whether your design is good.
Try the uncomfortable cases:
Edit an existing transaction
Edit it without changing anything
Change the amount
Change the transaction type
Delete it immediately after editing
Open an old transaction
Use invalid input
Perform the operation twice
If the system behaves consistently across these cases, your design is probably getting stronger.
If every edge case requires another special condition, that's a warning sign.
The problem may not be the edge cases.
The problem may be the underlying model.
The lesson I'm taking from this
I've started trying to solve fewer problems by immediately writing code.
Before implementing a feature, I want to understand:
What state exists?
What can change?
What must remain true?
What events can modify it?
What happens when the operation fails?
What happens when it is repeated?
This is especially important in applications where data has relationships and history.
A feature isn't just a screen.
It's a state transition inside a larger system.
And the better you understand that transition before coding, the less likely you are to build a feature that works only in the happy path.
Top comments (1)
"What must always be true after this operation?" is the right prompt, and the invariant list you wrote is a good one. The thing I'd push on next is where those rules live in code, because that's what decides whether they survive. If the balance-consistency check sits in the ViewModel, then every future entry point — a CSV import, a sync worker, a widget shortcut — is a fresh chance to break it. In my own money-ish app the fix was boring: I stopped storing the running balance as a mutable column and started deriving it from the transaction history. Then "balance must be calculated consistently" isn't a rule anyone has to remember to call; any path that writes a transaction keeps it true by construction, and the double-submit case you listed (performing the operation twice) becomes a uniqueness question on the record instead of a reconciliation question on the total.
The edge-case table you wrote is the same list I keep, and the warning sign you name is real — when every edge case needs another special condition, the model is wrong, not the cases. Which of those four invariants is currently enforced in your database, and which ones are still only enforced by the UI being polite?