DEV Community

Cover image for .NET Programming Habits I Wish I’d Started Sooner
Sukhpinder Singh for C# Programming

Posted on • Originally published at Medium

.NET Programming Habits I Wish I’d Started Sooner

I used to think getting better at .NET meant learning more frameworks, more patterns, more architecture diagrams, and at least one folder named Infrastructure that nobody could explain with a straight face.

Turns out, most of the improvement came from boring habits.

Not flashy habits. Not conference-talk habits. Not “senior engineer with a dark-mode slide deck” habits.

Just the kind of habits that save you from reading a stack trace at 11:47 PM while wondering which version of yourself thought Helper.cs was an acceptable file name.

A lot of my early .NET code technically worked. That was the problem. Working code can hide bad habits for a long time. It compiles, tests pass, feature is shipped, everyone moves on. Then six months later, someone touches it and the whole thing reacts like you disturbed an ancient tomb.

That someone is usually you.

And that’s why I care so much about habits now. Not because clean code is a religion. Not because every method needs to look like it’s auditioning for a textbook. But because software is maintenance first and ego second.

These are the .NET programming habits I wish I’d started sooner.

Not because they make code pretty.

Because they make code survivable.

1. I stopped writing “smart” code and started writing obvious code

Early in my career, I loved cleverness.

One-line LINQ chains. Tiny abstractions with huge confidence. Methods that returned exactly what I meant, but only after the reader solved a small puzzle. I told myself it was elegant.

It was not elegant. It was me showing off to people who just wanted the bug fixed.

One of the best habits I picked up was this: if the code makes the next person pause and squint, it’s probably worse than I think.

That doesn’t mean every method needs to be 30 lines long and spelled out like a children’s book. It just means clarity wins more often than cleverness.

I now ask myself:

  • Would a tired engineer understand this in one pass?
  • Does this method do one thing, or am I sneaking in three?
  • Am I compressing logic to save lines, or to help understanding?

Those are very different goals.

A few years ago, I reviewed a piece of business logic that calculated refund eligibility. It was packed into a single LINQ statement with nested ternaries and enough null-conditionals to make it look defensive and sophisticated. It took me five minutes to verify something that should’ve taken 30 seconds.

We rewrote it into plain, boring conditional logic.

It became longer.

It also became correct.

That tradeoff is a bargain.

2. I started being aggressive about naming things

Yes, yes. “There are only two hard things in computer science…”

Still true. Still annoying.

Naming is one of those things everyone agrees matters, then casually ignores when the sprint gets spicy.

I used to create names like DataManager, CommonService, Utils, ResponseModel, and the all-time classic: Helper.

Those names are basically a polite way of saying, “I did not want to think deeply about responsibility.”

Now I treat vague names as a smell.

If a class is called OrderProcessor, I should be able to guess what it does. If I need to open the file and inspect 200 lines to understand the name, the name failed.

Same for method names.

Handle() is too vague in most business code.
Process() is usually hiding too much.
GetData() tells me almost nothing.
Execute() is sometimes valid, but also sometimes a mask for “does whatever.”

A good name reduces meetings, review comments, and future regret.

A bad name creates accidental mystery.

And mystery has no business being in payroll code.

3. I learned to keep controllers thin and business logic out of the edges

This one took me longer than I’d like to admit.

I used to put too much logic in controllers because it felt efficient. The request is already there. The DTO is already there. The service call is right there. Why not just do the validation, mapping, authorization checks, conditional branching, and persistence in one place?

Because eventually your controller becomes a novella.

Thin controllers are not about purity. They’re about keeping the web layer stupid.

The web layer should deal with HTTP. That’s its job. Routing, request shape, response shape, status codes, auth boundaries. Great.

But your actual business decisions should live somewhere you can test without pretending to be the internet.

This habit paid off massively when I had to support the same business flow through an API, a scheduled job, and an internal admin tool. The old version had logic embedded in MVC controllers and little bits duplicated elsewhere like loose wires. The refactored version moved the actual rules into application services.

Suddenly, the transport mechanism stopped mattering.

That’s when code starts feeling solid.

Not when it becomes “clean architecture certified.”
When it stops caring where the request came from.

4. I stopped treating null as a personality trait

Older .NET codebases can feel like they were built on a handshake agreement with null.

Maybe there’s a value.
Maybe there isn’t.
Maybe we’ll find out in production together.

Turning on nullable reference types was one of the most useful uncomfortable habits I adopted.

Uncomfortable because it reveals things you’ve been getting away with.

It’s like flipping on the kitchen light after convincing yourself the noise was probably nothing.

Once I started taking nullability seriously, I noticed two things:

First, my models became clearer. I had to decide what was truly optional versus what I was too lazy to initialize properly.

Second, a surprising amount of defensive code disappeared. When your contracts are explicit, you don’t need to keep sprinkling ?. and ?? "" like you’re warding off evil spirits.

