TL;DR
- Turn vague product requests into precise clarifying questions and plain-language steps before touching code.
- Break logic into small, single-purpose functions, test edge cases (nulls, time zones, paid boundaries), and avoid embedding clock calls inside business logic.
- Master practical day-one debugging skills: isolating stack traces, setting breakpoints, using watch expressions, and effective error searching.
Level: Beginner · Prerequisites: A basic .NET developer environment installed and a willingness to ask questions before coding · Reading time: 10 min
The product owner stops by your desk: “Can you send a reminder to customers whose invoices are overdue?” She leaves. You open the editor and stare at an empty file. Where does the code even start? What is an invoice in this system? What does overdue mean? Email or SMS?
Many beginners start typing anyway. Two hours later there is a loop, a half-written email template, and a growing feeling that the thing being built is not the thing that was asked for. The problem is not your C#. The problem is that the request was never turned into something precise enough to code.
This article walks through that translation with a real example, ends with a console app you can run, and then covers the three skills that make the difference on day one: reading an error, using the debugger, and searching for the right thing.
What you will be able to do after this
- Turn a vague sentence into a list of clarifying questions and a plain-language plan
- Break the plan into small C# functions that each do one thing
- Handle the edge cases the request did not mention (missing dates, paid invoices, time zones)
- Read a stack trace and find the line that matters
- Use breakpoints, step over, and the watch window to see what your code actually does
Ask before you type
A request like “remind customers with overdue invoices” hides at least five decisions. Write them down and ask. Here are the questions and the answers the product owner gave:
| Question | Answer |
|---|---|
| What does “overdue” mean? | The due date has passed and we allow three days of grace |
| Which customers? | Anyone with at least one unpaid overdue invoice |
| What if the invoice was paid after the due date? | Skip it, paid is paid |
| Invoices without a due date? | Those are drafts, ignore them |
| How often and through which channel? | Once a day, by email, one email per customer listing all their overdue invoices |
Ten minutes of questions saved a day of rework. Notice that “one email per customer” changes the shape of the code: you need to group invoices by customer, not send one message per invoice.
Write the steps in plain language
Before any C#, write the algorithm as sentences a non-programmer could check:
- Take today’s date (in the company’s time zone).
- Take all invoices.
- Keep the ones that are not paid, have a due date, and whose due date is more than three days ago.
- Group them by customer.
- For each customer, build one message listing the invoices and the total.
- Send the messages.
Each line becomes one function or one LINQ call. If a line is hard to write as a sentence, it will be hard to write as code; ask more questions.
Turn each step into a small function
Create a console app and paste the code below into Program.cs. It is complete: it uses an in-memory list instead of a database so you can run it right now. DateOnly is used because an invoice is due on a day, not at a time.
| |
| |
Run it with dotnet run:
| |
Map the code back to the plan. FindOverdue is step 3. It receives the list, today’s date, and the grace period as parameters; it never reads the clock itself, which is what makes it testable: you can pass any “today”. GroupBy is step 4. BuildReminder is step 5. Step 6, sending, is deliberately missing; it will be an email service later, and the first version proves the logic without touching a mail server.
Each function is small enough to explain in one sentence. That is the test for whether you have split the problem enough.
Handle the edge cases the request did not mention
The sample data already contains the traps:
- Invoice 1002 was paid late.
PaidOn is nullfilters it out. Without that line, a customer who already paid gets a reminder, and you get an angry phone call. - Invoice 1004 has no due date.
DueDate is not nullskips it. Without it,DueDate.ValuethrowsInvalidOperationExceptionat runtime, usually on the one record you did not test with. - Invoice 1005 is four days late, just past the three-day grace period. Boundary values are where off-by-one bugs live. Change
todayto 2026-10-11 and confirm 1005 disappears from the output.
“Today” is the subtle one. DateTime.Now on a server in a Frankfurt data centre is not today in Istanbul at 01:00. For now, compute today in the company’s time zone and pass it in:
| |
Time zones get their own article later in the series. The habit to keep is that business functions receive today as a parameter and never call the clock themselves.
Test with tiny data, then iterate
The five-invoice list is the test. Each invoice exists to prove one rule: one is plainly overdue, one is paid, one is in the future, one has no due date, one sits on the boundary. When you later move the data to SQL Server and the sending to an email service, FindOverdue and BuildReminder do not change, and the same five records become your first unit tests.
Then iterate. The product owner reads the message, asks for the customer’s name in a greeting line, and you add one parameter to BuildReminder. Small functions make small changes.
Reading an error without panicking
Suppose a colleague removes the i.DueDate is not null && part of the filter. The next run prints something like this (your line numbers and the compiler-generated names will differ):
| |
Read it in this order:
- The first line is the exception type and message. “Nullable object must have a value” means something called
.Valueon a null. - The lines starting with
atare the stack trace, newest call first. The top frame is inside .NET itself. Skip every frame that is not in your code. - The first frame with a path to your file is the one to open:
Program.cs:line 30. That is the lambda insideWhere, and that is where the fix goes. - Lines that mention
System.LinqorSystem.Collectionsare plumbing between your frames. They tell you the path the call took, not where the bug is.
Using the debugger instead of Console.WriteLine
- Set a breakpoint by clicking the margin next to
var cutoff = ...(or press F9 on that line). Press F5. Execution stops there, and hovering over any variable shows its value. - Step over (F10) runs the current line and stops at the next one. Step into (F11) goes inside a method call. Step out (Shift+F11) finishes the current method and stops in the caller.
- The Watch window lets you type
cutofforinvoices.Countand keeps the value updated after every step. The Locals window shows everything in scope without typing. - A conditional breakpoint stops only when something is true. Right-click the breakpoint inside the
Wherelambda, choose Conditions, and enteri.Number == 1004. Now the debugger stops on the invoice you care about instead of 4,000 times.
VS Code with the C# Dev Kit uses the same keys and the same windows under slightly different names.
Searching for an error the right way
- Copy the exception type and the message, not your variable names:
InvalidOperationException "Nullable object must have a value". - Add the technology:
C#,EF Core, orASP.NET Core, plus the version if you suspect behaviour changed between versions. - Prefer learn.microsoft.com, the library’s GitHub issues, and Stack Overflow answers with recent dates.
- Read the question before the answer. Half the time the asker’s situation differs from yours in the one detail that matters.
- When an AI assistant suggests code, run it against the five-invoice data set before trusting it.
Who you work with
- The business analyst (or product owner) writes the request and the acceptance criteria. They expect you to ask questions early, in writing, and to say when something is ambiguous rather than guess and deliver the wrong thing confidently.
- The tester (QA, quality assurance) checks the result against the acceptance criteria and hunts for edge cases. They expect a note on what changed, how to reproduce it, and which cases you already covered. Give them the five-invoice list; they will add five more.
- Other developers review your code and maintain it later. They expect small functions, names that say what things are, and a pull request they can read in ten minutes.
Common mistakes
- Starting to code before the request is precise. You build the wrong thing well. Write the questions table first and get answers in writing.
- Reading the clock inside business logic.
DateTime.NowinsideFindOverduemakes it untestable and time zone dependent. Passtodayin as a parameter. - Using the null-forgiving operator to silence a nullable warning instead of filtering. The runtime exception arrives on production data. Filter with
is not nulland let the compiler stop warning on its own. - One giant method that selects, formats, and sends. You cannot test the selection without sending emails. Split by plan step.
- Testing only the happy path. Include a paid invoice, a missing date, a future date, and a boundary record in the first data set.
- Searching with your own variable names or file paths in the query. Nobody else has
Store.Reminders. Search the message. - Reading only the first line of a stack trace. The line number of your bug is a few lines down.
Checklist
- Clarifying questions and their answers written down before coding
- Plan written as numbered plain-language steps
- One function per step, each explainable in a sentence
todayand other external inputs passed as parameters- Test data includes null, paid, future, and boundary cases
- Expected output written down before running
- Stack trace read down to the first frame in your own file
- Breakpoint and Watch window, not
Console.WriteLine, used to inspect state
What’s next
The next article, “Git for Daily Work: Branches, Pull Requests, and Fixing Mistakes”, puts this program under version control and shows how to share it without overwriting anyone’s work. After that, Part 2 starts with “C# Types in Practice: Value vs Reference, decimal vs double, and Nullable”, which explains why the amounts above are decimal and the dates are DateOnly?.
Tags: csharp, dotnet, programming, debugging, beginners
