Skip to main content
Published / updated

Guided C# Console Project

Before you start

You need: all of Articles 01–09. This project uses every one of them.

In Visual Studio: one solution holding two projects — SchoolConsole and SchoolConsole.Tests. Article 09 shows how to add the test project and wire the reference.

Time: 8–12 hours across a week. Do not try to finish it in one sitting — build one feature, run it, commit, then start the next.

Goal

Build a working Student Marks Application that uses every part of this track, and be able to explain any line of it without notes.

Assignment

Build a console application for NexCoding Academy that manages students, records exam marks, and produces a class result report.

The application must handle the cases that make this hard: a student who was absent, a class where everyone was absent, a duplicate roll number, a malformed CSV row, and a user who types letters where a number belongs.

Required features

#FeatureUses
1Add a studentClasses, validation, duplicate check
2List students by class and sectionCollections, LINQ filtering, ordering
3Record marks for a subjectNullable types, absent handling
4Show one student's result cardGrading logic, condition order
5Class result reportLINQ grouping and aggregation
6Import students from CSVFile I/O, parsing, error reporting
7Save and load all data as JSONSerialisation, enums as strings
8Menu loopControl flow, TryParse

Required structure

SchoolConsole/
├── Program.cs menu and input only
├── Models/
│ ├── Student.cs
│ ├── Subject.cs
│ ├── ExamResult.cs
│ └── Enums.cs StudentStatus, ExamType
├── Services/
│ ├── StudentService.cs add, find, list
│ ├── GradingService.cs result and grade logic
│ └── ReportService.cs class report
├── Data/
│ ├── IStudentRepository.cs
│ ├── InMemoryStudentRepository.cs
│ └── JsonFileRepository.cs
└── SchoolConsole.Tests/
├── GradingServiceTests.cs
└── ReportServiceTests.cs

Program.cs contains no business logic. It reads input, calls a service, prints the result. If a calculation appears in Program.cs, it is in the wrong file — and it cannot be tested there.

Worked example: the grading service

This is the piece the whole track has been building towards.

namespace SchoolConsole.Services
{
public class GradingService
{
public string GetResult(ExamResult result, Subject subject)
{
if (result == null)
{
throw new ArgumentNullException(nameof(result));
}

if (subject == null)
{
throw new ArgumentNullException(nameof(subject));
}

if (result.IsAbsent)
{
return "Absent";
}

if (!result.MarksObtained.HasValue)
{
throw new InvalidOperationException(
$"Result {result.Id} is not marked absent but has no marks recorded.");
}

if (result.MarksObtained.Value >= subject.PassingMarks)
{
return "Pass";
}

return "Fail";
}

public string GetGrade(ExamResult result, Subject subject)
{
if (result.IsAbsent)
{
return "AB";
}

decimal percentage = (result.MarksObtained.Value * 100m) / subject.MaxMarks;

if (percentage >= 90m)
{
return "A+";
}

if (percentage >= 80m)
{
return "A";
}

if (percentage >= 70m)
{
return "B";
}

if (percentage >= 60m)
{
return "C";
}

if (percentage >= 35m)
{
return "D";
}

return "F";
}
}
}

Four things in that code are deliberate:

  • The absent check is first. Place it after the marks comparison and every absent student is reported Fail, silently.
  • A missing-marks result that is not marked absent throws rather than guessing. Inconsistent data is a bug, not a case to paper over.
  • The percentage uses 100m and decimal, not integer division. (85 * 100) / 100 as int works; (85 * 100) / 150 as int gives 56 where the answer is 56.67.
  • Guard clauses come first, so the calculation below can assume valid input.

Worked example: the class report

