Abstraction
OK, so we have this fabulous tool, encapsulation, but we're still staring at an empty architecture. How do we start filling in the details? One of the truly great achievements in OOP is how it simplifies design (and by extension construction). Historically, programmers attacked design in linear steps: what's the first thing the program has to do? What next? And what after that? You had to program in strict logical order. Sometimes this worked well, but other times it was nightmarish. This was one of the key issues that OOP was developed to resolve. OOP allows you to design (and code) by abstraction. If managing complexity is the most important technical imperative in programming, abstraction is the most important concept in programming because it's the best way to manage complexity.
Abstraction frees you from talking about a particular piece of data or a block of code, and allows you to talk about a particular object, frequently a real-world object. These objects are abstractions, i.e. something that you know and understand much more fundamentally than programming constructs like inheritance, virtual functions, interfaces, etc. OOP is much more understandable if we look at a hard example. It may be the first step, the last step, or anywhere in between (after all OOP frees us from a linear order of operations), but one frequent abstraction that developers use in their programs is called "user". This idea in your head will be translated into the machine world when you program the abstraction "user" as a class called "User". Now that abstraction lives in your mind and translates directly into the world of your code; it is part of your software architecture, it is utility code encapsulated in one location, the User class.
Now you can use that class to create instances of User objects throughout your run-time code; and not only is that code encapsulated neatly, not only do you get all the benefits of information hiding, but it is also packaged in this clear and understandable mental construct. You've tied that code to the abstraction "user" which has real meaning for you.
As the primary OOP tool for realizing abstractions in your code, classes allow you to organize data and code for your program into these mental constructs that you better understand. For example, users in the real-world have data like names, email addresses, security roles, etc. Users can also perform real-world actions like logging in to a website (through a login() function) or editing their personal data (through an editUserInfo() function). The class User defines that abstraction and is a convenient and clear way to organize all that data and code in one place. e.g. our User class so far would have firstName, lastName, emailAddress, and securityRoles data stored in it and contain the code for the login() and editUserInfo() functions.
However, abstraction not only allows you to design and organize code much more flexibly, it also gives you an amazing ability to mentally manage complexity. Let's say I'm trying to figure out how I'm going to handle login operations for the website. I can dive into the User class, seek out the login() function and deal just with that code. What happens in the rest of the program? I don't know and I don't care except for any specific ways in which the login function is dependent on other pieces of code in the program, for example I probably need to interact with a UI class that defines the XHTML for the login form. Those pieces are important to me, but quite literally nothing else is. I can use the abstraction of a User's login with the UI component; the rest of the program is something I can clear out of my mind. This clarity of mind and focus on just the things that matter is a key to managing complexity more easily in your program; it's the major innovation of OOP.
You can see how reducing the dependency of any piece of code thus makes it easier to program as well. If login() is dependent on three other functions, I have a lot to think about vs. if it is only dependent on one. It's also more difficult to manage the complexity of login() if a function it depends on is lying around in some other class vs. if it's a function (possibly even a private function) that's in the same User class.
Consistent Levels of Abstraction
Abstraction helps you narrow your focus to consider just the issue you're trying to cope with. However, it also helps you in another very important way when you are trying to mentally wrap your head around your program. When we've finished with our login() function, let's say we want to complete an email list to send out a newsletter to all our users (if you do this, be sure that this is an opt-in feature users can choose when they sign up, otherwise you'll have a lot of people reporting you as a spammer).
I'm going to create a sendNewletter() function which will accept an email message that I've composed and send it to each user. This will involve creating a queue of user objects and sending out the email to each one. Note that nowhere in this code am I concerned about details inside the User class. All that stuff which was so important to me just moments ago when I was writing the login() function, I'm now free to put out of my head. The only thing I care about is that the User class exists and my sendNewletter() function is going to send email to a bunch of them. All other details about users are unimportant and so OOP gives me permission to forget all about that for now. This concept is known as levels of abstraction.
Levels of abstraction mean that if one piece of code just grabs an object, I can work at a "high level" of abstraction where I don't need to know any details about it. Then in another piece of code, I can deal with very intricate details of the object, because I'm working at a lower, more detailed level of abstraction. Thus, abstraction works on multiple levels which gives you the luxury of thinking about your users in many ways and at multiple levels of detail. In one moment, I can work with a User all its glorious detail to take full advantage of the power of that code. In the next moment, if I don't need those details I can ignore them, saving my attention for more relevant details in the code that I'm working with. Between the concept of abstractions, and the ability for abstractions to work on many levels of detail, you have a tremendously powerful way to mentally "turn off" any complexity that is not mission critical for each piece of code at the time that you're working with it. This is the single most powerful tool ever developed in software, because it manages complexity in your mind and improves your ability to understand what you're doing.
Design by Abstraction
If you appreciate the importance of abstraction now, then we're ready to start filling in that empty architecture. The concept of abstraction and OOP's ability to realize mental abstractions as classes gives you a powerful combination for conceptualizing application design and then realizing it in your architecture. If you have ever been writing code, and encountered difficulties figuring out what your code was trying to do; you have wanted to design by abstraction. Design by abstraction is the technique of writing out your first function at a very high level of abstraction. This basically describes everything that your program will need to do. In writing out these high level steps, you will identify the high level abstractions for your program, things like login(), sendNewletter(), very high level real world tasks. Then you can tackle each of these abstractions and repeat the process.
For example, we need to write out the steps for sendNewletter(). It's going to grab a list of users, getAllUsers(), create the email message, buildEmail(), and then send it to each user, sendEmail(); these functions are lower level tasks and lower level abstractions that we've now identified. These are the next set of abstractions that we have to write. We proceed to progressively lower and more detailed layers of abstraction until we've written the final run time code which actually copes with nitty gritty implementation details.
The most sensible way to perform this is using pseudocode. Indeed Steve McConnell recommends a very formal Pseudocode Programming Process (PPP) which I like, though I've modified it to my taste. PPP is excellent because not only does it achieve the goal of design by abstraction, it's in plain language (English, Chinese, or whatever you are most comfortable reading). You are basically coding the application without having to write lines of code; you explain each step along the way in enough detail that you can execute the code for each step simply. If you don't know how to write the code to implement a step that you have written, that's where you delegate the job to a lower level function that handles the specifics of that lower level of abstraction.
At its best, pseudocode is completely technology and language agnostic; this is an excellent goal to keep you focused on the issues which the design phase handles best. Pseudocode, too, is ideal because the plain language doesn't get in the way of application design questions the way that code can. If an important step has been missed, it's not so clear when you're looking at a set of variables being initialized and run through a loop. However, it's glaringly obvious when you're trying to explain what's happening in plain language. This is why abstraction, levels of abstraction, and pseudocode are such a powerful combination working through a formal design, and filling in the detailed design inside your architecture.