Overview
A student management system is the classic first project for seeing why C# bundles data and behavior together in a `class` instead of scattering related values across separate arrays or variables. A `Student` is not just an id, a name, a course, and a GPA sitting next to each other in memory — it is one coherent object that should be created, read, and changed as a single unit. That is exactly what a C# class gives you, and C# adds a language feature Java does not have built in: properties. Instead of writing a `getName()` method and a `setName()` method by hand, a C# property like `public string Name { get; set; }` gives you the same controlled access with far less boilerplate.
By the end of this tutorial you will have a console application built around two classes: `Student`, which models one record using auto-implemented properties, and `StudentManager`, which owns a `List<Student>` and provides add, search, update, and delete operations over it. You will also see modern C#'s top-level statements in action — `Program.cs` does not need an explicit `class Program { static void Main(string[] args) { ... } }` wrapper the way older C# and Java both require; the compiler generates that entry point for you behind the scenes, so the file can start directly with executable code.
- A `Student` class with an auto-implemented `Id` property (settable only from inside the class) plus `Name`, `Course`, and `Gpa` properties.
- A `StudentManager` class that owns a `List<Student>` and assigns unique ids automatically.
- A linear `FindById()` search reused by the update and delete operations.
- An `UpdateStudent()` method that safely edits an existing record found by id.
- A `DeleteStudent()` method built on `List<T>.RemoveAll()`.
- A `Console.ReadLine()`-based menu loop tying every operation together into one running program.
Prerequisites
- Classes and objects — defining a `class` with properties, a constructor, and methods.
- C# properties — the `{ get; set; }` syntax, and how `{ get; private set; }` restricts who can assign a value.
- Collections basics — `System.Collections.Generic.List<T>` and the `foreach` loop.
- Console I/O — reading input with `Console.ReadLine()` and parsing it with `int.Parse()`/`double.Parse()`.
- Method basics — parameters, return types, and calling one method from another.
Project Structure
The whole program lives in a single file, `Program.cs`, created with `dotnet new console`. Unlike Java, C# does not require one `public` class per file, or that a file's name match any class inside it — so `Student`, `StudentManager`, and the top-level statements that drive the menu can all live together in `Program.cs` with no naming constraints at all. `Student` is a pure data-and-behavior class — it never touches the console or the collection it lives in. `StudentManager` is the layer above it: it owns the `List<Student>`, hands out ids, and exposes one method per menu action (`AddStudent`, `FindById`, `UpdateStudent`, `DeleteStudent`, `GetAll`). The top-level statements at the bottom never manipulate the list directly; they only ever call methods on a single shared `StudentManager` instance, which keeps every rule about how students are created or removed enforced in exactly one place.
In a C# file that uses top-level statements, the executable statements must come first and any `class`/`record` type declarations must come after them — that is why the complete program in this tutorial places the menu loop logic at the top of `Program.cs` and the `Student`/`StudentManager` class definitions below it, the reverse order of how the steps below introduce them.
Step 1: Define the Student Class
Every property here is deliberately shaped around what should and should not be changeable from outside the class. `Id` uses `{ get; private set; }` — readable by anyone, but assignable only from code inside `Student` itself, which is what stops a caller from ever reassigning a student's id after it is created. `Name`, `Course`, and `Gpa` use the full `{ get; set; }`, since those are exactly the fields `StudentManager`'s update operation is allowed to change later.
// Represents one student record. Auto-implemented properties give each field a// compiler-generated backing variable — "private set" on Id means code outside// this class can read Id but can never assign it a new value directly.class Student{ public int Id { get; private set; } // Assigned once by StudentManager; never chosen or changed by the caller public string Name { get; set; } // Full get/set: this is exactly what StudentManager is allowed to edit public string Course { get; set; } public double Gpa { get; set; } // double so partial grades like 8.75 are representable
public Student(int id, string name, string course, double gpa) { Id = id; // A constructor can assign a private-set property from inside its own class Name = name; Course = course; Gpa = gpa; }
public override string ToString() // "override" is a keyword here, not an annotation like Java's @Override { // $"..." is string interpolation; "{Name,-20}" left-aligns Name in a 20-character field, similar to // C's/Java's printf-style "%-20s" but built directly into the language's string syntax. return $"ID: {Id,-4} | Name: {Name,-20} | Course: {Course,-15} | GPA: {Gpa:F2}"; }}Step 2: Build the StudentManager and Add Students
`StudentManager` is where the collection actually lives. `List<T>` is .NET's growable array type — the direct equivalent of Java's `ArrayList<T>` — and it is common in C# to declare a field as `List<Student>` directly rather than through a separate interface type, since `List<T>` already implements `IList<T>`, `ICollection<T>`, and `IEnumerable<T>`. `_nextId` is a simple auto-incrementing counter (the leading underscore is a common C# naming convention for `private` fields), so `StudentManager`, not the caller, decides every student's id, guaranteeing no two records can ever collide.
using System.Collections.Generic; // List<T> — shown explicitly here even though modern project templates often bring it in via global usings
// Owns every Student record in memory and is the only class allowed to// create, search, update, or remove them — the menu loop never touches the// List<Student> directly, it only ever calls methods on a StudentManager.class StudentManager{ private List<Student> _students = new List<Student>(); // Grows automatically as students are added; no manual resizing private int _nextId = 1; // Next id to hand out; incremented after every successful add
public Student AddStudent(string name, string course, double gpa) { var student = new Student(_nextId, name, course, gpa); // The manager assigns the id, not the caller _students.Add(student); // List<T>.Add() appends in amortized O(1) time _nextId++; // Guarantees every future student gets a fresh id return student; // Handy for immediately printing "Added: ..." }}Step 3: List and Search Students
`GetAll()` exposes the collection for listing, returned as `IReadOnlyList<Student>` rather than `List<Student>` — a small but meaningful signal to callers that they may read this collection but should not add to or remove from it directly. `FindById()` is a plain linear scan using a `foreach` loop — with a class list realistically holding dozens to a few hundred students, a linear search is more than fast enough. The return type `Student?` uses C#'s nullable reference type annotation to make it explicit in the method signature itself that "no match found" is a real, expected outcome the caller must handle.
public IReadOnlyList<Student> GetAll(){ return _students; // Safe to expose here since StudentManager is the only class that ever mutates the list}
public Student? FindById(int id) // The ? marks this as possibly returning null — every caller must check for that{ foreach (var s in _students) // foreach walks every student in insertion order, same idea as Java's enhanced for { if (s.Id == id) { return s; // Found a match: return the actual object, not a defensive copy } } return null; // No match; every caller below must check for null before using the result}Click Run to see what this code prints.
Step 4: Update a Student Record
`UpdateStudent()` reuses `FindById()` from Step 3 rather than scanning the list a second time. If no student with that id exists, it returns `false` immediately and never touches any property — callers use that return value to decide whether to print "updated" or "not found," so `StudentManager` never needs to print anything itself.
public bool UpdateStudent(int id, string newName, string newCourse, double newGpa){ var student = FindById(id); // Reuse the lookup already written in Step 3 instead of duplicating it if (student is null) { return false; // Nothing to update; let the caller report "student not found" } student.Name = newName; // Only StudentManager and code holding a direct Student reference can reach these student.Course = newCourse; // properties, which is exactly the encapsulation boundary set up in Step 1 student.Gpa = newGpa; return true;}Step 5: Delete a Student
`DeleteStudent()` uses `List<T>.RemoveAll()`, which takes a predicate delegate and removes every element that matches it, returning an `int` count of how many elements were actually removed — unlike Java's `ArrayList.removeIf()`, which returns a `bool`. Comparing that count against `0` gives the same true/false signal Java's version returns directly. Since ids are unique, at most one student is ever removed here, but `RemoveAll()` is still the cleanest way to express "delete the student whose id equals this one" without manually managing a loop index while removing from the list being iterated.
public bool DeleteStudent(int id){ return _students.RemoveAll(s => s.Id == id) > 0; // Lambda predicate; RemoveAll returns a count, so compare against 0}Step 6: Build the Menu Loop
The top-level statements below create one `StudentManager` shared by every menu action and loop on a numbered menu until the user chooses to exit. Notice there is no equivalent of Java's `scanner.nextLine()` cleanup call here: `Console.ReadLine()` always reads and consumes one full line of input, including its trailing newline, so there is no leftover character sitting in a buffer waiting to be accidentally read by the next call — an entire category of bug Java's `Scanner` requires you to defend against by hand.
var manager = new StudentManager(); // One manager instance shared by every menu action belowint choice;
do{ Console.WriteLine("\n===== STUDENT MANAGEMENT SYSTEM ====="); Console.WriteLine("1. Add Student"); Console.WriteLine("2. List All Students"); Console.WriteLine("3. Search Student by ID"); Console.WriteLine("4. Update Student"); Console.WriteLine("5. Delete Student"); Console.WriteLine("6. Exit"); Console.Write("Enter your choice: "); choice = int.Parse(Console.ReadLine()!); // ! tells the compiler "trust me, this won't be null" for a console read
if (choice == 1) { Console.Write("Enter name: "); string name = Console.ReadLine()!; Console.Write("Enter course: "); string course = Console.ReadLine()!; Console.Write("Enter GPA: "); double gpa = double.Parse(Console.ReadLine()!); var added = manager.AddStudent(name, course, gpa); Console.WriteLine($"Added! Assigned ID: {added.Id}"); } else if (choice == 2) { Console.WriteLine("\n--- All Students ---"); foreach (var s in manager.GetAll()) // Reuses Student's ToString() from Step 1 for each line { Console.WriteLine(s); } } else if (choice == 3) { Console.Write("Enter ID to search: "); int id = int.Parse(Console.ReadLine()!); var found = manager.FindById(id); Console.WriteLine(found is null ? "No student with that ID." : found.ToString()); } else if (choice == 4) { Console.Write("Enter ID to update: "); int id = int.Parse(Console.ReadLine()!); if (manager.FindById(id) is null) { Console.WriteLine("No student with that ID."); } else { Console.Write("Enter new name: "); string newName = Console.ReadLine()!; Console.Write("Enter new course: "); string newCourse = Console.ReadLine()!; Console.Write("Enter new GPA: "); double newGpa = double.Parse(Console.ReadLine()!); manager.UpdateStudent(id, newName, newCourse, newGpa); Console.WriteLine("Student updated."); } } else if (choice == 5) { Console.Write("Enter ID to delete: "); int id = int.Parse(Console.ReadLine()!); bool removed = manager.DeleteStudent(id); Console.WriteLine(removed ? "Student deleted." : "No student with that ID."); } else if (choice == 6) { Console.WriteLine("Goodbye!"); } else { Console.WriteLine("Invalid choice, try again."); // Catches anything outside 1-6 }} while (choice != 6); // Keep looping until the user explicitly picks Exit
// --- Type declarations below the top-level statements above ---
class Student{ public int Id { get; private set; } public string Name { get; set; } public string Course { get; set; } public double Gpa { get; set; }
public Student(int id, string name, string course, double gpa) { Id = id; Name = name; Course = course; Gpa = gpa; }
public override string ToString() { return $"ID: {Id,-4} | Name: {Name,-20} | Course: {Course,-15} | GPA: {Gpa:F2}"; }}Complete Code
Here is the full program with the menu logic and both classes assembled in the correct order — top-level statements first, then type declarations — ready to save as `Program.cs` inside a project created with `dotnet new console` and run with `dotnet run`.
using System.Collections.Generic;
var manager = new StudentManager();int choice;
do{ Console.WriteLine("\n===== STUDENT MANAGEMENT SYSTEM ====="); Console.WriteLine("1. Add Student"); Console.WriteLine("2. List All Students"); Console.WriteLine("3. Search Student by ID"); Console.WriteLine("4. Update Student"); Console.WriteLine("5. Delete Student"); Console.WriteLine("6. Exit"); Console.Write("Enter your choice: "); choice = int.Parse(Console.ReadLine()!);
if (choice == 1) { Console.Write("Enter name: "); string name = Console.ReadLine()!; Console.Write("Enter course: "); string course = Console.ReadLine()!; Console.Write("Enter GPA: "); double gpa = double.Parse(Console.ReadLine()!); var added = manager.AddStudent(name, course, gpa); Console.WriteLine($"Added! Assigned ID: {added.Id}"); } else if (choice == 2) { Console.WriteLine("\n--- All Students ---"); foreach (var s in manager.GetAll()) { Console.WriteLine(s); } } else if (choice == 3) { Console.Write("Enter ID to search: "); int id = int.Parse(Console.ReadLine()!); var found = manager.FindById(id); Console.WriteLine(found is null ? "No student with that ID." : found.ToString()); } else if (choice == 4) { Console.Write("Enter ID to update: "); int id = int.Parse(Console.ReadLine()!); if (manager.FindById(id) is null) { Console.WriteLine("No student with that ID."); } else { Console.Write("Enter new name: "); string newName = Console.ReadLine()!; Console.Write("Enter new course: "); string newCourse = Console.ReadLine()!; Console.Write("Enter new GPA: "); double newGpa = double.Parse(Console.ReadLine()!); manager.UpdateStudent(id, newName, newCourse, newGpa); Console.WriteLine("Student updated."); } } else if (choice == 5) { Console.Write("Enter ID to delete: "); int id = int.Parse(Console.ReadLine()!); bool removed = manager.DeleteStudent(id); Console.WriteLine(removed ? "Student deleted." : "No student with that ID."); } else if (choice == 6) { Console.WriteLine("Goodbye!"); } else { Console.WriteLine("Invalid choice, try again."); }} while (choice != 6);
class Student{ public int Id { get; private set; } public string Name { get; set; } public string Course { get; set; } public double Gpa { get; set; }
public Student(int id, string name, string course, double gpa) { Id = id; Name = name; Course = course; Gpa = gpa; }
public override string ToString() { return $"ID: {Id,-4} | Name: {Name,-20} | Course: {Course,-15} | GPA: {Gpa:F2}"; }}
class StudentManager{ private List<Student> _students = new List<Student>(); private int _nextId = 1;
public Student AddStudent(string name, string course, double gpa) { var student = new Student(_nextId, name, course, gpa); _students.Add(student); _nextId++; return student; }
public IReadOnlyList<Student> GetAll() { return _students; }
public Student? FindById(int id) { foreach (var s in _students) { if (s.Id == id) { return s; } } return null; }
public bool UpdateStudent(int id, string newName, string newCourse, double newGpa) { var student = FindById(id); if (student is null) { return false; } student.Name = newName; student.Course = newCourse; student.Gpa = newGpa; return true; }
public bool DeleteStudent(int id) { return _students.RemoveAll(s => s.Id == id) > 0; }}Sample Run
Click Run to see what this code prints.
Extend This Project
- Add a `FindByName(string query)` method using LINQ's `_students.Where(s => s.Name.Contains(query, StringComparison.OrdinalIgnoreCase))`.
- Switch `StudentManager`'s backing collection from `List<Student>` to `Dictionary<int, Student>` keyed by id, turning `FindById()` from an O(n) scan into an O(1) lookup.
- Persist students to a JSON file with `System.Text.Json.JsonSerializer` so records survive between program runs.
- Replace `int.Parse`/`double.Parse` in the menu loop with `int.TryParse`/`double.TryParse` so bad input shows a friendly message instead of crashing the program.
- Add a `SortByGpa()` method using `_students.Sort((a, b) => b.Gpa.CompareTo(a.Gpa))` to list top performers first.
Summary
You built a working student management system where `Student` exposes its data through properties instead of Java-style getter/setter methods, and `StudentManager` composes many students into one manageable, searchable collection backed by `List<T>`. The add/find/update/delete shape you wrote here — one class owning a collection and exposing a small, well-named method per operation — is the same shape you will reuse in almost every data-driven C# console application from here on.