namespace SchoolConsole.Services
{
public class ClassResultSummary
{
public string ClassName { get; set; }
public string Section { get; set; }
public int TotalStudents { get; set; }
public int AppearedCount { get; set; }
public int AbsentCount { get; set; }
public int PassedCount { get; set; }
public decimal AverageMarks { get; set; }
public decimal HighestMarks { get; set; }
}

public class ReportService
{
private readonly GradingService _gradingService;

public ReportService(GradingService gradingService)
{
_gradingService = gradingService;
}

public ClassResultSummary BuildClassSummary(
List<ExamResult> results,
Subject subject,
string className,
string section)
{
ClassResultSummary summary = new ClassResultSummary();
summary.ClassName = className;
summary.Section = section;
summary.TotalStudents = results.Count;

List<ExamResult> appeared = results
.Where(r => !r.IsAbsent)
.Where(r => r.MarksObtained.HasValue)
.ToList();

summary.AppearedCount = appeared.Count;
summary.AbsentCount = results.Count - appeared.Count;

if (appeared.Count == 0)
{
summary.AverageMarks = 0m;
summary.HighestMarks = 0m;
summary.PassedCount = 0;

return summary;
}

summary.AverageMarks = appeared.Average(r => r.MarksObtained.Value);
summary.HighestMarks = appeared.Max(r => r.MarksObtained.Value);
summary.PassedCount = appeared.Count(r => r.MarksObtained.Value >= subject.PassingMarks);

return summary;
}
}
}

The empty-appeared guard is not defensive padding. Without it, Average over an empty sequence throws InvalidOperationException — and a class where every student was absent is a real situation, not a hypothetical one.

Absentees are counted, never scored. AbsentCount reports them; the average excludes them. A version using ?? 0 would report an average that is silently too low, on a document a principal reads.

Worked example: the CSV import

public class ImportResult
{
public List<Student> Students { get; set; } = new List<Student>();
public List<string> Errors { get; set; } = new List<string>();
}

public ImportResult ImportStudents(string path)
{
ImportResult importResult = new ImportResult();

if (!File.Exists(path))
{
importResult.Errors.Add($"File not found: {path}");
return importResult;
}

string[] lines = File.ReadAllLines(path);
HashSet<string> seenRollNumbers = new HashSet<string>();

for (int i = 1; i < lines.Length; i++)
{
string line = lines[i];
int rowNumber = i + 1;

if (string.IsNullOrWhiteSpace(line))
{
continue;
}

string[] fields = line.Split(',');

if (fields.Length < 4)
{
importResult.Errors.Add($"Row {rowNumber}: expected 4 fields, found {fields.Length}.");
continue;
}

string rollNumber = fields[0].Trim();

if (string.IsNullOrWhiteSpace(rollNumber))
{
importResult.Errors.Add($"Row {rowNumber}: roll number is empty.");
continue;
}

if (!seenRollNumbers.Add(rollNumber))
{
importResult.Errors.Add($"Row {rowNumber}: duplicate roll number {rollNumber}.");
continue;
}

Student student = new Student();
student.RollNumber = rollNumber;
student.Name = fields[1].Trim();
student.ClassName = fields[2].Trim();
student.Section = fields[3].Trim();
student.Status = StudentStatus.Active;

importResult.Students.Add(student);
}

return importResult;
}

Every rejected row reports its row number and its reason. "Import failed" tells the office nothing; "row 47: duplicate roll number NCA-2024-0012" is a fix.

seenRollNumbers.Add returning false is the duplicate check — one line, no second pass.

A bad row skips; it does not abort the import. 200 good rows should not be lost to one typo.

Required test cases

Write these before you write the code they test.

TestExpected
Absent student, marks null"Absent"
Marks 90, passing 35"Pass"
Marks exactly 35, passing 35"Pass"
Marks 34, passing 35"Fail"
Marks 0, present"Fail"
Marks null, IsAbsent falsethrows
Class average, 5 absent of 40Average of the 35 who appeared
Class where all 40 absentNo exception; average 0, absent count 40
Percentage 85 of 150 max56.67, not 56
CSV row with 3 fieldsSkipped, error names the row
CSV with a duplicate roll numberSkipped, error names the row

Verify each test fails without its fix. Comment out the implementation, run it, watch it fail, restore it.

Submission template

Repository or folder:

Structure
Program.cs contains no business logic: yes / no
Services separated from models and data: yes / no
Repository behind an interface: yes / no

Features
1. Add student, with duplicate rejection:
2. List by class and section:
3. Record marks, absent supported:
4. Result card for one student:
5. Class report:
6. CSV import with per-row errors:
7. JSON save and load:
8. Menu loop with TryParse:

Correctness evidence
Absent student output:
Class average with 5 of 40 absent (paste the number):
Same average if absentees were scored 0 (paste it):
Difference, and which is correct:
All-absent class output:
Percentage of 85 out of 150 (paste it):

