Table of Contents
TL;DR
- SOLID isn’t enough. Clean code is about making your C# code easy for humans to read, change, and maintain, which SOLID alone doesn’t guarantee.
- Focus on behavior, not just structure. Principles like Tell, Don’t Ask and avoiding anemic models keep your business logic where it belongs: with the data it operates on.
- Simplicity and explicitness win. Fight complexity with YAGNI and KISS. Write code that is obvious and has no hidden surprises.
- Fail fast and build resilient systems. Principles like Fail Fast, Design by Contract, and Idempotency are critical for production-grade applications that can handle errors gracefully.
- Start small. Don’t try to apply all 22 principles at once. Pick the ones that solve your biggest pain points today—like improving readability or stamping out bugs.
Your “SOLID” C# Code Is Probably Still a Mess. Here’s Why.
I once inherited a project that was, on paper, a perfect example of SOLID design. It had interfaces for everything, dependencies were inverted, and classes had single responsibilities. It was also a complete nightmare to work on. Adding a simple feature felt like performing open-heart surgery because the actual business logic was scattered across a dozen abstract service classes.
Sound familiar?
We’ve all been there. We learn SOLID and think we’ve got clean code figured out. But SOLID principles mostly deal with class-level design and dependency management. They don’t stop you from writing code that’s hard to read, full of hidden side effects, or impossible to debug.
This is my reference list of 22 practical principles that go beyond SOLID. These are the rules I follow to write code that my team (and my future self) won’t hate maintaining.
Why Bother Going Beyond SOLID?
Because SOLID doesn’t prevent a UserService from becoming a 2,000-line monster. It doesn’t stop developers from writing “clever” one-liners that take an hour to decipher. And it certainly doesn’t help you build resilient systems that fail gracefully in production.
Clean code is about reducing friction. It’s about writing code that is:
- Easy to read and understand.
- Safe to change without breaking something unexpected.
- Simple to debug when things inevitably go wrong.
The following principles address the real-world problems that turn a promising codebase into technical debt.
The Clean Code Reference for C# Devs
1. Tell, Don’t Ask (Stop Interrogating Your Objects)
Instead of pulling data out of an object to perform logic on it, you should tell the object to perform the action itself. This keeps behavior and data in the same place. When you see a bunch of getters in a class, it’s often a sign that its logic has been leaked elsewhere.
Where I’ve seen this go wrong: A ShoppingCart class that just held a List<Item>. All the logic for adding items, calculating totals, and applying discounts was in a massive ShoppingCartService. The fix was to move that logic into the ShoppingCart class itself, making it the single source of truth.
2. Avoid Anemic Domain Models
This is a direct result of violating “Tell, Don’t Ask.” An anemic model is a class that’s just a bag of properties with no behavior. All the business rules that should be inside it are handled by external service classes. Your domain models should be rich with methods that enforce their own rules and invariants.
Common Pitfall: Your Order class has a public Status property but no Complete() or Cancel() method. The logic for changing the status is somewhere else, meaning the Order can’t protect its own state.
3. DRY (Don’t Repeat Yourself)
This isn’t just about avoiding copy-pasted code. It’s about not repeating knowledge. If a business rule (like “a premium customer gets a 10% discount”) exists in two different places, you have a DRY violation. When the rule changes, you’re guaranteed to forget one of them.
Lesson Learned: Be careful not to create bad abstractions just to satisfy DRY. Sometimes, similar-looking code deals with different business concepts. Forcing them into one method can create a bigger mess than the duplication it was meant to solve.
4. YAGNI (You Aren’t Gonna Need It)
Stop building features for a hypothetical future. Every line of code you write adds maintenance overhead. If you’re building a generic caching mechanism “in case we need Redis later” when you’re just running on a single server, you’re probably over-engineering. Solve today’s problems today.
5. KISS (Keep It Simple, Stupid)
The simplest solution that works is almost always the best one. I once saw a junior developer implement a complex state machine pattern to handle a simple three-status workflow (Pending, Approved, Rejected). A simple enum and a few if statements would have been fine and a lot easier to understand. Clever code is a liability.
6. Encapsulation Boundaries
Your objects should be in charge of their own state. Don’t expose internal data structures. If an object needs to maintain a rule (like “the total price can’t be negative”), it should control all the code paths that can modify that data.
Common Pitfall: A class with a public List<T> Items { get; set; }. Anyone can now Clear() the list or add invalid items, bypassing any validation logic in the class. Expose an IReadOnlyList<T> and provide AddItem() and RemoveItem() methods instead.
7. Explicitness Over Implicitness
Code shouldn’t have magic side effects. When you call a method, it should be obvious what it does. This is why many teams avoid C# extension methods for core domain logic; they can hide what’s really happening and make the code harder to follow.
Where I’ve seen this go wrong: A SaveChanges() method that also sent an email. This was a nasty surprise during debugging. The fix was to make the email sending explicit, triggered by a domain event or a direct call after saving.
8. Small Functions, Clear Names
A function should do one thing and do it well. If you need a comment to explain what a 30-line function does, it’s probably too long and poorly named. Break it down. Good naming is one of the hardest but most valuable skills in software development. Name your variables, methods, and classes to reflect the business domain, not the technical implementation.
9. CQS (Command Query Separation)
Methods should either be a command (changes state, returns void or Task) or a query (returns data, has no side effects). Mixing the two is confusing. For example, a method named GetUser() should not also be updating a LastLogin timestamp in the database. That’s a sneaky side effect.
10. Fail Fast, Fail Loud
Don’t hide exceptions. If something goes wrong, your application should blow up as close to the source of the problem as possible. Swallowing an exception with an empty catch block is one of the worst things you can do. This bug nearly drove me mad once: a silent catch was hiding a critical database connection issue that only showed up under load.
Clean Code:
// Fail fast with a guard clause
public void ProcessOrder(Order order)
{
if (order is null)
{
throw new ArgumentNullException(nameof(order), "Order cannot be null.");
}
// ... rest of the logic
}
11. Design by Contract
Be explicit about your method’s preconditions (what it expects) and postconditions (what it guarantees). Guard clauses at the start of a method are a simple form of this. They make the method’s requirements clear and prevent invalid data from flowing through your system.
12. Immutability Where Possible
Once an object is created, its state shouldn’t change. This eliminates a huge category of bugs related to shared, mutable state, especially in concurrent systems. C# record types are fantastic for this. Use them for DTOs and value objects by default.
Production Tip: Use C# records for DTOs and simple value objects by default. Their built-in immutability prevents a whole class of sneaky bugs where one part of the app accidentally changes data used by another.
13. Favor Composition Over Inheritance
Inheritance creates very tight coupling. If you can achieve code reuse by injecting a dependency (composition) instead of inheriting from a base class, do it. Inheritance should only be used for true “is-a” relationships, which are rarer than you think.
14. Dependency Inversion at Module Level
This is the “D” in SOLID, but people often forget it applies to architectural layers, not just classes. Your Domain layer should not have a direct dependency on your Infrastructure layer (like Entity Framework). Instead, the Domain should define an interface (e.g., IOrderRepository), and Infrastructure should implement it. This keeps your core logic clean from persistence details.
15. Information Hiding (Parnas Principle)
Hide implementation details behind abstractions. If a class uses a List<T> internally to manage a collection, it should expose methods like AddItem() and GetItems() instead of exposing the list itself. This allows you to change the internal data structure (e.g., to a Dictionary<TKey, TValue>) without breaking any client code.
16. Minimize Surface Area (API Hygiene)
Keep your classes and interfaces small. Every public method is a contract you have to maintain. If nobody outside the class needs to call a method, make it private. This reduces the mental overhead for anyone using your class and makes it easier to refactor later.
17. Persistence Ignorance
Your core domain models shouldn’t know or care how they are saved to a database. This means no [Table("Orders")] attributes or [ForeignKey("...")] annotations in your domain entities. Keep that mapping configuration in your infrastructure layer where it belongs. This separation makes it easier to test your domain logic without needing a database.
18. Separation of Concerns (SoC)
Don’t mix UI logic, business logic, and data access logic in the same class. This is the foundation of layered architecture. A class should have one primary responsibility. When a single class is responsible for validating user input, executing a business rule, and writing to the database, it becomes impossible to test and maintain.
19. Defensive Programming
Assume that inputs can be bad. Validate data at the boundaries of your system (e.g., API controllers, service entry points). However, don’t go overboard. Once data is validated at the boundary, you can often trust it within the deeper layers of your system, avoiding redundant checks everywhere.
20. Layered Architecture
Structure your application into distinct layers (e.g., Presentation, Application, Domain, Infrastructure). Dependencies should only point one way: downwards. The Domain layer should be at the core and have zero dependencies on other layers. This creates a clean, maintainable structure.
21. Orthogonality
Changes in one part of the system shouldn’t ripple through unrelated parts. For example, changing your logging provider (an infrastructure concern) should not require you to change your business logic. This is achieved through good abstractions and separation of concerns.
22. Idempotency in Operations
This is critical for any distributed system or API. An operation is idempotent if you can call it multiple times with the same input and get the same result without any additional side effects. For example, a POST /orders endpoint is not idempotent, but a PUT /orders/{id} endpoint should be. This makes your system more resilient to network failures and retries.
My Take: Where to Start
Don’t try to memorize and apply all 22 principles tomorrow. That leads to analysis paralysis. Instead, find the pain points in your current project and pick a few principles to focus on.
- If your app is full of bugs: Focus on Fail Fast, Design by Contract, and Defensive Programming. Stop bad data at the door.
- If adding features is slow: Focus on Tell, Don’t Ask, Separation of Concerns, and Small Functions. Untangle the spaghetti.
- If your team is confused: Focus on Explicitness Over Implicitness and Clear Names. Make the code self-documenting.
Start with your next code review. Ask “Is this code simple? Is it explicit? Is it safe to change?” That’s the real measure of clean code, not just checking off the SOLID boxes.
References
- Clean Code: A Handbook of Agile Software Craftsmanship (Robert C. Martin)
- Domain-Driven Design: Tackling Complexity in the Heart of Software (Eric Evans)
- Martin Fowler’s Blog (martinfowler.com)
- Microsoft Application Architecture Guide