It’s my birthday this week, and I’ve been building software for over 25 years now, apps for about 15. I figured it was a good time to reflect on a few lessons.
These are some timeless lessons that I see returning. They’re my personal observations, not universal truths.
Especially when learning new technologies, it’s natural to lean on the latest frameworks, popular architectures, or shiny patterns. They bring momentum, and allow you to deliver without needing to know the entire tech-stack or super low-level stuff. Instead, you can just start building.
These tools are what people blog about, what companies are hiring for, and what gets presented at conferences.
But over time, I noticed I am drifting away more and more from the latest trends.
I learned that the code that lasts the longest, the kind no one has to touch because it just works, is not built on trends or rarely talked about on mobile conferences. It’s not even tied to a specific UI framework, and definitely not tied to the latest architectural buzzword with some combination of M’s V’s C’s and P’s.
It’s the boring logic underneath that silently keeps working, regardless if you’re on iOS 12 or 21. It’s not “cool”, or “trending”. But it’s the kind of code that keeps working regardless of how the UI is built.
Try not to waste too much time squabbling whether MVP is better than MVVM or whatever. Focus on writing good software that works even without UI. And then, every so often, you change the UI layer to keep up with the latest changes. Such as migrating from imperative to declarative UI, or trying new patterns. But underneath it all, the model layer keeps happily humming along.
This holds especially true in the mobile world, since the UI domain is relatively volatile.
A big lesson here is: When you focus on making your features work independently from UI, you often gain long-term flexibility.
Try building your feature like it could run from a command line, then add the UI on top.
Want more info?
→ What if your feature was a Command Line Tool?
→ Uh oh, you picked the wrong UI architecture
Early in my career, I thought reusable code meant making things more generic. I’d create base classes, or add optional parameters, and support every configuration that someone might need. It looked flexible on the surface, but in practice, it was harder to use, harder to understand, and rarely used in their full capacity.
Basically it’s a piece of code that’s watered down without a real purpose.
What I’ve come to realize is that the strongest reusable components are not the ones that support everything. They are the ones that solve few problems, cleanly with a strong focus.
For example, take a PrimaryButton
. It could be used all over the UI. Developers can place it in nearly every feature. That makes it flexible. But not because it has ten parameters and custom behaviors and subclassing options. It’s flexible because the API is simple. It has limited configuration (such as colors), and that’s it. The design is clear. And the intended use is obvious.
This kind of reuse doesn’t come from more layers of abstraction. It comes from reducing requirements. From building something focused that is easy to reach for and hard to misuse.
That also means convincing people to streamline requirements, or saying no a lot. The more a component is shaped by trying to fulfill every request, the less focused it becomes. What starts as a clean solution turns into a collection of edge cases, flags, and one-off requirements. It tries to do everything and ends up doing nothing particularly well.
Want more info? → Deliver reusable components without making them reusable
When I started my career, I loved third-party dependencies. Other developers who offered their work and contributed to the community? Amazing! It made my life so much easier. It’s a great to speed up your work. It feels smart when you’re short on time.
But, third-party dependencies aren’t free.
Over time, when I worked on more serious apps that live for many years, some third-party dependencies often became a pain. So you have to be really sure you needed the dependency.
It’s hard to convince others that there is a secret cost to these dependencies, especially if they haven’t been through multi-year maintenance. Because they all instantly solve a short-term problem!
This lesson usually hits when the app you’re working on is a few years old. You’re migrating to the newest OS or Swift/Kotlin version, and suddenly, your critical release is blocked. Not by your code, but by a third-party dependency that relies on something like uuid-generator-plusplus
, maintained by a solo dev who hasn’t touched it in years.
This innocuous transitive dependency is now ruining your release. Because all modules are now upgraded to the latest Swift version, but your module is still behind unless you remove/fork/contact the maintainer of this dependency.
It's one thing to inspect how well your third party dependencies are maintained, but to inspect all their dependencies (and their dependencies etc) is tough. Not to mention, these transitive dependencies can also change over time.
To be clear, I’m not saying avoid all third-party dependencies. Use these dependencies when they actually solve a real problem, especially ones that are domain-specific, like a well-supported animation engine, security frameworks, or a trusted crashlog SDK.
But, don’t be eager when adding optional “nice to have” dependencies.
For example, some devs write massive constructors and their conclusion isn’t “I made this is overcomplicated,” but instead, “We clearly need to swap to new better DI framework.”
You have to be honest with yourself: Are you reaching for a library because it saves time? Or because you heard about it on a conference?
That’s why I get a little alergic reaction when developers flock to yet another “latest and greatest” library and adding it without much thought. Not realizing that in three years, they are now trying to get rid of this third-party dependency because the maintainers are slow to update it. Don’t just look at the number of stars. Try to assess the risk.
For instance: Is this dependency maintained by one person or a team? How fast are they with resolving issues? Are they okay with others contributing? And is this dependency also depending on a lot of other dependencies? etc.
To sum it up: Be concious that the third-party dependency you’re adding solves a real need, and be aware that it’s adding more risk. Don’t go in thinking this trending framework is the silver bullet as it’s advertised.
👉
As a tip, to handle Dependency Injection without a third party solution, check out Elegantly Handling Transitive Dependencies
I’ve seen this more than once: someone joins a team, sees unfamiliar patterns, and immediately pushes for a full rewrite using their preferred framework.
But oftentimes the team wasn’t clueless; They were just operating under constraints you don’t yet see: legacy app behavior, timelines, team skill sets, product-owner pressure, or inherited legacy from devs that left the company.
So it’s easy to critique and think “They should have used a {insert your favorite} library!”
Before blindly proposing changes that you are comfortable with. Understand more of the context and history first.
Then you will become better informed before coming up with improvements.
Related to that…
When you join a new company, it’s common to run into code that looks unfamiliar, confusing, or just plain weird.
Sometimes, that’s because the code is genuinely hard to follow. Maybe it lacks structure, has inconsistent naming, or hides logic in obscure places.
But, honestly, sometimes the problem can be your skill-level. Being uncomfortable in a codebase doesn’t always mean the codebase is bad.
The key is being able to tell the difference.
Look for signals you can quantify. Are the responsibilities unclear? Are there a lot of known issues? Are a large numbers of devs also struggling with this code?
These are all signs of genuinely hard-to-maintain code.
But also check your own reaction. Are you frustrated because it’s hard to read, or because it challenges your assumptions? Some of the best systems I’ve seen didn’t make sense at first. But once I understood the principles behind them, they felt clean and I could learn a lot from it.
Too often I see developers (myself included) fall into the trap of a “clean code purist”.
It’s easy to fall into purity debates—like whether import UI should be allowed in a view model—while much bigger problems sit in plain sight: broken tests, missed deadlines, unstable features, ballooning build sizes, and so on.
I’ve been part of hundreds of “developer squabbles.” Nowadays, I try to avoid them where I can.
Here are some of the takeaways that stick:
If the product is mediocre, clean architecture doesn’t save the day. Users don’t see your abstractions or this framework is “so much better”. They notice crashes, delays, and clunky UX.
If you’re debating a detail, ask yourself if it’s more about aesthetics or actually solving a problem. Will this make it easier to test, change, or onboard someone later? If not, maybe it’s not the hill to die on.
If your app is getting poor reviews, but you’re arguing over file naming, then you’re missing the point. Get the fundamentals working, then refine. Debating style while core functionality is broken is like arguing about font choice on a broken login screen.
Progress is the goal, not “keeping everybody happy.” Great teams don’t agree on everything. They make deliberate trade-offs, keep momentum, and revisit decisions when needed. If you make concessions to please all developers’ opinions, you will please nobody.
Debates are not the enemy. In fact, the best teams I’ve worked with do debate architecture, naming, and boundaries, because they know those things shape velocity over time.
But the difference is: they always keep the goal in mind. The goal isn’t to debate over the best architecture, it’s to ship a good product.
Debate is valuable when it leads to improved processes. But, if it doesn’t improve improve the app, ways-of-working, increase momentum, or make a codebase easier to handle, it’s probably just noise.
Almost every time, when encountering an issue or bug, I’ve found that fixing the real issue, the root cause, is the best path forward.
Sure, there are exceptions. Like last-minute release chaos where you just need a patch. But in day-to-day work? If you notice a problem, fix the actual problem.
Say you’re building a feature and notice a bug where, in unique circumstances, network calls fire twice on failure. Most likely it’s a bug in the networking layer.
You could skip the real fix and add a crummy patch in your feature, allowing you to ship faster today. But you are now introducing technical debt already on newly released code. You’re not moving faster in the long-term.
If an entire team works this way, they are just sticking band-aids on top of each other, where they end up with a big ugly band-aid ball.
But you know that the real issue is deeper. The “real” solution is to get that bug fixed. Other than reporting a bug for the other team (which is totally valid), consider diving in and fixing it yourself. If not possible, at least explore it!
That means you’ll feel internal resistance:
“I didn’t write that code.”
“I don’t own that module.”
“It’s out of scope.”
“I want to finish this today.”
“It’s above my skill-level.”
Yes, it’s annoying. Yes, it’s foreign code. Yes, it’s not what you were planning to do today.
Go in, and try and fix it anyway.
If you succeed, your feature gets simpler, the bug disappears for good, and everyone else benefits too. And you level up, because you took responsibility for code that wasn’t “yours”, and made it better.
Does it take more time? It does for you, but the team as a whole often saves time.
Now the next developer doesn’t waste hours trying to understand a duplicate call. They don’t trace logs, or add their own workaround. And they don’t stare at a mysterious preventNetworkDoubleCall = true
wondering, Can I delete this? Why is this here?
You didn’t just fix the bug. You prevented confusion, pull-request debates, frustration, and you prevent people from spending time talking about the bug in meetings “is this ticket still open?”.
Then a next feature is built, they also don’t have to deal with the bug, since you fixed it already!
Thanks to you going the extra mile, the bug is now a non-issue.
I hope my insights bring some perspective and allow you to focus on what’s really important: Delivering quality apps that last a long time.
It’s not an exhaustive list, but they definitely shape how I write my software.
If you’re working on complex apps and want a system that holds up under pressure, then check out the Mobile System Design book. This is what the book is for: helping you think long-term, build cleanly, and lead mobile projects with confidence.
The Mobile System Design book
If you want to boost your career and become an even better mobile engineer, then check out the Mobile System Design book.
I am the author of this book, but you may also know me from the highly-rated Swift in Depth book.
Even if you're a senior or staff-level engineer, I am confident you'll get a lot out of it.
It covers topics in depth such as:
... and much more!
Get the Mobile System Design bookWritten by
Tjeerd in 't Veen has a background in product development inside startups, agencies, and enterprises. His roles included being a staff engineer at Twitter (before X) and iOS Tech Lead at ING Bank.
He is the author of the Mobile System Design and Swift in Depth books.