DEV Community

DEVANSHU PATIL
DEVANSHU PATIL

Posted on

When a Feature Looks Simple, Write Down Its Invariants First

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

For example:

Before:
Outstanding = ₹5,000

Edit transaction:
₹5,000 → ₹7,000

After:
Outstanding = ₹7,000
Enter fullscreen mode Exit fullscreen mode

Now consider a more complicated case:

Before:
Outstanding = ₹5,000

Repayment:
₹2,000

After:
Outstanding = ₹3,000
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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?
Enter fullscreen mode Exit fullscreen mode

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.

android #kotlin #jetpackcompose #softwarearchitecture #backend #databases #systemdesign #buildinpublic

Top comments (1)

Collapse
 
raknaos profile image
Raknaos

"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?