newsletter

40 Lessons I Learned in 12 Years as a .NET Developer

10 min read

Newsletter Sponsors

Copied

Smarter AI Test Generation (Sponsored)

AI test generation sounds great – until your agent burns half its token budget just finding the right files. JetBrains Rider 2026.2 introduces a new agent skill for Claude and Codex that leverages IDE-native coverage data to cut the token spend by up to 50%. And the best part? The tests come out consistent with your project's conventions.

πŸ‘‰ Learn how it works

Copied

Label a GitHub Issue, Get Back a Pull Request (Sponsored)

Your backlog is full of small, well-scoped issues that nobody has time to pick up. With Coder Agents, you add a label like "coder" to a GitHub issue and a background agent reads the context, writes the code, and opens a pull request for you to review β€” all running on infrastructure you control.

πŸ‘‰ See how Coder Agents work

17 years ago, I wrote my first line of C# code (.NET Framework 3.5). It was in the IT school.

12 years ago, I wrote my first application in .NET and pushed it to production. It worked, but it had bad code. I did not know it at the time, but I would spend the next few years learning how to improve.

Over more than 12 years, I have shipped buggy releases. I have over-engineered features nobody asked for. I have argued about tabs and spaces in pull requests.

I have debugged distributed systems on weekends, missed estimates, and watched "future-proof" architectures rot into legacy.

Some lessons were painful. Others were priceless.

None of them came from a textbook; they came from real projects, real bugs, and real teammates who pushed me to be better.

Today, I want to share the 40 most important takeaways I have learned over my career. Let's dive in.

Copied

0. The best code is the one you don't write

Every line of code is a future liability. It needs to be read, tested, debugged, deployed, and maintained for years.

The cheapest, safest, fastest code is the code that never gets shipped.

Before writing anything new, I ask myself: do we really need this? Is there a simpler way? Can an existing tool, library, or feature solve the problem instead?

Many projects fail because they have too much code. Less code means fewer bugs, faster onboarding, and less time spent maintaining things nobody uses.

Copied

1. You're not paid to write code - you're paid to solve problems

Code is just a tool. The real product is the value you deliver to the business and to the users.

I have seen developers spend weeks polishing a feature nobody asked for, while a critical bug stayed in production. I have seen teams build complex frameworks when a simple script that runs in 5 minutes would have done the job.

The best engineers I know start with the problem, not with the technology. They ask questions, understand the context, and only then choose the right tool. And sometimes that tool is no code at all.

Copied

2. Everything is a trade-off. There's no "best" tool

Every architectural decision, framework choice, or design pattern has costs. SQL or NoSQL? Microservices or Monolith? CQRS or simple CRUD? The answer is always: it depends.

When someone tells me a tool is "the best," I ask: best for what? Best at what scale? Best for which team?

My rule: pick the simplest option that solves today's problem and won't paint you into a corner tomorrow. Trade-offs are the essence of software engineering. The mistake is pretending they don't exist.

Copied

3. Write code that other developers will enjoy working with

You are not the only person who will read your code. Your teammates, your future self, and the developer who joins next year all depend on the code being clear and friendly to work with.

Treat code reviews, naming, formatting, and structure as easy to understand as possible. Clean code is a gift to the next person who will work and debug your code.

When I write something, I ask: would a developer understand this in five minutes? If not, it needs more refactoring.

Copied

4. Write meaningful commit messages

A good commit message tells a story. It explains what changed and, more importantly, why.

Compare these two commits:

fix: bug in orders request
fix: prevent duplicate creation of orders when client retries POST Clients sometimes retry POST /api/orders due to network timeouts. We now use the Idempotency-Key header to detect retries and return the original response instead of creating a duplicate order.

The second one helps the next developer understand the problem and the fix without opening the code diff.

I follow the Conventional Commits format: feat, fix, BREAKING CHANGE, refactor. It makes the history searchable, automation-friendly, and easy to review.

Copied

5. First make it work, then make it pretty

I used to spend hours polishing a function before it even worked end to end. Big mistake.

Premature polishing is a form of procrastination. You optimize details that may turn out to be wrong once you see the real behavior.

The right order is: working code first, then refactor. Get the green test, the working endpoint, and the integrated feature: then improve naming, structure, and performance.

Copied

6. Ship early, iterate often

A feature behind a feature flag in production is worth ten features still on a branch.

Real users find real problems. Real data shows real patterns. No amount of staging-environment testing replaces production feedback.

Ship the smallest useful feature, learn from how it behaves, and iterate. This applies to features, internal tools, and even the architecture itself.

