Software Engineering
The Cost of an Assumption: A Cross-Platform Engineering Lesson I Learned the Hard Way
A real-world engineering lesson about technology selection, cross-platform development, and why validating the riskiest assumptions early can prevent major project blockers.
By Ishan Wickremasuriya
Published 2026-08-16 • 8 min read
We make technology decisions every day as software developers and engineers.
We choose programming languages, frameworks, databases, libraries, architectures, deployment strategies, and platforms based on requirements, experience, research, and whatever evidence is available to us at the time.
Sometimes those decisions work exactly as expected.
Sometimes they require refactoring.
And sometimes, one assumption buried inside an otherwise reasonable decision becomes a major roadblock.
I learned this lesson the hard way while working on a project that started back in 2018.
There was no AI assistant available to help me evaluate the decision. My primary resources were documentation, Stack Overflow, GitHub issues, technical communities, Gitter, Slack, Discord, and, most importantly, hands-on experimentation.
The project was eventually delivered successfully across several environments.
But one of my technology assumptions prevented us from delivering one of the planned platforms.
That experience changed the way I approach technology selection today.
The Project
The company had an existing legacy system that needed to be re-engineered.
We already had substantial domain knowledge, including the requirements that had accumulated around the existing system and the new functionality that needed to be introduced.
One of the major architectural discussions was whether we could build a single reusable codebase that could support multiple platforms.
The target platforms included:
- Web
- Windows desktop
- macOS desktop
- Potentially mobile in the future
The objective was attractive from an engineering and maintenance perspective.
Instead of developing completely separate applications for every platform, we wanted to maximize code reuse while isolating the platform-specific functionality where necessary.
At the time, I was responsible for leading the technical direction, so I started researching the available technologies.
Choosing the Technology Stack
After evaluating several options, I selected JavaScript as the primary language.
For the backend, I selected Node.js with NestJS.
For the frontend, I selected Angular with Angular Material.
There were several reasons behind these decisions.
Angular provided a structured application architecture, dependency injection, modularity, and a mature ecosystem. It also had many of the UI and application-development capabilities we needed.
I particularly liked NestJS because its modular architecture was familiar to me from working with Angular. The separation of modules, controllers, providers, and other application concerns made it possible to structure the backend cleanly.
For persistence, I used TypeORM.
The database requirements were also interesting.
The web application needed to work with Oracle, while the desktop application would use SQLite.
The idea was to keep the application and domain logic largely reusable while allowing the persistence layer to accommodate the different environments.
For the desktop application, I selected NW.js.
At the time, it provided the ability to package a web-based application together with a Node.js runtime and native capabilities, while supporting Windows, Linux, and macOS.
It also gave us capabilities that were important for the application, including printing, encoding, filesystem access, and the ability to run the backend as part of the desktop application.
On paper, the architecture looked promising.
But there was one problem.
I had not validated one of the most important assumptions.
The Tests I Ran
Before committing to the technology stack, I performed several technical tests.
I tested the major requirements on the web platform.
I also tested the desktop application on Windows.
The results were encouraging.
The application could communicate with the database.
The backend could run as part of the desktop application.
The filesystem operations worked.
The application could perform the required business operations.
The overall architecture looked technically feasible.
So I concluded that the approach was viable.
There was just one platform I could not test at that point:
macOS.
I did not have access to a Mac during the initial evaluation.
Instead of treating that as an unresolved technical risk, I made an assumption:
If it works on Windows, the same architecture should work on macOS with the necessary platform-specific adjustments.
That assumption eventually became the biggest problem in the project.
Building the Web Application
I started with the web application.
The architecture was implemented, the business functionality was developed, the required features were completed, and the application was successfully deployed.
The first major milestone was achieved.
The technology choices were working.
At least on the platform we had validated.
Building the Desktop Application
Next came the desktop application.
The goal was to reuse as much of the existing codebase as possible.
The architecture allowed us to do that reasonably well.
However, the first implementation wasn't enough to simply package the web application and call it a desktop application.
The desktop environment introduced additional requirements.
We needed functionality such as:
- Offline operation
- Local data storage
- Data synchronization
- Data download management
- Filesystem operations
- Desktop-specific UI behavior
- Printing
- Application packaging
- Installer generation
Some refactoring was necessary.
I introduced additional modules and isolated certain desktop-specific concerns from the common application code.
After several iterations, we successfully built and delivered the Windows desktop application.
This was an important lesson by itself:
Code reuse does not mean zero platform-specific code.
A genuinely cross-platform application usually needs a common core plus carefully isolated platform-specific capabilities.
The Network Application
After successfully building the desktop application, another requirement emerged.
We needed an application that could run inside a Windows server environment.
The backend would run as a server responsible for data and administrative operations.
Client applications would connect to that server over the local network.
Interestingly, this part became much easier.
The experience gained from building the desktop application had already forced us to separate several concerns that were originally too tightly coupled.
We were able to reuse much of the existing application and adapt it for the network environment.
Eventually, we successfully delivered that application as well.
At this point, the architecture had proven itself across several environments:
Web → Desktop → Network
It was working.
And that made the next problem even more unexpected.
The macOS Problem
The next target was macOS.
I finally had access to a Mac and started setting up the development environment.
The development application started successfully.
The frontend behaved as expected.
The business functionality appeared to work.
At first, everything looked fine.
Because the application used native Node.js modules for database access, I also had to compile/install the relevant dependencies for the macOS environment so they were compatible with the NW.js runtime.
That part was manageable.
Then I connected the application to NW.js.
And the assumption I had made months earlier finally caught up with me.
The application could not behave the same way on macOS as it did on Windows.
The most important failures involved two areas:
- Database access
- Filesystem access
The same architecture that worked on Windows was not simply transferable to macOS.
What Actually Happened?
I started investigating.
This involved reading documentation, searching GitHub issues, reviewing community discussions, and experimenting with different configurations.
The problem wasn't simply that one line of code was wrong.
The macOS environment introduced platform-specific security and packaging constraints that my Windows testing had never exposed.
The way the application was packaged and executed, together with macOS security restrictions and the interaction between NW.js, native modules, and filesystem/database access, meant that capabilities I had relied upon on Windows could not simply be assumed to behave identically on macOS.
That was the important distinction I had missed.
I had tested:
"Can this architecture work on Windows?"
But I had mentally converted that result into:
"Can this architecture work on Windows and macOS?"
Those are two different questions.
The Real Mistake
Looking back, I don't consider the biggest mistake to be choosing JavaScript, Angular, NestJS, TypeORM, SQLite, or NW.js.
Those technologies were reasonable choices based on the requirements, ecosystem, and information available at the time.
The bigger mistake was in the validation process.
I had a requirement for multiple operating systems.
Yet I validated only one of the critical desktop operating systems.
I had effectively validated the technology against the easiest available environment and assumed the other target would behave similarly.
That was the wrong approach.
The question I should have asked was:
What is the riskiest assumption in this architecture, and can I prove it before committing to the implementation?
If macOS was a mandatory platform, then macOS was not an optional test.
It was a release-blocking technical requirement.
What I Learned
This experience changed how I approach technology decisions.
1. Don't validate only the happy path
A technology selection shouldn't be based only on whether the basic application can run.
You need to test the areas that could invalidate the architecture.
For example:
- Can the database driver run on every target platform?
- Can native dependencies be compiled and packaged?
- Does filesystem access work?
- Does authentication work?
- Does offline synchronization work?
- Does printing work?
- Can the application be packaged and installed?
- Does the security model permit the required operations?
- Can the application be updated?
- Does the deployment model work?
- Can the application recover from network failures?
The exact list depends on the project.
The important thing is to identify the architecture-threatening requirements early.
2. Test the riskiest platform first
If an application must support Windows, macOS, and Linux, successfully running it on Windows does not prove that it will run on macOS or Linux.
If the application depends on native modules, filesystem access, hardware integration, database drivers, printing, or OS-specific APIs, platform differences become even more important.
A better approach is to identify the hardest or least-understood target and build a small proof of concept around it.
Don't wait until the end of the project to discover that the most important requirement isn't technically feasible.
3. A proof of concept should prove the risk
One thing I would do differently today is make the proof of concept much smaller.
I didn't need to build the application.
I needed to answer a few critical questions.
For example:
Can NW.js on macOS run the Node.js backend, load the required native database driver, connect to SQLite, and perform the required filesystem operations?
If the answer was no, we would have discovered it before investing heavily in the architecture.
A proof of concept should not necessarily prove that the entire product can be built.
It should prove that the riskiest assumptions are technically viable.
4. Cross-platform does not mean identical
Another lesson was that cross-platform development is largely about managing differences.
A shared codebase is valuable.
But shared code does not eliminate:
- OS-specific APIs
- Filesystem differences
- Security restrictions
- Native dependencies
- Packaging differences
- Installer requirements
- Permissions
- Database driver compatibility
- Printing behavior
- Hardware integration
A good architecture acknowledges those differences instead of pretending they don't exist.
The goal should be:
Maximum safe reuse, with explicit isolation of platform-specific behavior.
5. Technology decisions are hypotheses
This is probably the most important lesson I took from the experience.
When we select a technology, we are effectively making a hypothesis.
For example:
"This technology can support our required platforms, database, deployment model, and business requirements."
That statement should not remain an assumption.
It should become something we verify.
The more critical the assumption, the earlier we should validate it.
What I Would Do Differently Today
If I were approaching the same project today, I would start by creating a technical feasibility matrix before committing to the architecture.
Something as simple as:
| Requirement | Web | Windows | macOS | Linux |
|---|---|---|---|---|
| Application startup | ✓ | ✓ | ? | ? |
| Database driver | ✓ | ✓ | ? | ? |
| SQLite | ✓ | ✓ | ? | ? |
| Filesystem access | ✓ | ✓ | ? | ? |
| Printing | ✓ | ✓ | ? | ? |
| Offline mode | ✓ | ✓ | ? | ? |
| Synchronization | ✓ | ✓ | ? | ? |
| Packaging | ✓ | ✓ | ? | ? |
| Installer/update | ✓ | ✓ | ? | ? |
The unknowns would become the first things to investigate.
I would then create small proof-of-concept applications specifically for those unknowns.
Only after the critical risks were understood would I commit to the larger architecture.
This doesn't eliminate risk.
It moves the risk to a point where changing direction is still relatively cheap.
The Broader Engineering Lesson
Every project contains uncertainty.
We don't have complete information when we start.
Requirements change.
Libraries change.
Operating systems change.
Infrastructure changes.
Business priorities change.
Sometimes the technology we choose will eventually need to be replaced.
That's normal.
The objective isn't to predict the future perfectly.
The objective is to make evidence-based decisions and expose uncertainty as early as possible.
A technology may look perfect in documentation, perform well in a prototype, and still fail when combined with a specific operating system, database, deployment model, security policy, or business requirement.
That's why experience matters.
Not because experienced engineers never make mistakes.
Quite the opposite.
Experience gives us a collection of mistakes, failed assumptions, unexpected constraints, and successful solutions that we can use to make better decisions the next time.
Final Thought
One of the easiest mistakes to make as an engineer is to confuse:
"I tested it and it worked."
with:
"I tested the important things and proved that it works."
Those are very different statements.
My Windows tests proved that the technology could support the requirements on Windows.
They did not prove that it could support the same requirements on macOS.
That distinction cost us the ability to deliver one of the planned platforms using the selected technology.
It was frustrating at the time.
But it became one of the more valuable engineering lessons I carried forward.
Today, when evaluating a new framework, library, database, architecture, or platform, I try to ask a different question:
What assumption am I making right now, and what is the cheapest experiment I can run to prove or disprove it?
Because a wrong decision discovered during a one-day proof of concept is an engineering lesson.
The same decision discovered after months of development can become a project blocker.
Don't try to eliminate every risk before starting a project. Identify the risks that can invalidate your architecture, and test those first.