There are two Dimensions along which complexity can grow, each coming with its own set of problems and solutions.
This is having a lot of independent functionality. To handle this properly you have to group these functionalities along some dimension.
Premature optimization is the root of all evil
When writing code you shouldn't group prematurely. I only start extracting code into new classes, grouping classes into packages or packages into modules, once my brain can't handle the displayed information comfortably anymore.
If a class has too much code to grasp it at a glance, you should extract it into multiple classes. If a package has too many classes to grasp the point of the package at a glance, you should regroup them into new packages, if a module has too many packages to understand it's purpose at a glance, your should regroup those packages into new modules.
Your brain should be able to comfortably handle the complexity at every level of detail.
This is having functionality that depends on functionality that depends on functionality. I haven't yet figured out all the principles by which you can handle this type of complexity effortlessly. Here is my current progress:
- As soon as you can't describe what your module is doing / what it's purpose is easily, you need to split it up. Same goes for packages and classes.
- You have to strike a balance between smart and clear code. Writing only clear code (like the extreme philosophy behind Golang), is frankly just depressing. Writing code that is too smart will hinder you in the long run.
- Designing a large architecture is more a thing of art, than it is a mathematical equation. You should try and train your intuition and approach the challenge with a calm and empty mind.
Core functionality tests
Nobody thats not insane likes unit tests and testing in insane detail is just that, insane. What really helps though are tests for the core functionality of you project. Everything a user could depend on on a general level should be tested. Take a pizza delivery app for example. You would want tests for ordering something, logging in, payment, sending confirmation emails, registration, discounts, etc..
Those tests are golden
When you then try to expand your app, you instantly know what core feature you bricked. Sure small details may be off, but you cut your development time in half while still maintaining 95% of the functionality.
Another good rule of thumb is: If a bug occurs more than once, you should write a test for it. Following this rule the vulnerable parts of the program come to the forefront.