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.
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.
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.
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.
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.
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.
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.
A good commit message tells a story. It explains what changed and, more importantly, why.
Compare these two commits:
fix: bug in orders request
1fix: prevent duplicate creation of orders when client retries POST
23Clients sometimes retry POST /api/orders due to network timeouts.
4We now use the Idempotency-Key header to detect retries and return
5the 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.
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.
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.
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.
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.
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.
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.
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
1// Bad - noisy and unstructured2logger.LogInformation("Entered method");3logger.LogInformation("Got order "+ orderId +" for customer "+ customerId);4logger.LogInformation("Returning result");56// Good - structured and intentional7logger.LogInformation(8"Order {OrderId} created for customer {CustomerId} with total {Total:C}",9 order.Id, order.CustomerId, order.Total);
Structured logs are searchable in Seq, Elastic, or Application Insights.
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.
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.
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.
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.
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.
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.
A clever one-liner that takes 5 minutes to read is worse than five plain lines anyone can understand in a few seconds.
csharp
1// Clever - dense, hard to debug, hard to extend2var totals = orders
3.GroupBy(o => o.CustomerId)4.ToDictionary(g => g.Key, g => g.SelectMany(o => o.Items)5.Sum(i => i.Quantity * i.Price *(1-(i.Discount ??0))));67// Clear - longer, but easy to follow and easy to change8var totals =newDictionary<int,decimal>();910foreach(var customerOrders in orders.GroupBy(o => o.CustomerId))11{12decimal customerTotal =0;13foreach(var order in customerOrders)14{15 customerTotal += order.CalculateTotal();16}17 totals[customerOrders.Key]= customerTotal;18}
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.
21. Choose descriptive naming over explanatory comments
A comment that explains what a variable means usually means the variable has the wrong name.
csharp
1// Bad - the comment compensates for a poor name23// Number of days the order can still be cancelled4int d =7;56// Good - the name speaks for itself7int 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.
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
1// Inheritance - tightly bound to the base class hierarchy2publicabstractclassPaymentProcessorBase{/* ... */}3publicclassStripePaymentProcessor:PaymentProcessorBase{/* ... */}4publicclassLoggingStripePaymentProcessor:StripePaymentProcessor{/* ... */}56// Composition7publicclassPaymentProcessor(8IPaymentGateway gateway,9IFraudDetector fraudDetector,10IPaymentLogger logger)11{12publicasyncTask<PaymentResult>ChargeAsync(PaymentRequest request,CancellationToken ct)13{14if(!await fraudDetector.IsSafeAsync(request))15{16 logger.Log(request, PaymentResult.Rejected);17return PaymentResult.Rejected;18}1920var result =await gateway.ChargeAsync(request, ct);21 logger.Log(request, result);22return result;23}24}
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.
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.
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.
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.
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.
Magic feels clever until something goes wrong.
Then nobody can find what is happening or where.
csharp
1// Implicit - magic interceptor silently rewrites DELETE into UPDATE23// Somewhere in a service β looks like a real delete, but isn't4await context.Orders.DeleteAsync(orderId);5await context.SaveChangesAsync();// interceptor silently kicks in67publicclassSoftDeleteInterceptor:SaveChangesInterceptor8{9publicoverrideValueTask<InterceptionResult<int>>SavingChangesAsync(10DbContextEventData eventData,InterceptionResult<int> result,CancellationToken ct)11{12var entries = eventData.Context!.ChangeTracker.Entries<ISoftDeletable>()13.Where(e => e.State == EntityState.Deleted);1415foreach(var entry in entries)16{17 entry.State = EntityState.Modified;18 entry.Entity.IsDeleted =true;19 entry.Entity.DeletedAt = DateTime.UtcNow;20}2122returnbase.SavingChangesAsync(eventData, result, ct);23}24}
csharp
1// Explicit - the intent is visible right where the action happens2publicasyncTaskSoftDeleteOrderAsync(Guid orderId,CancellationToken ct)3{4var order =await context.Orders.FindAsync(orderId, ct);5if(order isnull)6{7return;8}910 order.IsDeleted =true;11 order.DeletedAt = DateTime.UtcNow;1213await context.SaveChangesAsync(ct);14}
Explicit code is searchable, debuggable, and self-documenting.
Implicit code saves a few keystrokes and costs hours of investigation later.
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.
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.
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.
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
1// Easy to misuse - which is "from" and which is "to"?2publicMoneyConvert(decimal amount,string from,string to);34Convert(100,"EUR","USD");5Convert(100,"USD","EUR");// both compile, only one is correct67// Hard to misuse - the types tell the story8publicMoneyConvert(Money amount,Currency target);910Convert(newMoney(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.
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
1// Bad - the comment just repeats what the code already says2// Skip inactive users3var activeUsers = users.Where(u => u.IsActive);45// Good - the comment explains what the code cannot67// Inactive users still appear in the database for audit purposes,8// but they must never receive communications per GDPR Article 17.9// Filtering here is the last safety net before any message is dispatched.10var activeUsers = users.Where(u => u.IsActive);
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.
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.
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.
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.