Is it perfect? No.

There are times when external data, legacy layers, or imperfect serializers mean you still need runtime checks. Nullable reference types are not magic. But they force better thinking, and that alone is worth it.

The real win is not “fewer null exceptions.”

The real win is “fewer ambiguous assumptions.”

5. I started designing methods around behavior, not just data movement

A lot of mediocre .NET code is just data passing through a series of rooms.

Controller gets DTO.
Service maps DTO.
Repository saves entity.
Another service maps it back.
Everybody claps.

Nothing technically wrong with that. But sometimes we build entire applications that are mostly moving data around without clearly modeling what the system is actually doing.

I started writing better code when I asked: what behavior lives here?

Not just: what object should own this property?

For example, instead of:

UpdateSubscription(User user, Plan plan, DateTime nextBillingDate)

I’d rather see something closer to a business action:

ChangePlan(userId, newPlanId, requestedBy, effectiveDate)

The second version tells a story. It reflects intent. It gives you a natural place to validate rules and capture decisions.

I’m not saying everything needs domain-driven poetry.

I’m saying behavior is usually where the important complexity actually is.

And if your code hides behavior under generic CRUD shapes, the important parts get harder to find.

6. I got serious about logging things I’d actually need during a bad day

There was a period in my career where our logs were technically present but emotionally unavailable.

Lots of stuff like:

  • “Error occurred”
  • “Request failed”
  • “Operation unsuccessful”

Thank you. Very useful. Incredible detective work.

Now I think about logs in one specific context: a tired engineer trying to understand a real production issue under pressure.

That changes everything.

Good logs should help answer:

  • What operation was happening?
  • Which entity or request was involved?
  • What was the expected path?
  • What failed?
  • What can I correlate this with?

This doesn’t mean dumping entire payloads into logs like a raccoon in a trash can. You still have to think about privacy, noise, and cost. Structured logging matters. Sensible context matters. Logging secrets is still a career-limiting move.

But too many teams under-log the exact business transitions that matter most.

One incident still sticks with me. A batch job was “randomly” skipping a subset of invoices. The infrastructure looked healthy. No obvious crashes. No smoking gun.

The only reason we found it quickly was because one engineer had added a structured log around the decision path for skipping invoice generation, including customer status and effective dates. Without that, we would’ve spent hours blaming timing issues, queues, and maybe the moon.

That log line probably saved half a day.

The best logs aren’t loud.

They’re useful when things get weird.

7. I stopped pretending tests were just for correctness

I used to think tests were mainly there to catch regressions.

That’s true, but incomplete.

Tests also expose bad design faster than code review ever will.

If something is annoying to test, there’s a decent chance it’s also annoying to maintain.

I’m not saying every friction point means you need another abstraction. Sometimes hard-to-test code is just inherently dealing with ugly edges. File systems, time, HTTP, databases, queues. Reality is messy.

But a lot of the time, painful tests are pointing at a real design issue:

  • too many responsibilities
  • hidden dependencies
  • giant methods
  • unclear inputs and outputs
  • logic coupled to infrastructure

One habit I wish I started sooner was writing tests not as a coverage exercise, but as a design pressure test.

Can I test this behavior without booting half the app?
Can I understand what the unit is supposed to do?
Is the setup telling me the object graph is out of control?

I still don’t worship coverage numbers. I’ve seen teams hit their target with tests that mainly prove the mocking framework is operational.

I care more about high-value tests around risky logic.

Pricing.
Permissions.
State transitions.
Date boundaries.
Anything involving money, time, or “should never happen.”

Basically the stuff that always happens.

8. I started respecting async all the way through

I definitely had a phase where I used async like decorative parsley.

A little async here.
A little .Result there.
A sneaky .Wait() when deadlines got close and patience got low.

That phase produced exactly the kind of bugs you’d expect.

Awkward blocking. Confusing behavior under load. Threads doing unnecessary gymnastics. And the occasional feeling that the app was personally disappointed in me.

Once I really understood that async is not just syntax but a design choice, things got better.

If you’re going async, go async properly through the call chain where it makes sense. Don’t mix sync and async casually just because it “works on my machine.”

And yes, there are tradeoffs.

Not every method needs to become async by default.
Not every CPU-bound operation benefits from it.
Sometimes the added complexity is not worth it in simple code paths.

But in I/O-heavy web apps, background processing, or service-to-service communication, being sloppy with async creates problems that only show up once traffic arrives. Which is a very rude time to learn.

9. I learned to optimize later, but measure sooner

One of the strangest developer habits is how often we guess performance problems with absolute confidence.

We see a loop and panic.
We see LINQ and assume it’s the villain.
We see allocation and start speaking in benchmark tongues.

Meanwhile the actual bottleneck is a database call doing interpretive dance across three joins and a missing index.

I wish I’d started measuring earlier.

