The Idea
This article documents my thought process while system-designing a simple personal software tool — from the first idea to the final architecture.
Amidst all the Web 2.0, AI-powered, blockchain-enabled and quantum-ready buzzwords, I wanted to build something embarrassingly simple: a humble desktop application for my own personal use. Think along the lines of a budgeting tool, an expense tracker, a personal journal and the like.
Tech Stack
As the ideator, architect and developer of this idea, my first question was obvious: what stack?
As a backend engineer, my muscle memory immediately said Ruby on Rails. But Rails nudges you toward a web app — did I actually want one? No. I wasn’t building the next social media platform; some things don’t need to be shared, gamified, liked or commented on. I wanted a personal tool people could download and use, which ruled out SaaS entirely: no multi-tenancy, no authentication, no login page, no backend at all. Just a native app from an app store, paid for once, used for as long as they liked. Rails wasn’t the problem — I was solving the wrong problem.
So I decided to build a macOS application using Flutter (never touched it before). Every user gets their own local SQLite database. No servers, no infrastructure, no internet dependency.
Of course, local databases don’t grow on trees… they grow on disks, and mine couldn’t keep growing forever. Thankfully, the app didn’t need soft deletes — I wanted cold-blooded hard deletes without a care in the world for the destroyed. I also wasn’t expecting years of active records, so capping it around a hundred active entries felt reasonable.
My first thought was to hand users a script that periodically purges old records. Then I realised — why should they? Nobody installs a finance app hoping to do database maintenance on weekends. The app can expose a simple clean-up option instead, and a future version can quietly prune old archived entries on its own, based on a retention policy. People shouldn’t have to care about vacuuming SQLite any more than they care about defragmenting a hard disk.
Easy. Build the application, publish it, charge once, users own their data. I proudly declare I’m not responsible if their laptop decides to retire unexpectedly.
Life was good. Until…
Mobile Versions
Who isn’t greedy? Certainly not me — I was already thinking about Android and iOS. Copy-paste the business logic into three different applications? My toddler would cancel me for repeating myself. Clearly, we needed code sharing.
The obvious question was whether that meant an API server, turning the desktop and mobile apps into thin clients. Absolutely not — that quietly brings back everything I’d just eliminated: authentication, multi-tenancy, hosting, cloud bills, and before I know it I’ve accidentally built a SaaS product.
So without a client-server architecture, how do we reuse business logic across platforms? I realised I was conflating two different things: the application’s code and the application’s data.
Think of a chocolate cake recipe (yes, still craving one). Three people baking the same cake each buy their own ingredients, use their own kitchen, their own oven. The only thing they share is the recipe — following the same recipe doesn’t mean mixing batter in the same bowl. (Wow, I actually like that analogy.)
Software works the same way — business logic is the recipe, user data is the ingredients. Once separated, the solution was simple: we aren’t sharing data, we’re sharing behaviour. This is where Flutter shines: write the business logic once in Dart, compile it separately for every platform. Compile, not copy. The same Dart code becomes a native macOS, Android and iOS app — different binaries, same source, identical behaviour, each still owning its own local SQLite database.
We aren’t sharing data. We’re sharing logic. Shared recipe, independent kitchens. (Can’t take my mind off that chocolate cake.)
Phew — we eliminated the need for a client-server architecture again. Proud as a peacock.
Synchronization
Just when the headache had disappeared, the wanna-be product manager in me asked: “What about synchronization?” Fair question — if I edit an entry on my MacBook, I’d like to see it on my phone too. I’m only a needy mortal.
My first instinct: “back to needing a server” — every client talks to a central server, which stores the data and serves it to everyone else. Done. Except that brought back everything I’d just eliminated: a central database means authentication, multiple users mean multi-tenancy, private data means authorization. Suddenly I’m hosting databases, taking backups, patching security, paying cloud bills. My tiny personal app had quietly turned into a SaaS product. Nope. Not happening. I backed away slowly.
Next idea, equally tiring: let users export the local database and import it on another device. Technically it works — export, transfer, import, done. Elegant? Absolutely not. I wouldn’t use my own app after hearing that. (And the cake analogy has officially stopped helping — we can’t sync cakes. Am I the only one imagining it?)
A friend suggested skipping the cloud entirely: devices on the same Wi-Fi discover each other and sync directly — no accounts, no storage providers, no infrastructure. I liked it, but it solved a different problem. My phone isn’t always on the same network as my laptop; I travel, devices sleep, and if they all have a bad day together, so does my data. Peer-to-peer is fantastic for apps that live inside a home network. I wanted sync to work wherever I was, and I liked my cloud storage doubling as a backup. So I reluctantly parked the idea.
There had to be a middle ground: sync without owning servers or asking users to juggle database files. Then it hit me — I don’t need a backend, I need a courier. The cloud doesn’t have to understand, validate or query my data, or even look inside it. It just moves changes from one device to another. Suddenly iCloud and Google Drive looked less like databases and more like delivery services. Every device keeps its own local SQLite database; the cloud just carries transports between them. I don’t own it, maintain it, or pay for it — users bring their own storage account. Synchronization stopped being an infrastructure problem and became a file transfer problem. That tiny shift in perspective felt like waving a magic wand. (Waving it to fetch me a cake… still no luck.)
Great, we have our cloud courier. Now: when should synchronization happen?
First thought: sync on every create or edit. Problem solved, self-proclaimed genius. Except imagine typing away — create an entry, typo, edit, another typo, edit, forgot something, edit again. Congratulations, four syncs in under a minute. Not elegant, and users don’t expect another device to reflect every keystroke — this isn’t a collaborative document editor.
Fine — every five minutes? Reasonable, until I imagined editing something, closing the app two minutes later, and opening it on my phone at home to find nothing synced. Periodic sync didn’t feel reliable either.
What about syncing only on close — finish working, close the app, it uploads the latest changes? Except apps don’t always get to say goodbye: laptops crash, batteries die, operating systems kill your app without asking. (How are they so cold-blooded?) Waiting for shutdown suddenly seemed risky.
There had to be a middle ground. (Desperately prays so.) Bingo — debounced sync. Instead of syncing after every write, the app just remembers something changed, like a Post-it note saying “don’t forget to back me up.” Every write marks the database dirty and starts a short timer; another write before it expires just resets the timer. Only when the user pauses for thirty or sixty seconds does the app upload the accumulated changes — notified after the pause, not after every keystroke.
But what if the user closes the app before the debounce timer fires? (It isn’t easy being an overthinker. Nor living with one.) Simple — one final sync before the app backgrounds or closes. Now even my perfectionist self couldn’t find fault. (Yet.) No constant uploads, no cron jobs, no manual “Sync Now” button — just sync whenever it makes sense.
Next question: how does another device know something new is waiting? I don’t want every phone and laptop asking “anything new? anything new?” every few seconds — this is a boring app, not cortisol-inducing social media. The answer: a quick metadata check against the cloud on startup. Nothing changed, nothing happens. Something newer exists, it downloads before the user starts working. No polling, no background jobs — one check at startup.
One final question: what if two devices edit the same record before either syncs? Edit an expense on my MacBook while travelling, then edit the same expense on my phone before the first change reaches the cloud — now both devices think they’re right. Who wins? Welcome to distributed systems.
I briefly looked at CRDTs — multiple devices modify data independently and mathematically converge to the same state. Beautiful. Also complete overkill for a personal expense tracker; sometimes the smartest decision is knowing what not to build. A simpler strategy sufficed: every record carries a last_modified_at timestamp, and when two versions meet, the newer one wins — Last-Write-Wins.
Is it perfect? No — simultaneous edits could overwrite each other. But honestly, how often am I editing the same entry on two devices at the same instant? Almost never. The complexity wasn’t worth the tiny probability. Engineering isn’t about eliminating every edge case — it’s choosing which ones deserve your attention.
And with that, my synchronization story finally felt complete.
Backwards Compatibility
By now I finally had an architecture I was genuinely happy with. (Read: “arrogant about.”) One codebase, native applications, local storage, automatic synchronization, no backend, no infrastructure. Surely we’re done?
Not quite. Software refuses to stay “done.” A bug gets reported, fixed. A better idea strikes, gets built. A feature gets requested, added. (Respect the one user who finds your humble app useful.) Version 1 quietly becomes Version 2, then 3, then 17. Applications evolve. (So should we.)
That raises an important question: what happens to users on an older version? Suppose Version 1 stores an entry like this:
Entry
------
id
title
status
Enter fullscreen mode Exit fullscreen mode
A few months later I decide every entry should also remember when it was created. Seems harmless — Version 2 now expects:
Entry
------
id
title
status
created_at
Enter fullscreen mode Exit fullscreen mode
I publish Version 2. The user updates. The new code looks for created_at — except the user’s database still belongs to Version 1, and that column doesn’t exist. Best case, an error. Worst case, a crash. Nothing’s more frustrating than an update that breaks your own data.
One option: ask users to export everything, delete the database, recreate it and import it all again. Remember when I said I wouldn’t use my own application if it behaved that way? Exactly.
Updates should feel invisible. Instead, on startup after an update, the app quietly checks the local database’s schema version. Matches the latest? Nothing happens. Older? It automatically runs a migration first — a tiny set of instructions teaching yesterday’s database to become today’s: add a column, create a table, reshape data, whatever’s needed. Version 1 becomes 2, 2 becomes 3, 3 becomes 4, one step at a time. The user’s data survives every update. (Just like my relentless attempts to avoid building a SaaS app.)
Software shouldn’t punish users for staying up to date. A good update feels boring: click update, open the app, carry on where you left off. That’s backwards compatibility — not supporting old software forever, but protecting the trust users place in you every time they update.
The architecture finally felt complete: fully offline, synchronized across devices, evolving without breaking anyone. The next question wasn’t technical — it was commercial. How should this thing be packaged?
Packaging
One unexpected side-effect of choosing a local-first architecture had nothing to do with engineering — it changed how I could package the product (don’t panic, this one’s pleasant).
Down the traditional SaaS route, the business model chooses itself: users sign up, I host servers, store their data, pay cloud bills, every single month — which means they pay me every single month. Subscriptions aren’t evil, sometimes they’re justified. But I wasn’t running an always-on service. The app runs on the user’s device, the database lives on their machine, sync uses their own cloud storage. Once published, my recurring infrastructure cost is almost zero.
That opens up business models a SaaS architecture can’t support. Someone buys once and uses it for as long as they like. Years later, a much-improved Version 2 becomes a product decision, not an infrastructure one — free upgrade, discounted upgrade, new edition, whatever. My architecture doesn’t force subscriptions just to keep the lights on. No accounts, no reminders, no “your trial expires in 3 days.” Just software.
It got more interesting once I stopped thinking about one app. What if I built a whole collection of personal tools — budgeting, expense tracking, a reading log, a meal planner? Publishing four separate apps means duplicating almost everything: four to install, four to update, four to maintain. Didn’t feel right.
Instead, package it all into one application — a toolbox, where every module ships in but users unlock only what they need. Someone wants just budgeting, fine, they buy budgeting. Someone wants budgeting, tracking and meal planning but not the reading log — also fine. As needs evolve they unlock more: no new app to discover, no new interface, no data migration. Want everything? Bundle it cheaper. Everybody wins.
None of this requires the architecture to change. Still local-first, sync works the same, migrations work the same — only the licensing changes.
I wasn’t designing a single application anymore — I was quietly building a platform for personal software, letting people decide how much or how little to make it their own. Ironically, the biggest business decision in the project had nothing to do with marketing; it happened the moment I chose local-first over SaaS. (Thanks to my overwhelm.) Sometimes the cleanest business model is just a side-effect of a clean architecture.
Closing Thoughts
Is this architecture perfect? Probably not. If my laptop dies seconds after an edit, before the debounce timer syncs it, I lose those last few changes. I’m okay with that trade-off — I wasn’t trying to eliminate every failure, just build something simple, practical and pleasant to maintain.
Sometimes the simplest architecture that satisfies your requirements is the best one. There are probably better approaches than the ones I chose — that’s the fun part of system design. Rarely a single “correct” answer, only trade-offs. If you’d have done this differently, I’d love to hear it. This wasn’t about proving I built the perfect architecture — just documenting the thought process behind one.
Coffee?
If this article saved you a few hours of architectural overthinking (or inspired a personal project of your own), consider buying me a coffee ☕
https://superprofile.bio/vp/buy-me-a-coffee-263
It helps me continue writing about software architecture, system design and, every now and then, the occasional poem.

답글 남기기