The longer the code stays unshipped, the more fear builds up around releasing it. Small, frequent releases reduce risk far more than big, careful ones.

Copied

7. Estimations are never true

Software estimation is a rough guess. Every project has unknowns: integrations that misbehave, requirements that shift, dependencies that break.

I have learned to give ranges instead of single numbers, and to say "I don't know yet" and what is blocking me from making an estimate.

I split big tasks into small ones. Small tasks are easier to estimate and easier to ship. The goal of an estimate is not to be exact, it is to surface risk and align expectations.

If a stakeholder needs precision, what they actually need is a smaller scope.

Copied

8. Refactor continuously and incrementally

Big refactors fail. They take months, drift away from the main branch, and get cancelled when priorities change.

I prefer the Boy Scout rule: leave the code a little better than you found it. Rename a confusing variable. Extract a small method. Delete a dead branch. Every time you touch a file.

Small, continuous improvements add up. They keep the codebase healthy without ever needing a "refactor sprint" that nobody approves.

Copied

9. Code reviews improve more than just quality β€” they improve teams

Reviews are the cheapest and most powerful way to spread knowledge across a team. Junior developers learn patterns. Senior developers explain their reasoning. Everyone shares context about the codebase.

A good review is a conversation that addresses the code. It asks questions, suggests alternatives, and praises good ideas. A bad review is short, harsh, and focused on style nits.

When my team reviews well, the code gets better, the bus factor improves, and the whole team levels up together.

Copied

10. Never trust user input.

Every input from outside your system is subject to wrong data or even malicious attacks. That includes form fields, query strings, headers, file uploads, message queue payloads, and even responses from "trusted" upstream services.

Validate at the boundary. Use strict types. Reject early.

Validation libraries like FluentValidation make this cheap. The cost of skipping it: corrupted data, security holes, mysterious bugs.

Copied

11. Log precisely, not excessively.

Logs that contain everything tell you nothing. They become noise that slows down debugging.

Log meaningful events with structured data. Use proper log levels: Information for business events, Warning for recoverable issues, Error for real problems. Debug is for internal details that are not always needed.

csharp
// Bad - noisy and unstructured logger.LogInformation("Entered method"); logger.LogInformation("Got order " + orderId + " for customer " + customerId); logger.LogInformation("Returning result"); // Good - structured and intentional logger.LogInformation( "Order {OrderId} created for customer {CustomerId} with total {Total:C}", order.Id, order.CustomerId, order.Total);

Structured logs are searchable in Seq, Elastic, or Application Insights.

Copied

12. Automate everything that can be automated

Manual steps are where bugs live. Anything you do twice should be turned into a script. Anything you do ten times should be a pipeline.

Automate builds, tests, deployments, code formatting, dependency updates, and database migrations. Automation does not just save time, it removes the chance of human error.

If a process is too complex to automate, simplify it first.

And with AI tools available, it's easy to automate a lot of things.

Copied

13. Complexity kills projects, don't over-engineer

The fastest way to kill a project is to build for an imaginary future.

Most projects died with 1000 users, not with 1,000,000.

I have seen teams design for "1 million users" when they had 5000. They built abstractions for swappable databases, microservices for a 5-page admin tool, and event sourcing for a CRUD form. As a result, the project missed the deadline by a month.

Build for the problem you have today. Add complexity only when the simple solution starts to fail.

Most "future-proof" code becomes legacy code before its imagined future ever arrives.

Copied

14. Fix root causes, not symptoms

A try/catch that swallows an exception fixes the symptom. The bug is still there.

When something goes wrong, ask "why" until you reach the actual cause.

The five-whys technique works well: each answer reveals the next layer.

Symptom: API returns 500.

  • Why? Because the database call timed out.
  • Why? Because there was a long-running query.
  • Why? Because the query was missing an index.
  • Why? Because no one has tested it with realistic data volume.

The fix is not "increase the timeout." The fix is "add the index, and run load tests before merging."

Copied

15. Measure first, optimize second

Performance intuition is almost always wrong. The slow part is rarely where you think it is.

Before optimizing anything, measure: with a profiler, BenchmarkDotNet, OpenTelemetry, Application Insights, or simple timestamps in logs. Optimize the actual hot path.

I have spent days "improving" code that ran once a day, while a 100ms query running thousands of times per hour went unnoticed. Numbers beat opinions every time.

Copied

16. Minimize coupling, maximize cohesion

Coupling is how much your modules depend on each other. Cohesion is how related the things inside a module are.