Error handling
Letters typed where a number belongs:
CSV row with a missing field:
CSV row with a duplicate roll number:
JSON file missing on load:

Tests
Test names and what each asserts:
Confirmation each fails without its fix:

Explanation
One line you found hardest, and why:

Verification

Every feature works from the menu, including the failure paths — a wrong menu choice, letters where a number belongs, a missing file.

The absent case is correct end to end. A student marked absent shows Absent on the result card, is counted in AbsentCount, and is excluded from AverageMarks.

The two averages differ. Compute the class average excluding absentees and then treating them as zero. For 5 absent out of 40 the second is roughly 12% lower. Paste both numbers. This is the single most important piece of evidence in the submission.

An all-absent class does not throw.

Percentage uses decimal arithmetic. 85 out of 150 is 56.67, not 56.

A duplicate roll number is rejected at both the add-student path and the CSV import.

A malformed CSV row is skipped with its row number named, and the remaining rows still import.

Enums save as strings. Open the JSON and confirm "Status": "Active", not "Status": 0.

No var, no braceless if, no expression-bodied methods in your code — the style this track has used throughout.

Every test fails when its fix is removed.

AI practice

Three AI exercises from this track's syllabus. Do each after the project works, and apply Track 18's discipline — every answer is a hypothesis until you have run it.

  1. Request an exercise without the solution. Ask for three C# collection problems using the School entities, with no code and no answers. Solve them yourself, then ask for a critique of your solution rather than a rewrite.
  2. Ask AI to explain a method line by line. Paste your BuildClassSummary and ask what each line does and what happens when appeared is empty. Check every claim against the code in front of you.
  3. Compare your solution with an alternative. Ask for a different implementation of GetResult and ask for the trade-off, not a recommendation. Then check its condition order — if the absent check is not first, you have just found the exact bug this track spent ten articles on.

Exercise 3 is the calibration one. Generated grading logic gets the absent case wrong often, and noticing that is the skill.

Track 18 — Reviewing AI-generated code — has the full checklist.

Self-assessment

Your project is complete when it runs, handles every failure case, and you can explain any line without notes.

Five specific tests:

  • Can you explain why the absent check comes first? If the answer is "because the guide said so", reverse the order, run the report, and read what it says about a student who was ill.
  • Do you know why money and percentages are decimal? Compute a class total as double and as decimal and compare.
  • Can you name the three places a null could reach your code? A repository lookup, a FirstOrDefault, and a dictionary read. Each one needs a check.
  • Would your tests catch a regression? Comment out the absent check and confirm exactly one test fails. If none do, the tests were written from the code.
  • Is Program.cs free of logic? If a calculation lives there, it is untestable, and moving it is the last refactor of this track.

Track completion criteria

You can write, debug and explain structured C# programs using core language features and practical object-oriented design.

Specifically, you can:

  • Create, build and run a .NET console application, and explain the SDK, compiler and runtime
  • Tell a compile-time error from a run-time one from a logical one
  • Choose the right type, and explain why money is decimal and an absent mark is null
  • Convert user input safely with TryParse
  • Write conditions in an order that is correct, not merely plausible
  • Choose the right loop, and avoid modifying a collection while iterating it
  • Write methods that do one thing
  • Model School entities as classes and make invalid states unreachable
  • Use inheritance, interfaces and composition where each is appropriate
  • Pick the right collection from the shape of the problem
  • Write and consume generic types
  • Pass behaviour into a method with a delegate or lambda
  • Filter, project, group and aggregate with LINQ, excluding absentees correctly
  • Choose between First, FirstOrDefault and Single
  • Catch exceptions specifically and keep failures visible
  • Read and write files, CSV and JSON, reporting errors per row
  • Write async methods, and recognise a missing await, a blocking .Result and an async void
  • Debug with conditional breakpoints, Watch and the Call Stack
  • Write a unit test from the requirement and verify it fails without its fix

The syllabus recommends Track 06 — SQL Server & Database Development or Track 10 — ASP.NET Core Development next. Track 04 — VB.NET and Track 05 — Web Forms are optional legacy-maintenance tracks you can take at any point.