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:


Example 1: Complete Code

Create a file main.go and copy the following code in full:

GO
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()
}
▶ Try it Yourself

Code Walkthrough

1. Data Structure Design

GO
type Student struct {
    ID      string   `json:"id"`
    Name    string   `json:"name"`
    Courses []Course `json:"courses"`
}

2. Methods and Pointer Receivers

GO
func (s *Student) AddCourse(name string, score float64) { ... }
func (s Student) Average() float64 { ... }

3. Interface Abstraction — Storer

GO
type Storer interface {
    Save(students map[string]*Student) error
    Load() (map[string]*Student, error)
}

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
GO
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

GO
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():

GO
// 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

BASH
# 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:

GO
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:

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:

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.


Next Lesson: Goroutine and Concurrency
Web-Tutorial.com

Web-Tutorial Tech Team

A team of developers maintaining programming tutorials. Each tutorial is written and reviewed by developers with expertise in that field. We work to keep our content accurate and reliable — if you spot an issue, please let us know.

100%

🙏 帮我们做得更好

我们是刚上线的编程教程站,几个人的小团队,精力有限。页面虽经检查,难免还有疏漏——链接失效、排版错乱、内容有误、语言生硬……

如果您发现了,麻烦告诉我们,我们会在收到反馈后第一时间进行修复,再次感谢您的光临 🙏