Tightly coupled code is hard to change - touching one place breaks five others. Low-cohesion code is hard to find - related logic is scattered across the project.

Aim for the opposite: each module does one thing well, and modules communicate through small, stable interfaces. This is the heart of modular monoliths, clean architecture, and vertical slices alike.

Copied

17. Keep third-party dependencies minimal and well-managed

Every dependency is a bet. You bet that the library will keep working, stay maintained, and not introduce a security issue.

Before adding a NuGet package, ask: do I really need this? Could a few lines of code solve the same problem?

When you do depend on something, manage it carefully. Use central package management, pin versions, scan for vulnerabilities, and update regularly.

Copied

18. Never hard-code sensitive information

Secrets in source control are a classic, expensive mistake. Once a key is in git history, you have to rotate it everywhere and pray no one cloned the repo before you noticed.

Use proper secret management from day one:

For local development, use User Secrets (dotnet user-secrets). For production use, use Azure Key Vault, AWS Secrets Manager, or HashiCorp Vault. For CI/CD, use encrypted environment variables.

Treat any leaked secrets as compromised and rotate them immediately.

Copied

19. Errors should fail loudly and immediately

Silent failures are the worst kind. The system looks healthy, the logs look clean, and the data is wrong.

If something is broken, it should be impossible to ignore. Throw a clear exception, return a real error response, and let the upstream layer decide what to do.

Copied

20. Choose clarity over cleverness

A clever one-liner that takes 5 minutes to read is worse than five plain lines anyone can understand in a few seconds.

csharp
// Clever - dense, hard to debug, hard to extend var totals = orders .GroupBy(o => o.CustomerId) .ToDictionary(g => g.Key, g => g.SelectMany(o => o.Items) .Sum(i => i.Quantity * i.Price * (1 - (i.Discount ?? 0)))); // Clear - longer, but easy to follow and easy to change var totals = new Dictionary<int, decimal>(); foreach (var customerOrders in orders.GroupBy(o => o.CustomerId)) { decimal customerTotal = 0; foreach (var order in customerOrders) { customerTotal += order.CalculateTotal(); } totals[customerOrders.Key] = customerTotal; }

The clear version is longer, but it can be debugged, extended, and understood at a glance. The codebase is read far more often than it is written. Optimize for the reader.

Copied

21. Choose descriptive naming over explanatory comments

A comment that explains what a variable means usually means the variable has the wrong name.

csharp
// Bad - the comment compensates for a poor name // Number of days the order can still be cancelled int d = 7; // Good - the name speaks for itself int cancellableWindowInDays = 7;

Good names eliminate the need for many comments. They turn the code itself into documentation that cannot get outdated.

You can replace comments with:

  • Variables with descriptive names.
  • Private methods with descriptive names.
  • Classes with descriptive names.
  • Tests that act as documentation on the behavior.

Use comments only to explain WHY when the code behavior is not obvious. And never write comments to explain WHAT.

Note: XML code summary is very useful, it's not the same as comments. Write code summary for public members whenever possible.

Copied

22. Favor composition over inheritance

Inheritance is rigid. Once you extend a base class, you inherit its structure, its assumptions, and its surface area, forever. And it's hard to change the order of classes in the hierarchy.

Composition is flexible. You combine small pieces with clear responsibilities and swap them out as needs change.

csharp
// Inheritance - tightly bound to the base class hierarchy public abstract class PaymentProcessorBase { /* ... */ } public class StripePaymentProcessor : PaymentProcessorBase { /* ... */ } public class LoggingStripePaymentProcessor : StripePaymentProcessor { /* ... */ } // Composition public class PaymentProcessor( IPaymentGateway gateway, IFraudDetector fraudDetector, IPaymentLogger logger) { public async Task<PaymentResult> ChargeAsync(PaymentRequest request, CancellationToken ct) { if (!await fraudDetector.IsSafeAsync(request)) { logger.Log(request, PaymentResult.Rejected); return PaymentResult.Rejected; } var result = await gateway.ChargeAsync(request, ct); logger.Log(request, result); return result; } }

In modern .NET, primary constructors and dependency injection make composition almost free. Reach for inheritance only when you truly model an "is-a" relationship, and even then, prefer interfaces.

Copied

23. Complexity doesn't scale; simplicity does

Every layer of abstraction, every "just in case" feature, every flexible config - they all add weight. At first, the cost is invisible. Over the years, it has become the reason features take a month instead of a week.

Simple code scales. Simple architecture scales. Simple processes scale.

