14 Years of Enterprise ASP.NET, Part 1: Clean Code, OOP & SOLID That Actually Survive Production
Part 1 of a 14-year ASP.NET series: why clean code isn't enough, the OOP that matters, and how SOLID changed the systems I build.
- Author
- Randhir Jassal
- Published
- Reading time
- 16 min read
- Views
- 1 views
Part 1 of 4 — 14 Years of Enterprise ASP.NET. Fourteen years, one constant: most production pain doesn't come from the hard problems — it comes from the fundamentals applied sloppily. This part covers the three that compound the most: why clean code isn't enough, the OOP that actually matters, and how SOLID quietly changed every system I've built. Real code, real before/after, and the metrics from Mattrx, the system we'll use throughout the series.
What this series is
Across 14+ years building enterprise ASP.NET systems I've kept a running list of lessons that actually changed outcomes — not blog-fashionable opinions, the ones that showed up in incident counts, latency graphs, and how fast a new hire could ship safely. This series is that list, in four parts:
- Part 1 (this post): Clean Code, OOP & SOLID — the craft that determines whether a codebase ages well.
- Part 2: LINQ, EF Core & SQL Server — the data layer, without the footguns.
- Part 3: Performance, Microservices & Design Patterns — architecture that earns its keep.
- Part 4: Azure, Observability & AI — running it in the cloud and what's coming next.
The running example is Mattrx — a real-shaped multi-tenant marketing-analytics SaaS: 110k MAU, Angular 19 front end, .NET 9 / ASP.NET Core back end, Clean Architecture + CQRS via MediatR, Azure SQL, ~3,200 req/sec peak across six instances, ~95k lines of C#. Every metric below is from that system.
Lesson 1 — Clean Code is Not Enough
Early in my career I thought clean code was the goal: good names, small methods, no duplication. Then I watched a beautifully clean codebase become unmaintainable anyway. The names were lovely. The architecture was a swamp.
Clean code is necessary but not sufficient. Readable code with the wrong boundaries is just a tidy mess. Clean code is about the line and the method; clean architecture is about who depends on whom. You need both, and the second one is the one that decides whether the system survives year three.
The most common trap: a "clean" service that's perfectly formatted and 1,200 lines long, doing validation, orchestration, persistence, and notification — all with great variable names.
Before
// BEFORE — every method is "clean", the class is a swamp. Pristine names, no boundaries.
public sealed class CampaignService(AppDbContext db, IEmailSender email, ILogger<CampaignService> log)
{
public async Task<Result> PublishCampaignAsync(PublishRequest request, CancellationToken ct)
{
// validation, business rules, DB access, email, audit — all in one method,
// each line readable, the whole thing impossible to test or reuse in pieces.
if (string.IsNullOrWhiteSpace(request.Name)) return Result.Fail("Name required");
var campaign = await db.Campaigns.FirstOrDefaultAsync(c => c.Id == request.Id, ct);
if (campaign is null) return Result.Fail("Not found");
if (campaign.Budget < request.MinSpend) return Result.Fail("Budget too low");
campaign.Status = CampaignStatus.Published; // domain rule buried in a setter
campaign.PublishedAt = DateTime.UtcNow;
await db.SaveChangesAsync(ct);
await email.SendAsync(campaign.OwnerEmail, "Published", "...", ct); // side effect inline
log.LogInformation("Published {Id}", campaign.Id);
return Result.Ok();
}
// ... 18 more methods, each "clean", the class doing everything
}
After
Same readability, but the boundaries are now real: validation, the domain decision, and the side effects are separated, each independently testable and reusable.
// AFTER — boundaries, not just tidy lines. Each piece does one thing and is testable.
public sealed class PublishCampaignHandler(
ICampaignRepository campaigns, IPublishPolicy policy, IDomainEvents events) // abstractions
{
public async Task<Result> Handle(PublishCampaign cmd, CancellationToken ct)
{
var campaign = await campaigns.GetAsync(cmd.Id, ct);
if (campaign is null) return Result.Fail("Not found");
var decision = policy.CanPublish(campaign, cmd); // business rule, pure + unit-testable
if (!decision.Allowed) return Result.Fail(decision.Reason);
campaign.Publish(); // domain owns the state transition
await campaigns.SaveAsync(campaign, ct);
await events.RaiseAsync(new CampaignPublished(campaign.Id), ct); // side effects decoupled
return Result.Ok();
}
}
The email send is now a handler for the CampaignPublished event — not wired into the publish logic. The publish decision is a pure policy object you can test with zero mocks.
Mattrx metric: the original CampaignService was 1,240 lines; splitting it along boundaries dropped the module's change-failure rate (deploys that needed a follow-up fix) from ~18% to under 5%, and unit-test coverage of the publish rules went from ~30% to 92% — because the rules became testable in isolation.
Lesson 2 — Mastering OOP Principles (the ones that matter)
OOP got a bad reputation because people learned the wrong half: deep inheritance trees and getters/setters. The half that actually pays off is encapsulation of invariants and composition over inheritance. The biggest single improvement to any enterprise codebase I've worked on was killing the anemic domain model — entities that are just bags of public properties with all the behavior living in "service" classes.
Put behavior next to the data it protects. An object that exposes its internals and lets anyone mutate them isn't an object — it's a struct with extra steps. Encapsulation isn't
privatefields with public getters/setters; it's the object enforcing its own rules so no caller can put it in an invalid state.
Before
// BEFORE — anemic model: public setters everywhere, invariants enforced by hope
public class Campaign
{
public Guid Id { get; set; }
public CampaignStatus Status { get; set; } // anyone can set ANY value, any time
public decimal Budget { get; set; }
public decimal Spent { get; set; }
public DateTime? PublishedAt { get; set; }
}
// rules live in services, scattered and duplicated — and easy to forget:
campaign.Status = CampaignStatus.Published; // forgot to set PublishedAt? invalid state.
campaign.Spent += charge; // forgot to check budget? overspend.
After
The entity owns its transitions and refuses invalid states. Composition (a Money value object, a BudgetPolicy) replaces sprawling inheritance.
// AFTER — rich model: the object guards its own invariants. Invalid states are unrepresentable.
public sealed class Campaign
{
public Guid Id { get; }
public CampaignStatus Status { get; private set; } // only the entity changes it
public Money Budget { get; }
public Money Spent { get; private set; }
public DateTime? PublishedAt { get; private set; }
public void Publish()
{
if (Status != CampaignStatus.Draft)
throw new DomainException("Only draft campaigns can be published.");
Status = CampaignStatus.Published;
PublishedAt = DateTime.UtcNow; // can't publish WITHOUT timestamp
}
public void RecordSpend(Money charge)
{
if (Spent + charge > Budget) // invariant enforced HERE, once
throw new DomainException("Charge exceeds budget.");
Spent += charge;
}
}
Now there is exactly one place a campaign can become "published," and it's impossible to publish without a timestamp or overspend a budget — the compiler and the entity, not code review, enforce it.
Mattrx metric: moving budget/status rules into the entities eliminated a recurring class of "invalid state" data bugs (campaigns published with null PublishedAt, spend exceeding budget) — ~9 incidents/quarter → 0, and deleted ~600 lines of duplicated rule-checking from the service layer.
Lesson 3 — SOLID Principles Changed Everything
SOLID sounds like interview trivia until you maintain a system for a few years; then you realize the five principles are really one idea wearing five hats: isolate the things that change so a change in one place doesn't ripple everywhere. The two that bought me the most were SRP (one reason to change) and DIP (depend on abstractions, not concretions).
Here's a single refactor that applies SRP, OCP, and DIP together — the report-export feature, which kept growing a new if every time the business added a format.
Before
// BEFORE — violates SRP (many reasons to change), OCP (edit to extend), DIP (new's concretions)
public class ReportExporter
{
public byte[] Export(Report report, string format)
{
if (format == "pdf")
{
var gen = new PdfGenerator(); // hard dependency, can't test or swap
return gen.Render(report);
}
else if (format == "csv") { /* csv logic inline */ }
else if (format == "xlsx") { /* xlsx logic inline */ }
// every new format = editing this class = re-testing ALL formats. OCP violation.
throw new NotSupportedException(format);
}
}
After
Each format is its own class behind an interface (SRP), new formats are added not edited in (OCP), and the exporter depends on the abstraction (DIP). The framework's DI resolves the right one.
// AFTER — one responsibility per exporter; open for extension, closed for modification
public interface IReportFormatter
{
string Format { get; }
byte[] Render(Report report);
}
public sealed class PdfFormatter(IPdfEngine engine) : IReportFormatter // DIP: injected engine
{
public string Format => "pdf";
public byte[] Render(Report report) => engine.Render(report);
}
// CsvFormatter, XlsxFormatter — each a separate, independently-testable class.
public sealed class ReportExporter(IEnumerable<IReportFormatter> formatters)
{
private readonly Dictionary<string, IReportFormatter> _byFormat =
formatters.ToDictionary(f => f.Format);
public byte[] Export(Report report, string format) =>
_byFormat.TryGetValue(format, out var f)
? f.Render(report)
: throw new NotSupportedException(format);
}
// Program.cs — adding a format is ONE registration line, zero edits to existing code:
builder.Services.AddScoped<IReportFormatter, PdfFormatter>();
builder.Services.AddScoped<IReportFormatter, CsvFormatter>();
builder.Services.AddScoped<IReportFormatter, XlsxFormatter>();
DEPENDENCY DIRECTION — the heart of DIP
BEFORE: ReportExporter ──► PdfGenerator (concrete) high-level depends on low-level
AFTER: ReportExporter ──► IReportFormatter ◄── PdfFormatter
(abstraction) both depend on the abstraction
Mattrx metric: adding a new export format used to be a ~2-day task touching and re-testing the whole exporter; after the refactor it's a new class + one DI line, shipped in under an hour, with the other formats provably untouched. The Reports subsystem's regression bugs on format changes went from "every release" to 0 across the following year.
The thread through all three
Clean Code → the line and the method read well
OOP → the object protects its own invariants
SOLID → changes stay local; one reason to change per unit
Together: a codebase where a new requirement touches ONE place,
that place is testable in isolation, and invalid states can't compile.
None of this is academic. It's the difference between a 14-year-old codebase that a new hire ships to in week one and one that everyone is afraid to touch. Clean code gets you a good first impression; OOP and SOLID get you year five.
Continue the series
This is Part 1 of 4 in 14 Years of Enterprise ASP.NET.
→ Next: Part 2 — LINQ, EF Core & SQL Server Without the Footguns — the data layer is where "clean" code most often meets a slow, surprising database. This is how to keep it fast and correct.
The full series:
- Part 1 (you are here): Clean Code, OOP & SOLID
- Part 2: LINQ, EF Core & SQL Server
- Part 3: Performance, Microservices & Design Patterns
- Part 4: Azure, Observability & AI
Further reading
- Clean Architecture in C# — A Complete Guide with Real Code — the boundaries from Lesson 1, in full.
- SOLID Principles in C# — Real Project Example, and When NOT to Apply Them — Lesson 3 expanded, including where SOLID is overkill.
- CQRS in C# — A Complete Guide with Real Code — the handler shape used throughout this series.
Maintaining a codebase that's "clean" but still hard to change? Email randhir.jassal@gmail.com with the class that scares your team most and I'll point at which of these three lessons it needs.
Get the next issue
A short, curated email with the newest posts and questions.