LearnAI ToolsCareerPractice BuildsPlayContact
C# & .NETBeginner~1.5 hours

Student Management Console App

Build a console app to add, update, search, and delete student records using classes and collections.

ClassesObjectsCollections

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.

What You'll Build
  • 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
}
Example Usage

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 below
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()!); // ! 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`.

Program.cs
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

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.