There's recently been a bit of banter (here and here) regarding the appropriateness of applying the MVC pattern to ASP.Net applications (and probably any application). I'm quite opinionated in this area so I felt I should chime in, from the "over-engineered vs. under-engineered" design perspectives.
I find that the more experience you get with application design, the more you uncover this inherent conflict that exists in every architecture. It's a balance between what you want to see today, and what you want to do tomorrow. There's really no avoiding the need for expansion in any program, and to ignore that is just going to mean a massive rewrite later on, and yet you don't want to bog down your first version with an insanely complex n-tier codebase from hell either.
I've seen it happen time and time again. There's really two scenarios that occur: under-engineered and over-engineered: Either the system is written with complete disregard for future expansion (interweaving domain and presentation logic, for example), or the system is subject to the mind of a naïve genius who single-handedly develops an uber-design based on what they think will happen to the business and the application and ultimately produces a system that is way over-engineered, and then (surprise surprise) the requirements change and the entire codebase has to be re-developed from scratch.
I don't think the problem is quite as difficult to solve as it may seem. As Sahil Malik pointed out in a blog on the subject (in so many words), the critical aspect is that you develop in such a way that your codebase remains simple to manage and written in terms of your immediate requirements, but sufficiently agile to be expanded in any one of a broad choice of directions, and fairly rapidly at that. (or, in other words, just keep MVC in mind while you develop).
The key, I think, is a mixture of agile programming and loose coupling, both of which are fairly trendy things, and form the basis of extreme programming (even though loose coupling has been known as orthogonality for many years, it's nice to rediscover old ideas, give them a new name and claim a new revolution now and then).
Off the top of my head, here's a few key principles I've derived from this formula:
1. No matter where you business / domain logic is, identify logic that in itself is quite important to your business, and separate this in some way - even if it just means putting it in its own function. Refer to it as a service.
For example, rather than validating an order in the OKButton_Click event procedure in a form class, if the validation is quite complex then just put it into a separate function and pass the order details in as a parameter, with an intention of making it independent from the presentation layer code. It's a common sense and simple step to take, but it makes the code far more expandable and manageable if this approach permeates your entire application. It also opens the code up to refactoring, if you need to move the code out to another project later on.
Also, integrating the concept of services (and service orientation) into your application design will help you considerably with scalability, resilience and accessibility as your application grows to become more of an enterprise application.
2. Keep a list of the services you've developed and be prepared to refactor the services so they can be re-used.
It's important that you define the signature or interface on to your service function with a bit of thought. For example, think about the name of the service, what comes in and what goes out. Identify your services with the intention of re-use, and of centralizing the unit of business knowledge into one easily accessible method. Apply the DRY principle.
If you set your code up appropriately, refactoring can be a very effective way of having your application evolve. Anything that you can do to make your code easier to refactor will make your life much easier when the time comes, and help you maintain maximum agility. Keeping your code simple, not interdependent, and maximizing your re-use of code are all fundamental to achieving a codebase that is open to refactoring.
3. Keep these services as independent from each other as possible by adopting loose coupling.
To achieve this, to the extent possible - write your service functions as if they were global functions. This way you're restricting yourself to just what is passed in as parameters, and reducing your dependency on any state that might be maintained in the class and the rest of the application.
4. Keep these services as independent from the structure of the data as possible by adopting loose coupling.
Another aspect of loose coupling is to not strictly rely on the structure of the data. This is quite difficult in C#, but from what I've read, easier in the next major C# language revision. With today's technologies, this means making extensive use of typed-datasets wherever possible as they offer a slightly more dynamic alternative to storing your data in fields in a plain old C# object.
5. Keep a healthy distinction between data and code.
In a world where we are increasingly plagued with information overload, you never know where your data will come from in the future. By disconnecting your code from your data you're opening up your code to work on data from other sources too.
In a classic object oriented system your methods and data are nearly always stored together, in fact it's practically encouraged. The problem here is that it restricts your code to just working on the data in the object in which it's declared. By moving your business functions out of your objects, you're opening them up to receiving data from any source and that's an important step towards making your code more agile.
In practical terms, keep your data in your typed datasets, and your code in functions in a separate class or module.
6. Don't code like you own the data.
Always treat the data like it's a guest to your application. Wherever possible, without making any significant compromises to performance, reduce where you're caching the data. Disconnected and semi-permanent caching of data often reduces your ability to expand the application, because now any additional services that use that data must get it from your cache. If you must cache data, find a way to keep that cache live - maybe not in real-time but at least give the cache an expiry so it can periodically refresh.
7. Keep it simple!
Above all, keep your code simple. And if you're looking for a principle, this has been coined the KISS or "Keep It Simple, Stupid!" Principle.
Keeping code simple is often a matter of weighing up the cost of changing something later on, compared to putting up with unnecessary complexity now. If you really think about it, most of the time the opportunity to refactor code at a later date is a real possibility.
For example, don't create separate projects for each class, on the basis that you might expand that class at some point and it might be too big to share with another project, or that you might want to share that code with other applications one day so it deserves to be put into another project. Neither of those reasons justify creating a new project! It's simply a matter of keeping your code agile enough that it can be refactored into separate projects if or when it's needed.
Another couple of classic over-complications: why create an interface for your class just because it might need an interface one day? As long as the class isn't used outside of your project, the day that you need to expose it through an interface will be a simple matter of refactoring and rebuilding your class. The overhead of maintaining both the class and interface until then just isn't justified. The same goes for public properties. If it's an internal class, why not just declare a public field instead? When you do need to control access to the field, then you can easily add a property to encapsulate that field.
Conclusion
So I think the bottom line is that you don't need multiple layers, just an easily-recognizable distinction between:
1. Your domain logic (services or model),
2. Your data classes, and
3. The glue that keeps it all together (the view and controller).
You certainly don't need layer upon layer of object hierarchies, all you need is a clean and simple structure that can be easily refactored as the requirements change.
And above all, KISS!