When a system becomes hard to change, it is usually because the solution has grown too complex.

Copied

24. Respect the principle of least surprise

Code should do what its name and signature suggest. Nothing more.

A method called GetUserById should not also send an email. A property called IsActive should not perform a database call.

A Validate method should not throw because the database is down.

And often the code surprises the reader when it crashes in the runtime or returns a weird error.

Copied

25. Remove unused code without hesitation

Dead code lies. It looks important, takes up screen space, and confuses every reader who tries to understand what the system actually does.

"But maybe we will need it later" is not a reason to keep it. Git remembers everything. If you ever need it back, it is one git log away.

Delete unused classes, unused parameters, unused branches, and commented-out blocks. The codebase will breathe again.

Copied

26. Think and code in small, testable units

Big classes and long methods are hard to maintain. They have too many dependencies, too many code paths, and too many side effects.

Small units are easy to test, easy to reason about, and easy to compose. A method that does one thing, a class with a single responsibility, a Handler that only creates a payment - they all become easy to reason about.

Copied

27. Be consistent with your coding standards

Consistency is more valuable than any specific style choice. Tabs versus spaces, var versus explicit types, where the brace goes - none of it matters as much as picking one rule and sticking to it.

.editorconfig, analyzers, and formatters take the debate out of pull requests. The team agrees once, and the tooling enforces it forever. Reviewers focus on logic, not on formatting.

Copied

28. Abstraction should hide complexity, not create it

A good abstraction makes the caller's life simpler. A bad one adds a layer for the sake of having a layer.

Many "abstractions" I have seen in the wild were just thin wrappers around the underlying tool:

  • Repositories that mirrored EF Core's DbSet
  • Services that wrapped a single method call
  • Factories that created a single class.

Before introducing an interface, ask: does this hide real complexity, or does it just hide the type? If you cannot point to the complexity it hides, you do not need the abstraction yet.

Copied

29. Favor explicit over implicit

Magic feels clever until something goes wrong. Then nobody can find what is happening or where.

csharp
// Implicit - magic interceptor silently rewrites DELETE into UPDATE // Somewhere in a service β€” looks like a real delete, but isn't await context.Orders.DeleteAsync(orderId); await context.SaveChangesAsync(); // interceptor silently kicks in public class SoftDeleteInterceptor : SaveChangesInterceptor { public override ValueTask<InterceptionResult<int>> SavingChangesAsync( DbContextEventData eventData, InterceptionResult<int> result, CancellationToken ct) { var entries = eventData.Context!.ChangeTracker.Entries<ISoftDeletable>() .Where(e => e.State == EntityState.Deleted); foreach (var entry in entries) { entry.State = EntityState.Modified; entry.Entity.IsDeleted = true; entry.Entity.DeletedAt = DateTime.UtcNow; } return base.SavingChangesAsync(eventData, result, ct); } }
csharp
// Explicit - the intent is visible right where the action happens public async Task SoftDeleteOrderAsync(Guid orderId, CancellationToken ct) { var order = await context.Orders.FindAsync(orderId, ct); if (order is null) { return; } order.IsDeleted = true; order.DeletedAt = DateTime.UtcNow; await context.SaveChangesAsync(ct); }

Explicit code is searchable, debuggable, and self-documenting. Implicit code saves a few keystrokes and costs hours of investigation later.

Copied

30. Every line of code should justify its existence

This is the question I ask in every code review: why is this line here?

If the answer is "just in case," "we might need it later," or "I'm not sure," it does not belong in the codebase.

Each method, parameter, branch, and dependency adds maintenance cost. It needs to earn its place.

Copied

31. Always strive to understand the business behind the code

Code without a business context is dangerous. You can ship technically correct features that solve the wrong problem.

Talk to product managers, support, and even customers when you can. Understand what the user is trying to accomplish, not just what the BRD says.

The best engineers I have worked with knew the business as well as they knew the code. They proposed better solutions because they understood the actual goal.

Copied

32. Keep Pull Requests small and manageable

A 2,000-line PR gets an LGTM comment. A 200-line PR gets a real review.

Reviewers cannot hold thousands of lines in their heads. They skim, miss issues, and approve out of fatigue. Small PRs get attention, real feedback, and faster merges.

And the same goes for AI review tools, the more code you have, the less quality review AI tools can make.

Break work into multiple smaller commits, code into vertical slices. Ship a small, working improvement, then build on it.

Copied

33. Invest in CI/CD right from the start

A new project without CI/CD is a project that already has technical debt.

