Cliché opening statement: structuring a large project efficiently is challenging. Even with the best intentions and years of experience it's possible to end up with an over-engineered tangle of spaghetti code that becomes difficult to maintain and impossible to scale. Within a short span of time, a successful startup can find itself struggling to work with the sprawling remnants of an old MVP that was never intended to reach the levels of complexity it did.
Being involved from the beginning can be a significant blind spot: having a monopoly of knowledge on the system's workarounds and idiosyncrasies (“job security”) can create the illusion of simplicity, and failing to question our choices often leads to an echo chamber where unpredictable behaviors escalate. What better way to keep things simple than to examine our work from the perspective of a newcomer?
Let me present a few best practices that can go a long way in terms of stability, scalability and developer satisfaction. Even if you don’t plan on expanding your team, these ideas can significantly improve the quality of your codebase, and streamline the flow of information by highlighting the important parts and hiding the distractions.
So, how can we simplify onboarding new team members and make sure they can contribute without breaking things? What would you expect to see after being granted access to the codebase of a huge project?
This might feel a bit anticlimactic but let’s get it out of the way: the readme file of the repository is the entry point of the project. It’s also a document that is alive: it shouldn’t be set in stone, feel free to revisit and rewrite it as the product evolves, even delete the outdated parts.
A properly modularized project could contain separate readme files for each module, describing the relevant pieces of functionality individually, in more detail.
Here are a couple of things one might expect to see in a readme (many of these can be links of course):
Please note that not all of these ideas will apply to open source projects, as in that case the readme might serve slightly different purposes.
After cloning the main branch of the repository, compiling a debug build locally with the latest stable version of the IDE should be as simple as possible (any complications should have been mentioned in the readme). If the main branch does not compile (either because of temporary issues or unintuitive setup requirements), the project immediately feels more intimidating than it should. Too many warnings during the build process are also a red flag: they give a first impression of a project abandoned by its contributors.
Subjective personal preference: I would also like to be able to build the production variant (of course with “fake” signing credentials) just to test potential minification and obfuscation issues.
Unreasonably long build times are often detrimental to productivity, and problems affecting build performance should be tackled consistently during development (good modularization can help with parallel execution, unused dependencies should be removed, third party plugins and libraries should be up to date, etc).
If a new developer's first commit includes automatically generated files, the gitignore file is probably incomplete. Or maybe they didn’t read the readme that we so nicely prepared in the first section.
In general, the version control system should not track the following:
It might be a good idea to include the code style guide configuration in the repository, but keep in mind that doing so ties the project to a specific IDE. In these cases the gitignore file should be as specific as possible (enforcing the standards agreed upon by the team, but not modifying the developer’s personal preferences that don’t affect the code directly).
Let’s get into the more interesting topics. Modularization is a big one, and advocating for one of the many approaches to clean architecture can be a controversial stance. Nevertheless,I’m sure that we can agree on a few high-level guidelines:
When it comes to feature modules, concise but meaningful naming is crucial. Ideally, if a new developer is getting familiar with the application by testing its UI, they should be able to connect the different flows to the various modules just by looking at their names.
Lastly, the entry point of the app should be obvious: the main module handling the navigation and the dependency injection graph is a special one and should be highlighted as such.
One of the more overwhelming experiences while trying to wrap your head around new concepts is separating the noise from the important pieces of information. We should always try to prevent these situations by creating small API surfaces. A good public contract offers an overview of the class's capabilities, and also provides a solid basis for automated testing.
The public functions and their signatures should use clear and understandable naming. I do believe in self-documenting code, but comments are always appreciated.
Dependency injection should always expose abstractions, not implementations.
And to make sure that we created the right abstractions, we can easily verify them with…
A low-hanging fruit for sure, but the topic of meaningful testing is connected to the clearly defined API contracts described in the previous section. Without good abstractions it’s too easy to write tests for implementation details that cause more harm than good in the long run: bad tests hinder development by blocking refactorization, while good ones encourage it.
A test failing should be a good thing: the developer who made the breaking change should stop to think about what they just introduced, and after careful consideration modify their work or the tests. Either way, they shouldn’t feel frustrated about the test failing because of technicalities, or more often, unintentionally testing the wrong parts of the given component.
Tests can also serve as the technical documentation of a component’s expected range of functionality, thus simplifying onboarding new developers.
The opportunities here are practically endless: all sorts of different tools can be used to automate the day to day tasks. On the other hand, mindlessly integrating services or choosing a configuration that’s inappropriate for the project can become a serious hindrance.
Here are just a few general ideas that one could expect from a well-configured mobile CI/CD pipeline:
Developers often delve into an unfamiliar codebase by examining its build scripts, which can unveil valuable high-level insights. Among these insights, the different third party dependencies of the various modules can provide useful information, so does the dependency graph of the project.
Keeping the build scripts organized is a relatively easy task, due to their small number and isolation from the rest of the codebase. Some aspects of organization to keep in mind:
While feature flags have a number of benefits for the product in general, let’s focus on how they can simplify the lives of new team members. Having a nicely kept list of these toggles (maybe in a debug menu) is useful for newcomers, as they can see what the team is currently working on right on the UI - a bit simpler than going through the Git history.
Furthermore, there’s also a psychological element to them. With all the impostor syndrome of the first few weeks and the overwhelming aspects of ramping up, actually starting to contribute can be a stressful experience. Having the changes behind a feature flag gives an extra sense of security
Finally, here are some more subjective points that, in my opinion, show that developers care about their codebase. While it’s difficult to quantify how some of these aspects contribute to quality, they might improve the overall developer satisfaction and definitely give the impression of a product that’s being crafted in a mindful way:
Creating a sleek and functional app should not be a challenge with the modern array of tools available to developers. However, coming up with a stable and reliable system under the hood that can be extended later on without causing too much headache is a problem that most teams realize too late.
Tech debt is an inherent aspect of creating software, particularly in the swiftly evolving realm of mobile development. Every large project will have legacy code, as well as a few hacky solutions that somebody implemented sometime ago and no one remembers why - yet, no one dares to touch it. Nonetheless, we do have the ability to exert control over the spread of such suboptimal components within the core sections of the codebase.
A resilient product that stands the test of time breaks down complex problems into well-defined, manageable pieces. It should have adequate documentation, well thought-out safeguards (both in the form of standards and automations) and a sane project structure. Taking a step back and scrutinizing our processes is of paramount importance in avoiding the minor shortcuts that can increase complexity exponentially down the road.
Observing how a new team member navigates and adapts to such circumstances serves as an excellent litmus test for the system. A welcoming codebase effectively conceals its inherent complexity, fostering an environment that encourages developers to collaborate with it rather than struggle against it.