- Practice a lot, and analyse the (short and long term) consequences of your (and your peers') decisions.
- Start with the simplest thing which could possibly work.
- Let the software evolve over time - when you smell bad code, refactor.
- Don't hesitate to revert your decisions if they don't work.
- Ask for other's help and insights when stuck.
- "Perfection is achieved, not when there is nothing more to add, but when there is nothing left to take away."
I think there are no clean and objective rules on how to create a good (much less great) design. Software development is a craft, thus one needs to practice it a lot, over an extended time, to make a lot of decisions and also to see the later consequences of those decisions. I may decide to do one thing based on whatever reason (because of SOLID, DRY, or because my boss said so :-) but two years later, when the program has evolved and extended to unforeseen new directions, it may turn out to be a bad decision after all. Or a quick ad hoc decision, which at the moment looks a bit ugly, but I have no better idea on how to make it nicer, may open up the possibility for a major redesign a year later.
I more and more tend to see software as something which is not "designed", but "evolving" over time. So a lot of my decisions are based on feelings rather than clean architectural rules. (Many of those rules are IMHO attempts to define and formalize such base feelings to some extent - however, blindly following those rules without getting the intention behind them leads to no good.) In other words, instead of attempting to impose some predefined design upon the code, I try to understand the inherent inner structure of the code and express it more openly via refactoring.
One more thought on this view vs the quote about perfection above: refactoring strives towards perfection, but it is a moving target, as the code needs to be constantly adapted to an ever changing context. So we need to learn to be satisfied with an ever imperfect code base. (Biological evolution can teach us that in the long term, the best survivors are not the ones who perfectly adapt to one specific environment, rather the ones who imperfectly adapt, because these have more room to change when the environment changes.)
So usually when I am implementing something, first I strive to get it working - somehow, anyhow. Do the simplest thing which could possibly work. When I am confident that it is working (to some extent - it may not be fully implemented yet), I look and smell the code and if I smell anything bad, I refactor it.
E.g. I may have put a new method into an existing class which seems to be related to the new functionality, but working with that class feels awkward because it is just too big. So I start to look at what's in there, and discover duplication. After extracting the duplicated code, the class may become easier to handle. Or I may notice that the methods in the class are related to distinct responsibilities, and I am able to extract a new class to cleanly represent a single responsibility, thus making the rest of the old class cleaner and leaner.
Or I may also feel bad about my new method because it is hard to use: the code which actually uses it has difficulties getting hold of the right instance of the containing class. (Or - the other side of the coin - the method itself has difficulties reaching other objects/methods it depends on.) This makes me rethink where the method really belongs to. I may be able to find a better place for it where it is more naturally reachable for its clients / easily accesses its own dependencies.
This is by no means a one way street: it often happens that I revert some decision I made earlier - half an hour or half a year ago - when I realize it is not working well. Software systems evolve as the world around them changes, requirements come and go, the "hot spots" of a system move around. What seemed like a great solution half an hour or half a year ago may show its drawbacks now. Then I remove it, and try to find a better solution. I have learnt over the years to detach myself from my ideas and creations. No matter whose idea it was, it must stand the test of time on its own. If my idea turns out to be a bad one, I am happy to replace it with a better solution, even if it comes from someone else.
And this happens often, because I can't always have the right or best solution in mind. When I get stuck, I discuss the problem with my peers, either within the team, or on StackOverflow. Other people may have a better insight because they have different experiences, or simply because I am too close to the problem to see the solution. I may be stuck in a mental rut, thinking only about a certain class of solutions, while a more general approach would yield better results.