LearnAI ToolsCareerPractice BuildsPlayDevelopmentContact
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.