Not because every app needs obsessive profiling. Most business applications are not Formula 1 cars. They are office sedans. Reliable matters more than exotic.

But you should still know where your application is paying.

I once spent time refactoring in-memory processing because I was convinced it was the hot path. Cleaned it up, reduced allocations, felt smart.

It made almost no difference.

Later we traced the real slowdown to repeated external service calls in a loop. Classic. Embarrassing. Educational.

That habit stuck with me: don’t bring performance opinions to a metrics fight.

Measure first.
Then optimize the thing that’s actually slow.
Then re-measure, because confidence is not telemetry.

10. I stopped over-abstracting code “for the future”

This one hurt, because I genuinely believed I was being responsible.

I used to create extra interfaces, extra layers, extra extension points, and extra generic plumbing for use cases that did not yet exist. Sometimes I’d tell myself we were “keeping it flexible.”

A lot of the time, I was just adding ceremony.

Abstractions are great when they protect you from volatility.

They are terrible when they protect you from imaginary futures.

I’ve seen teams build five layers around a repository that only one implementation would ever have. I’ve done it too. We call it architecture. Then six months later nobody wants to touch it because a simple query now requires a guided tour.

These days, I ask a more annoying question:

What pain is this abstraction solving right now?

If the answer is vague, I usually wait.

This doesn’t mean never designing ahead. Sometimes future-proofing is justified. Shared libraries, integration boundaries, public APIs, reusable platforms. Sure. Think ahead.

But for normal product code, over-abstraction is one of the fastest ways to make simple things feel expensive.

A lot of “senior” code is just junior code with more layers.

11. I started treating code reviews like design reviews, not typo hunts

The best code reviews I’ve been part of were never just about syntax or formatting.

Those can be automated. Let the tools do their job.

The real value is in asking better questions:

  • Is this the right place for this logic?
  • Is the naming clear enough?
  • What happens when this input is missing?
  • Are we making a business rule explicit or burying it?
  • Will this still make sense in six months?

One of the most useful habits I picked up was leaving review comments that explain the future maintenance cost, not just the current style preference.

That creates better conversations.

It also makes your team better.

A review that says “rename this variable” is okay.

A review that says “this name hides a business rule and future readers may miss why this branch exists” is much more valuable.

Good reviews are not about sounding smart.

They’re about helping the codebase age more gracefully.

12. I finally accepted that consistency beats personal genius

This might be the least sexy opinion in the article, but it’s one I believe strongly.

A consistent codebase with mostly good decisions beats a brilliant codebase with five different philosophies fighting in the hallway.

I care less now about whether something is my favorite pattern and more about whether the team can use it predictably.

Consistency helps onboarding.
Consistency helps code review.
Consistency helps debugging.
Consistency helps that horrible moment when you need to patch something quickly in a part of the system you didn’t write.

Personal genius is overrated in team software.

I’ve worked in codebases where every senior engineer had their own style, their own architecture preferences, their own naming conventions, their own favorite ways to handle errors. Individually, a lot of the code was good. Collectively, it felt like a panel interview.

That’s exhausting.

Great teams reduce unnecessary variation.

Not because creativity is bad.

Because maintenance is real.

What changed after I built these habits?

I didn’t suddenly become a 10x mythical forest creature.

What changed was more practical than that.

My pull requests got easier to review.

My bugs got easier to trace.

My code got easier to change without fear.

I stopped confusing complexity with maturity.

And maybe most important, I became less attached to writing code that impressed people and more interested in writing code that helped teams move.

That’s a much better game.

Because in real engineering work, nobody gives you a trophy for making a method elegant at the cost of comprehension. You get rewarded for helping the team ship, maintain, debug, and evolve software without turning every release into a trust fall.

That’s the job.

Practical takeaways you can use this week

If I had to make this painfully actionable, I’d start here:

Turn on nullable reference types in the next service you touch.

Pick one vague class or method name and rename it to reflect actual responsibility.

Move one chunk of business logic out of a controller and into a testable service.

Add one structured log around a business decision that would matter during an incident.

Review one async flow and remove any lazy .Result or .Wait() usage.

Delete one abstraction that exists mostly to look sophisticated.

That’s enough to create momentum.

You do not need a full rewrite.
You do not need a manifesto.
You do not need a three-week architecture discussion with a Miro board and emotional damage.

You need better defaults.

Final thought

The biggest shift in my .NET career was realizing that great code usually doesn’t feel magical when you write it.

It feels a little plain.
A little disciplined.
Sometimes even a little boring.

Then six months later, when a requirement changes, a bug appears, or a teammate jumps in cold, that “boring” code suddenly looks beautiful.

That’s the trick nobody tells you early enough.

The habits that feel less exciting today are often the ones that make you look much better tomorrow.

And if you’ve got a .NET habit you learned embarrassingly late, I’d genuinely love to hear it.

Those are usually the best ones.

Top comments (0)