Practice: Student Management System
Practice: Student Management System
Imagine you're a class teacher with a notebook at hand: each page records a student's name, student ID, and grades for each subject. When a new student transfers in, you turn to a blank page to register them; after exams, you find the corresponding page to record grades; at the end of the semester, you print report cards ranked by total score. The entire process is performing "CRUD" operations.
Today we're moving that notebook into the computer — building a command-line student management system from scratch in Go, connecting everything we've learned about structs, methods, interfaces, and error handling.
Project Requirements
| Feature | Description |
|---|---|
| Add student | Enter name and student ID to create a new record |
| Add course grade | Record a grade for a specific course for a student |
| Query student | Find by student ID, display student info and all grades |
| List all students | Show all recorded students with their average scores |
| Delete student | Remove a student by student ID |
| Exit program | Save data and exit |
System Design
+---------------------------------------------+
| CLI Menu |
+---------------------------------------------+
| StudentManager |
| (Holds Storer interface, executes logic) |
+---------------------------------------------+
| Storer Interface |
| Save / Load -- replaceable storage backend |
+-------------------+-------------------------+
| MemoryStore | FileStore (optional) |
| (in-memory map) | (JSON file) |
+-------------------+-------------------------+
Core types:
- Student — Student info + course grades
- Course — Single course grade
- Storer — Storage abstraction interface
- StudentManager — Business logic layer
Example 1: Complete Code
Create a file main.go and copy the following code in full:
package main
import (
"bufio"
"encoding/json"
"fmt"
"os"
"sort"
"strconv"
"strings"
)
// ============================================================
// Data Structure Definitions
// ============================================================
// Course represents a course grade
type Course struct {
Name string `json:"name"`
Score float64 `json:"score"`
}
// Student represents a student
type Student struct {
ID string `json:"id"`
Name string `json:"name"`
Courses []Course `json:"courses"`
}
// Average calculates the student's average score
func (s Student) Average() float64 {
if len(s.Courses) == 0 {
return 0
}
total := 0.0
for _, c := range s.Courses {
total += c.Score
}
return total / float64(len(s.Courses))
}
// AddCourse adds a course grade to the student
func (s *Student) AddCourse(name string, score float64) {
// If the course already exists, update the score
for i, c := range s.Courses {
if c.Name == name {
s.Courses[i].Score = score
return
}
}
s.Courses = append(s.Courses, Course{Name: name, Score: score})
}
// String implements the fmt.Stringer interface for easy printing
func (s Student) String() string {
return fmt.Sprintf("[%s] %s (Average: %.1f)", s.ID, s.Name, s.Average())
}
// ============================================================
// Storage Interface
// ============================================================
// Storer defines the abstract storage interface
type Storer interface {
Save(students map[string]*Student) error
Load() (map[string]*Student, error)
}
// ------------------------------------------------------------
// Memory Storage Implementation
// ------------------------------------------------------------
// MemoryStore stores data in memory (lost when program exits)
type MemoryStore struct{}
func NewMemoryStore() *MemoryStore {
return &MemoryStore{}
}
func (m *MemoryStore) Save(students map[string]*Student) error {
// Memory storage doesn't need persistence, just return nil
return nil
}
func (m *MemoryStore) Load() (map[string]*Student, error) {
return make(map[string]*Student), nil
}
// ------------------------------------------------------------
// File Storage Implementation
// ------------------------------------------------------------
// FileStore saves data to a file in JSON format
type FileStore struct {
Path string
}
func NewFileStore(path string) *FileStore {
return &FileStore{Path: path}
}
func (f *FileStore) Save(students map[string]*Student) error {
data, err := json.MarshalIndent(students, "", " ")
if err != nil {
return fmt.Errorf("serialization failed: %w", err)
}
err = os.WriteFile(f.Path, data, 0644)
if err != nil {
return fmt.Errorf("writing file failed: %w", err)
}
return nil
}
func (f *FileStore) Load() (map[string]*Student, error) {
students := make(map[string]*Student)
data, err := os.ReadFile(f.Path)
if err != nil {
if os.IsNotExist(err) {
// File doesn't exist, return empty map
return students, nil
}
return nil, fmt.Errorf("reading file failed: %w", err)
}
err = json.Unmarshal(data, &students)
if err != nil {
return nil, fmt.Errorf("parsing data failed: %w", err)
}
return students, nil
}
// ============================================================
// Business Logic Layer
// ============================================================
// StudentManager manages all student data
type StudentManager struct {
students map[string]*Student
store Storer
reader *bufio.Reader
}
// NewStudentManager creates a new manager
func NewStudentManager(store Storer) (*StudentManager, error) {
students, err := store.Load()
if err != nil {
return nil, fmt.Errorf("loading data failed: %w", err)
}
return &StudentManager{
students: students,
store: store,
reader: bufio.NewReader(os.Stdin),
}, nil
}
// AddStudent adds a new student
func (m *StudentManager) AddStudent(id, name string) error {
if id == "" || name == "" {
return fmt.Errorf("student ID and name cannot be empty")
}
if _, exists := m.students[id]; exists {
return fmt.Errorf("student ID %s already exists", id)
}
m.students[id] = &Student{ID: id, Name: name}
return m.store.Save(m.students)
}
// AddCourse adds a course grade to a student
func (m *StudentManager) AddCourse(id, courseName string, score float64) error {
student, err := m.findStudent(id)
if err != nil {
return err
}
if score < 0 || score > 100 {
return fmt.Errorf("grade must be between 0 and 100")
}
student.AddCourse(courseName, score)
return m.store.Save(m.students)
}
// GetStudent queries student info
func (m *StudentManager) GetStudent(id string) (*Student, error) {
return m.findStudent(id)
}
// DeleteStudent deletes a student
func (m *StudentManager) DeleteStudent(id string) error {
if _, exists := m.students[id]; !exists {
return fmt.Errorf("student ID %s does not exist", id)
}
delete(m.students, id)
return m.store.Save(m.students)
}
// ListStudents returns all students sorted by student ID
func (m *StudentManager) ListStudents() []*Student {
list := make([]*Student, 0, len(m.students))
for _, s := range m.students {
list = append(list, s)
}
sort.Slice(list, func(i, j int) bool {
return list[i].ID < list[j].ID
})
return list
}
// findStudent finds a student by ID (internal helper)
func (m *StudentManager) findStudent(id string) (*Student, error) {
s, exists := m.students[id]
if !exists {
return nil, fmt.Errorf("student ID %s does not exist", id)
}
return s, nil
}
// ============================================================
// CLI Interaction Layer
// ============================================================
// Run starts the command-line interaction loop
func (m *StudentManager) Run() {
for {
m.printMenu()
choice := m.readLine("Enter option: ")
switch choice {
case "1":
m.handleAddStudent()
case "2":
m.handleAddCourse()
case "3":
m.handleGetStudent()
case "4":
m.handleListStudents()
case "5":
m.handleDeleteStudent()
case "6":
fmt.Println("\nData saved. Goodbye!")
return
default:
fmt.Println("\nInvalid option, please try again")
}
fmt.Println()
}
}
func (m *StudentManager) printMenu() {
fmt.Println("+------------------------------+")
fmt.Println("| Student Manager v1.0 |")
fmt.Println("+------------------------------+")
fmt.Println("| 1. Add Student |")
fmt.Println("| 2. Add Course Grade |")
fmt.Println("| 3. Query Student |")
fmt.Println("| 4. List All Students |")
fmt.Println("| 5. Delete Student |")
fmt.Println("| 6. Exit |")
fmt.Println("+------------------------------+")
}
func (m *StudentManager) handleAddStudent() {
id := m.readLine("Enter student ID: ")
name := m.readLine("Enter student name: ")
err := m.AddStudent(id, name)
if err != nil {
fmt.Printf("\nAdd failed: %v\n", err)
return
}
fmt.Printf("\nStudent %s added successfully!\n", name)
}
func (m *StudentManager) handleAddCourse() {
id := m.readLine("Enter student ID: ")
courseName := m.readLine("Enter course name: ")
scoreStr := m.readLine("Enter grade (0-100): ")
score, err := strconv.ParseFloat(scoreStr, 64)
if err != nil {
fmt.Printf("\nInvalid grade format: %v\n", err)
return
}
err = m.AddCourse(id, courseName, score)
if err != nil {
fmt.Printf("\nAdd failed: %v\n", err)
return
}
fmt.Printf("\nGrade added successfully!\n")
}
func (m *StudentManager) handleGetStudent() {
id := m.readLine("Enter student ID: ")
s, err := m.GetStudent(id)
if err != nil {
fmt.Printf("\nQuery failed: %v\n", err)
return
}
fmt.Println("\n--- Student Info ---")
fmt.Printf("ID: %s\n", s.ID)
fmt.Printf("Name: %s\n", s.Name)
if len(s.Courses) == 0 {
fmt.Println("No course grades yet")
} else {
fmt.Println("Course grades:")
for _, c := range s.Courses {
fmt.Printf(" %s: %.1f\n", c.Name, c.Score)
}
fmt.Printf("Average: %.1f\n", s.Average())
}
}
func (m *StudentManager) handleListStudents() {
list := m.ListStudents()
if len(list) == 0 {
fmt.Println("\nNo student records")
return
}
fmt.Println("\n--- All Students ---")
for _, s := range list {
courseCount := len(s.Courses)
fmt.Printf(" %s Courses: %d Average: %.1f\n", s, courseCount, s.Average())
}
fmt.Printf("\nTotal: %d students\n", len(list))
}
func (m *StudentManager) handleDeleteStudent() {
id := m.readLine("Enter student ID to delete: ")
err := m.DeleteStudent(id)
if err != nil {
fmt.Printf("\nDelete failed: %v\n", err)
return
}
fmt.Printf("\nStudent ID %s deleted\n", id)
}
// readLine reads a line of user input and trims whitespace
func (m *StudentManager) readLine(prompt string) string {
fmt.Print(prompt)
line, _ := m.reader.ReadString('\n')
return strings.TrimSpace(line)
}
// ============================================================
// Program Entry
// ============================================================
func main() {
// Use file storage (data saved to students.json)
store := NewFileStore("students.json")
// To use in-memory storage (data lost on exit), change to:
// store := NewMemoryStore()
mgr, err := NewStudentManager(store)
if err != nil {
fmt.Fprintf(os.Stderr, "Initialization failed: %v\n", err)
os.Exit(1)
}
fmt.Println("Welcome to the Student Management System!")
mgr.Run()
}
Code Walkthrough
1. Data Structure Design
type Student struct {
ID string `json:"id"`
Name string `json:"name"`
Courses []Course `json:"courses"`
}
Studentholds a slice ofCourses, each containing a course name and grade.jsontags allow the struct to be directly serialized to JSON for file storage.
2. Methods and Pointer Receivers
func (s *Student) AddCourse(name string, score float64) { ... }
func (s Student) Average() float64 { ... }
AddCoursemodifies the slice, so it uses a pointer receiver*Student.Averageonly reads data, so a value receiverStudentis sufficient.AddCourseimplements "update if course name exists" logic to avoid duplicates.
3. Interface Abstraction — Storer
type Storer interface {
Save(students map[string]*Student) error
Load() (map[string]*Student, error)
}
StudentManagerdepends only on theStorerinterface, not the specific storage method.- Two implementations are provided:
MemoryStore(in-memory) andFileStore(JSON file). - Want to switch to a database? Just add a new type implementing
Storer— zero changes to business code.
4. Error Handling Strategy
This project handles errors at every layer:
| Layer | Strategy |
|---|---|
| Data validation | Check student ID/name not empty, grade range 0-100 |
| Business logic | Check if student ID exists, error if duplicate |
| Storage layer | Wrap underlying errors with fmt.Errorf("...: %w", err) |
| CLI layer | Catch errors and display friendly messages to users |
return fmt.Errorf("serialization failed: %w", err)
%w wrapping preserves the original error info, allowing callers to use errors.Is or errors.As to check error types.
5. CLI Menu Loop
for {
m.printMenu()
choice := m.readLine("Enter option: ")
switch choice { ... }
}
The main loop continuously prints the menu, reads input, and dispatches to the corresponding handler. This "read-dispatch" pattern is the classic structure for command-line programs.
6. Extensible Storage Layer
Switching storage methods only requires changing one line in main():
// In-memory mode
store := NewMemoryStore()
// File mode
store := NewFileStore("students.json")
StudentManager is completely unaware of the underlying change — this is the value of interfaces.
Running and Testing
# Compile and run
go run main.go
Welcome to the Student Management System!
+------------------------------+
| Student Manager v1.0 |
+------------------------------+
| 1. Add Student |
| 2. Add Course Grade |
| 3. Query Student |
| 4. List All Students |
| 5. Delete Student |
| 6. Exit |
+------------------------------+
Enter option: 1
Enter student ID: 1001
Enter student name: Alice
Student Alice added successfully!
When done, the program generates a students.json file in the current directory, which is automatically loaded on next startup.
❓ FAQ
Q1: Why does Average() use a value receiver while AddCourse() uses a pointer receiver?
Average() only reads data to calculate a sum, without modifying Student, so a value receiver is safer. AddCourse() needs to append elements to the Courses slice, requiring a pointer receiver to modify the caller's original data. Simple principle: use a pointer to modify, use a value to read.
Q2: What does * mean in map[string]*Student?
This is a map with pointer values. m.students[id] returns *Student (a pointer to Student), not a copy of Student. This way, when we modify student data via AddCourse, changes are directly reflected in the map without extra handling.
Q3: Why does Load() separately handle the case when the file doesn't exist?
On first run, students.json doesn't exist yet, and os.ReadFile will return an error. If we just report the error and exit, the user can never start the program. So we check with os.IsNotExist(err) — a missing file is normal, and we return an empty map.
Q4: How to extend to database storage?
Just implement the Storer interface:
type MySQLStore struct {
db *sql.DB
}
func (m *MySQLStore) Save(students map[string]*Student) error {
// INSERT/UPDATE to database
}
func (m *MySQLStore) Load() (map[string]*Student, error) {
// SELECT from database
}
Then pass &MySQLStore{db: db} in main(), and the business code needs no changes.
📖 Summary
This lesson connects the core knowledge of Phase 2 through a complete project:
- Structs —
StudentandCoursedefine the data model;jsontags support serialization. - Methods — Value receivers for read-only operations; pointer receivers for modifying data.
- Interfaces —
Storerinterface decouples business logic from storage implementation, demonstrating Go's interface-oriented programming. - Error handling — Validation and error wrapping at every layer; the CLI layer displays friendly messages to users.
- Package organization — All code in one package, but with clear separation of concerns through types and methods.
This is the fundamental pattern of Go engineering: model with structs, encapsulate behavior with methods, decouple dependencies with interfaces, and propagate exceptions with errors.
📝 Exercises
Exercise 1: Add Sort by Average Score
Add a new menu option "Rank by Average Score" that sorts all students by average score from highest to lowest and outputs the result. Hint: use sort.Slice.
Exercise 2: Add Import/Export Functionality
Implement two new methods:
ExportCSV(filename string) error— Export all students to a CSV file (student ID, name, course, grade).ImportCSV(filename string) error— Batch import students and grades from a CSV file.
Exercise 3: Add Data Validation Middleware
Create a ValidatingStore struct that wraps any Storer implementation, validating all student data before Save (student ID not empty, name not empty, grade between 0-100). This is the decorator pattern in practice.