On day one, set up a pipeline that builds, runs tests, and (when ready) deploys. GitHub Actions, Azure DevOps, GitLab β€” pick one and start small.

Once the pipeline exists, every change is tested automatically, every deploy is reproducible, and you don't need to change your code later to make it fit the CI/CD pipeline.

Copied

34. Design APIs that are easy to use correctly and hard to misuse

The shape of an API guides its users. If misuse is possible, someone will eventually do it. If correct usage requires reading the docs, half your users will not.

csharp
// Easy to misuse - which is "from" and which is "to"? public Money Convert(decimal amount, string from, string to); Convert(100, "EUR", "USD"); Convert(100, "USD", "EUR"); // both compile, only one is correct // Hard to misuse - the types tell the story public Money Convert(Money amount, Currency target); Convert(new Money(100, Currency.EUR), Currency.USD);

Strong types, named parameters, builder patterns, and required arguments make wrong code feel wrong. The compiler becomes your first reviewer.

And the same goes for Web APIs.

If you want to dive deeper, check my article on best practices for building REST APIs.

Copied

35. Document why, not just what

Good code shows what it does. Comments can only explain why it does it that way, when it's not obvious from the code.

csharp
// Bad - the comment just repeats what the code already says // Skip inactive users var activeUsers = users.Where(u => u.IsActive); // Good - the comment explains what the code cannot // Inactive users still appear in the database for audit purposes, // but they must never receive communications per GDPR Article 17. // Filtering here is the last safety net before any message is dispatched. var activeUsers = users.Where(u => u.IsActive);
Copied

36. Forget about "it works on my machine"

If a test passes only on your machine, it does not pass. If a deploy works only when you do it, it is not a real deploy.

Use containers for development. Use the same .NET version, the same database, and the same dependencies as in production. Aspire and Docker Compose make this trivial in modern .NET.

The goal: anyone on the team can clone the repo, run one command, and have a working environment in a few minutes.

Copied

37. Be aware of technical debt; repay it incrementally

Technical debt is not evil. Sometimes it is the right call: ship now fast, refactor later. The mistake is pretending it does not exist.

Track it. Talk about it. Schedule small cleanups inside every sprint. Use the Boy Scout rule.

A focused 10% per release adds up to a healthier codebase over a year, far more than a "big refactor month" that never gets approved.

Debt you ignore compounds. Don't do it.

Copied

38. Balance YAGNI ("You Aren't Gonna Need It") with thoughtful design

YAGNI tells you not to build features you do not need yet. It is a great rule against over-engineering.

But YAGNI is not an excuse to ignore obvious extension points. If you know more notification channels will be added, do not hard-code one. If you know that your Service might move from a Monolith to a Servless function, design it in a flexible way.

Keep it simple today, but don't make it hard to change tomorrow. Make today's solution as simple as possible, while leaving room for extension and evolution in the future.

Copied

39. Ask for help when you're stuck

Don't waste hours and days stuck on a problem. Often, your problem can be solved by the right people in a few minutes.

A good rule of thumb: try to fix for 1-2 hours, then ask. The senior on your team probably solved this exact issue last year.

Pair on it. Post in the team chat. Open a ticket with a vendor.

Asking for help is how you grow faster. The best engineers I know are the ones who were never afraid to say "I don't know", no matter how senior they were.

Copied

40. Never stop learning and questioning your assumptions

The code I started with looked nothing like what I write today. The patterns I swore by ten years ago, I would push back on now.

Read books, blogs. Read source code. Watch online courses.

Follow the people building the tools you use. Try new languages. Question the way your team has "always done it."

The day you stop learning is the day your skills start to expire.

Twelve years in, I am working as a Software Architect, and I learn even more than I did 12 years ago.

Hope you find this newsletter useful. See you next time.

Whenever you're ready, here's how I can help you:

The .NET Senior Playbook is built to:

  • Fast-track you from junior or mid-level to senior
  • Keep you growing as a senior
  • Help you beat any .NET interview

Covers everything: C#, ASP.NET Core, EF Core, system design β€” answer each question first, reveal the solution, and a test after every chapter proves it stuck. Finish, and you earn a verifiable certificate for your LinkedIn.

The .NET Senior Playbook
View the Playbook

Enjoyed this article? Share it with your network

Improve Your .NET and Architecture Skills

Join my community of 25,000+ developers and architects.

Each week you will get 1 practical tip with best practices and real-world examples.

Learn how to craft better software with source code available for my newsletter.

Join 25,000+ developers already reading
No spam. Unsubscribe any time.