Exams, Marks Entry, and Report Cards API – Web API | Lesson 82
Introduction
After every exam, each teacher marks a bundle of answer sheets: one paper, one section. The SMS must accept those marks, refuse impossible ones, and turn them into report cards for parents.
This lesson builds those endpoints with Dapper and the response wrapper from Lessons 80 and 81. Much of it reuses ideas from attendance (Lesson 79), and one rule check moves into a shared class for exactly that reason.
By the end of this lesson, you will be able to:
- Model exams, papers, and marks as endpoints that match how a school works.
- Enter a whole marks sheet in one idempotent
PUT, with clear rule errors. - Move a check into a shared class once a second feature needs it.
- Keep report card rules in a service, apart from the data query.
- Turn a database trigger's error into the same error the API already reports.
Why this matters
Marks decide results, promotions, and sometimes admissions. A mark saved for the wrong section, a 105 out of 100, or a duplicated sheet causes real harm and angry parents.
Report cards are also where rules change most often. Pass marks, grade scales, and what "absent" means differ between schools and boards. Rules kept in one service are easy to find and to change.
The concept
Think of the exam hall. The timetable lists papers, and each teacher gets one bundle of answer sheets: one paper, one section. The report card collects one line per paper for one student.
Example: exams, papers, and marks
| Table (Lesson 13) | One row is | SMS example |
|---|---|---|
Exams | an exam in an academic year | Half Yearly, 2026-09-21 to 2026-09-30 |
ExamSubjects (a "paper") | one subject's paper for one class | Class 10 English, 2026-09-21, out of 100, pass at 35 |
Marks | one student's result in one paper | Kabir, English: 36 |
The routes follow the same shape. A paper has its own address, and the section is a filter:
| Route | Does |
|---|---|
GET /api/exams | this year's exams |
GET /api/exams/{examId}/papers?classId=10 | one class's papers for an exam |
GET and PUT /api/exam-papers/{paperId}/marks?sectionId=19 | read or save a section's marks sheet |
GET /api/students/{admissionNumber}/report-cards/{examId} | one report card |
Example: one marks sheet, one request
Like the attendance register, the marks sheet travels as a whole. Each line has a roll number and either marks or "absent". Sending the same sheet again changes nothing; sending it with one corrected line changes one mark.
Example: the marks rules
| Rule | Error | Status |
|---|---|---|
| The paper exists | 50050 | 404 |
| The paper's date has come | 50051 | 422 |
| The section exists | 50031 | 404 |
| The section is in the paper's class | 50052 | 422 |
| Every student once, nobody else | 50043, 50044, 50045 | 422 |
| Each line has marks or absent, not both, not neither | 50053 | 422 |
| Marks are between 0 and the paper's maximum | 50001 | 422 |
The last rule is checked twice. MarksService checks it first and explains it clearly. The database trigger
tr_Marks_ValidateMaxMarks from Lesson 23 checks it again for any program that writes marks.
Example: the report card rules
- Each paper: Pass at or above its pass marks, Fail below, Absent, or Not entered yet.
- Each grade: from the class's grade scale (Lesson 56). Classes 6 to 10 use A1 to E.
- The result: Incomplete while any paper is not entered. Otherwise Pass only if every paper passed.
- The percentage: only when the card is complete. An absent paper counts as 0 out of its maximum.
Apply it to the SMS
Step 1: the requests and results
using System.ComponentModel.DataAnnotations;
namespace SchoolApp.Core.Dtos;
// Lesson 82: exams, papers, marks, and report cards.
public record ExamDto(int Id, string Name, string ExamType, DateOnly StartDate, DateOnly EndDate);
// One paper of an exam for one class (a row of ExamSubjects).
public record PaperDto(int Id, int ExamId, int ClassId, string SubjectCode, string SubjectName,
DateOnly ExamDate, decimal MaxMarks, decimal PassMarks);
// One line of a marks sheet: marks, or absent, never both.
public record MarkEntry(
[Range(1, 999)] short RollNumber,
[Range(0, 999.99)] decimal? MarksObtained,
bool IsAbsent,
[StringLength(200)] string? Remarks);
public record EnterMarksRequest([Required, MinLength(1)] List<MarkEntry> Entries);
public record EnterMarksResult(int ExamSubjectId, int SectionId, int Created, int Updated,
int Absent, int Passed, int Failed, decimal? Highest, decimal? Average);
public record MarksSheetLineDto(short RollNumber, string AdmissionNumber, string StudentName,
decimal? MarksObtained, bool? IsAbsent, string? Remarks);
// The raw data for a report card, as stored. ReportCardService adds grades and results.
public record ReportCardData(string AdmissionNumber, string StudentName, int ClassId, string ClassName, string SectionName,
string ExamName, IReadOnlyList<ReportCardPaper> Papers);
public record ReportCardPaper(string Subject, decimal MaxMarks, decimal PassMarks, bool HasMark,
decimal? MarksObtained, bool IsAbsent);
public record ReportCardLineDto(string Subject, decimal MaxMarks, decimal PassMarks, decimal? MarksObtained,
string Status, string? Grade);
public record ReportCardDto(string AdmissionNumber, string StudentName, string ClassName, string SectionName,
string ExamName, IReadOnlyList<ReportCardLineDto> Lines, decimal TotalObtained,
decimal TotalMax, decimal? Percentage, string? Grade, string Result);
MarkEntry allows MarksObtained to be null, because an absent student has no marks. The rule that a
line has one or the other is checked in the service, where it can name the roll number.
Step 2: a shared check, once it is needed twice
Attendance (Lesson 79) already checked that a register had every student exactly once. Marks need the same check, so it moved into its own class:
using SchoolApp.Core.Exceptions;
namespace SchoolApp.Core.Services;
// Lesson 82: the roll-number check shared by attendance (Lesson 79) and marks. Written once the second
// use appeared: every enrolled roll number exactly once, and nobody else.
public static class RegisterCheck
{
// listName names the list in messages, such as "register" or "marks sheet".
public static void EnsureExact(IEnumerable<short> sent, IReadOnlyCollection<short> enrolled, string listName)
{
List<short> sentList = sent.ToList();
short? repeated = sentList.GroupBy(r => r).Where(g => g.Count() > 1).Select(g => (short?)g.Key).FirstOrDefault();
if (repeated is not null)
{
throw new SchoolRuleException(50045, $"Roll number {repeated} appears more than once.");
}
List<short> unknown = sentList.Where(r => !enrolled.Contains(r)).ToList();
if (unknown.Count > 0)
{
throw new SchoolRuleException(50043, $"No student has roll number {string.Join(", ", unknown)} in this section.");
}
List<short> missing = enrolled.Except(sentList).Order().ToList();
if (missing.Count > 0)
{
throw new SchoolRuleException(50044, $"The {listName} is incomplete: roll number {string.Join(", ", missing)} missing.");
}
}
}
AttendanceService now calls RegisterCheck.EnsureExact(..., "register"), and its tests still pass
unchanged. The listName only changes the message: "The register is incomplete" or "The marks sheet is
incomplete".
Step 3: the marks service
using SchoolApp.Core.Dtos;
using SchoolApp.Core.Entities;
using SchoolApp.Core.Exceptions;
using SchoolApp.Core.Interfaces;
using SchoolApp.Core.Rules;
namespace SchoolApp.Core.Services;
// Lesson 82: entering a whole section's marks for one paper, like attendance (Lesson 79): one request,
// every student once, and sending it again updates instead of duplicating.
// Rules, in order: the paper exists (50050), its date has come (50051), the section exists (50031) and is
// in the paper's class (50052), the sheet is exact (50043 to 50045), each line is marks or absent (50053),
// and marks are within the maximum (50001).
public class MarksService
{
private readonly IMasterDataRepository _masterData;
private readonly IMarksRepository _marks;
private readonly TimeProvider _time;
public MarksService(IMasterDataRepository masterData, IMarksRepository marks, TimeProvider time)
{
_masterData = masterData;
_marks = marks;
_time = time;
}
public async Task<EnterMarksResult> EnterAsync(int examSubjectId, int sectionId, EnterMarksRequest request,
CancellationToken cancellationToken = default)
{
PaperDto paper = await _marks.GetPaperAsync(examSubjectId, cancellationToken)
?? throw new SchoolRuleException(50050, $"No exam paper has id {examSubjectId}.");
DateOnly today = DateOnly.FromDateTime(_time.GetLocalNow().DateTime);
if (today < paper.ExamDate)
{
throw new SchoolRuleException(50051, $"The {paper.SubjectName} paper is on {paper.ExamDate:yyyy-MM-dd}; marks cannot be entered before it.");
}
int? sectionClassId = await _marks.GetSectionClassIdAsync(sectionId, cancellationToken);
if (sectionClassId is null)
{
throw new SchoolRuleException(50031, $"Section {sectionId} does not exist.");
}
if (sectionClassId != paper.ClassId)
{
throw new SchoolRuleException(50052, $"This paper is for Class {paper.ClassId}, not for section {sectionId}.");
}
AcademicYearDto year = await _masterData.GetCurrentAcademicYearAsync(cancellationToken)
?? throw new SchoolRuleException(50035, "No academic year is marked as current.");
List<RegisterStudent> register = await _marks.GetRegisterStudentsAsync(sectionId, year.Id, cancellationToken);
RegisterCheck.EnsureExact(request.Entries.Select(e => e.RollNumber), register.Select(s => s.RollNumber).ToList(), "marks sheet");
// Each line: either absent with no marks, or present with marks between 0 and the maximum.
foreach (MarkEntry entry in request.Entries)
{
if (entry.IsAbsent == entry.MarksObtained.HasValue)
{
throw new SchoolRuleException(50053, $"Roll number {entry.RollNumber}: give marks, or mark the student absent, not both.");
}
if (entry.MarksObtained is decimal marks)
{
SchoolRules.ValidateMark(marks, paper.MaxMarks); // InvalidMarkException, error 50001
}
}
Dictionary<short, int> enrollmentByRoll = register.ToDictionary(s => s.RollNumber, s => s.EnrollmentId);
Dictionary<int, Mark> existing = (await _marks.GetMarksAsync(examSubjectId, sectionId, cancellationToken))
.ToDictionary(m => m.EnrollmentId);
List<Mark> created = new List<Mark>();
List<Mark> changed = new List<Mark>();
foreach (MarkEntry entry in request.Entries)
{
int enrollmentId = enrollmentByRoll[entry.RollNumber];
if (existing.TryGetValue(enrollmentId, out Mark? mark))
{
if (mark.MarksObtained != entry.MarksObtained || mark.IsAbsent != entry.IsAbsent || mark.Remarks != entry.Remarks)
{
mark.MarksObtained = entry.MarksObtained;
mark.IsAbsent = entry.IsAbsent;
mark.Remarks = entry.Remarks;
changed.Add(mark);
}
}
else
{
created.Add(new Mark
{
ExamSubjectId = examSubjectId,
EnrollmentId = enrollmentId,
MarksObtained = entry.MarksObtained,
IsAbsent = entry.IsAbsent,
Remarks = entry.Remarks
});
}
}
await _marks.SaveAsync(created, changed, cancellationToken);
List<decimal> scores = request.Entries.Where(e => e.MarksObtained.HasValue).Select(e => e.MarksObtained!.Value).ToList();
return new EnterMarksResult(
examSubjectId, sectionId, created.Count, changed.Count,
Absent: request.Entries.Count(e => e.IsAbsent),
Passed: scores.Count(s => s >= paper.PassMarks),
Failed: scores.Count(s => s < paper.PassMarks),
Highest: scores.Count > 0 ? scores.Max() : null,
Average: scores.Count > 0 ? Math.Round(scores.Average(), 2) : null);
}
}
The rules run in the order of the table above, cheapest first, and nothing is saved until all of them pass. The changed marks go into a list for the repository, the pattern from Lesson 81.
Step 4: the report card service
using SchoolApp.Core.Dtos;
using SchoolApp.Core.Interfaces;
using SchoolApp.Core.Rules;
namespace SchoolApp.Core.Services;
// Lesson 82: a student's report card for one exam. The data comes from the repository; the rules are here:
// - each paper: Pass at or above its pass marks, Fail below, Absent, or Not entered yet;
// - the result: Incomplete while any paper is not entered; otherwise Pass only if every paper passed;
// - the grade: from the class's grade scale (Lesson 56), on the percentage of all papers.
public class ReportCardService
{
private readonly IMarksRepository _marks;
public ReportCardService(IMarksRepository marks)
{
_marks = marks;
}
public async Task<ReportCardDto?> GetAsync(string admissionNumber, int examId, CancellationToken cancellationToken = default)
{
ReportCardData? data = await _marks.GetReportCardDataAsync(admissionNumber, examId, cancellationToken);
if (data is null)
{
return null;
}
IGradeScale scale = GradeScales.ForClass(data.ClassId);
List<ReportCardLineDto> lines = data.Papers.Select(p =>
{
string status = !p.HasMark ? "Not entered"
: p.IsAbsent ? "Absent"
: p.MarksObtained >= p.PassMarks ? "Pass" : "Fail";
string? grade = p.MarksObtained is decimal m ? scale.Grade(SchoolRules.CalculatePercentage(m, p.MaxMarks)) : null;
return new ReportCardLineDto(p.Subject, p.MaxMarks, p.PassMarks, p.MarksObtained, status, grade);
}).ToList();
// Totals cover the papers with a mark entered; an absent paper counts as 0 of its maximum.
List<ReportCardPaper> entered = data.Papers.Where(p => p.HasMark).ToList();
decimal totalObtained = entered.Sum(p => p.MarksObtained ?? 0m);
decimal totalMax = entered.Sum(p => p.MaxMarks);
bool complete = entered.Count == data.Papers.Count && data.Papers.Count > 0;
decimal? percentage = complete ? SchoolRules.CalculatePercentage(totalObtained, totalMax) : null;
string result = !complete ? "Incomplete"
: lines.All(l => l.Status == "Pass") ? "Pass" : "Fail";
return new ReportCardDto(data.AdmissionNumber, data.StudentName, data.ClassName, data.SectionName, data.ExamName,
lines, totalObtained, totalMax, percentage,
percentage is decimal p2 ? scale.Grade(p2) : null, result);
}
}
The repository only answers "what is stored": each paper, with the student's mark if there is one. Every decision about Pass, grades, and results stays here, where a unit test can check it without a database.
Step 5: the Dapper repository
Show src/SchoolApp.Data/Dapper/DapperMarksRepository.cs
using Dapper;
using Microsoft.Data.SqlClient;
using SchoolApp.Core.Dtos;
using SchoolApp.Core.Entities;
using SchoolApp.Core.Exceptions;
using SchoolApp.Core.Interfaces;
namespace SchoolApp.Data.Dapper;
// Lesson 82: exams, papers, marks, and report card data, with Dapper (the Lesson 81 patterns).
public class DapperMarksRepository : IMarksRepository
{
private const string SelectPaper = """
SELECT es.Id, es.ExamId, es.ClassId, sub.Code AS SubjectCode, sub.Name AS SubjectName,
es.ExamDate, es.MaxMarks, es.PassMarks
FROM dbo.ExamSubjects AS es
JOIN dbo.Subjects AS sub ON sub.Id = es.SubjectId
""";
private readonly SqlConnectionFactory _connections;
public DapperMarksRepository(SqlConnectionFactory connections)
{
_connections = connections;
}
public async Task<List<ExamDto>> GetExamsAsync(int academicYearId, CancellationToken cancellationToken = default)
{
await using SqlConnection connection = _connections.Create();
IEnumerable<ExamRow> rows = await connection.QueryAsync<ExamRow>(new CommandDefinition("""
SELECT Id, Name, ExamType, StartDate, EndDate
FROM dbo.Exams
WHERE AcademicYearId = @AcademicYearId
ORDER BY StartDate;
""", new { AcademicYearId = academicYearId }, cancellationToken: cancellationToken));
return rows.Select(r => new ExamDto(r.Id, r.Name, r.ExamType, r.StartDate, r.EndDate)).ToList();
}
public async Task<List<PaperDto>> GetPapersAsync(int examId, int classId, CancellationToken cancellationToken = default)
{
await using SqlConnection connection = _connections.Create();
IEnumerable<PaperRow> rows = await connection.QueryAsync<PaperRow>(new CommandDefinition(
SelectPaper + " WHERE es.ExamId = @ExamId AND es.ClassId = @ClassId ORDER BY es.ExamDate, sub.Name;",
new { ExamId = examId, ClassId = classId }, cancellationToken: cancellationToken));
return rows.Select(r => r.ToDto()).ToList();
}
public async Task<PaperDto?> GetPaperAsync(int examSubjectId, CancellationToken cancellationToken = default)
{
await using SqlConnection connection = _connections.Create();
PaperRow? row = await connection.QuerySingleOrDefaultAsync<PaperRow>(new CommandDefinition(
SelectPaper + " WHERE es.Id = @Id;", new { Id = examSubjectId }, cancellationToken: cancellationToken));
return row?.ToDto();
}
public async Task<int?> GetSectionClassIdAsync(int sectionId, CancellationToken cancellationToken = default)
{
await using SqlConnection connection = _connections.Create();
return await connection.QuerySingleOrDefaultAsync<int?>(new CommandDefinition(
"SELECT ClassId FROM dbo.Sections WHERE Id = @Id;", new { Id = sectionId }, cancellationToken: cancellationToken));
}
public async Task<List<RegisterStudent>> GetRegisterStudentsAsync(int sectionId, int academicYearId,
CancellationToken cancellationToken = default)
{
await using SqlConnection connection = _connections.Create();
IEnumerable<RegisterStudent> students = await connection.QueryAsync<RegisterStudent>(new CommandDefinition("""
SELECT e.Id AS EnrollmentId, e.RollNumber, s.AdmissionNumber, s.FirstName + ' ' + s.LastName AS StudentName
FROM dbo.Enrollments AS e
JOIN dbo.Students AS s ON s.Id = e.StudentId AND s.IsDeleted = 0
WHERE e.SectionId = @SectionId AND e.AcademicYearId = @AcademicYearId
ORDER BY e.RollNumber;
""", new { SectionId = sectionId, AcademicYearId = academicYearId }, cancellationToken: cancellationToken));
return students.ToList();
}
public async Task<List<Mark>> GetMarksAsync(int examSubjectId, int sectionId, CancellationToken cancellationToken = default)
{
await using SqlConnection connection = _connections.Create();
IEnumerable<Mark> marks = await connection.QueryAsync<Mark>(new CommandDefinition("""
SELECT m.Id, m.ExamSubjectId, m.EnrollmentId, m.MarksObtained, m.IsAbsent, m.Remarks,
m.CreatedAt, m.CreatedBy, m.UpdatedAt, m.UpdatedBy
FROM dbo.Marks AS m
JOIN dbo.Enrollments AS e ON e.Id = m.EnrollmentId
JOIN dbo.Students AS s ON s.Id = e.StudentId AND s.IsDeleted = 0
WHERE m.ExamSubjectId = @ExamSubjectId AND e.SectionId = @SectionId;
""", new { ExamSubjectId = examSubjectId, SectionId = sectionId }, cancellationToken: cancellationToken));
return marks.ToList();
}
// One transaction: the changed marks, then the new ones. Marks has triggers (Lesson 23), so the INSERT
// has no OUTPUT clause; nothing here needs the new Ids.
public async Task SaveAsync(IReadOnlyCollection<Mark> newMarks, IReadOnlyCollection<Mark> changedMarks,
CancellationToken cancellationToken = default)
{
await using SqlConnection connection = _connections.Create();
await connection.OpenAsync(cancellationToken);
await using SqlTransaction transaction = connection.BeginTransaction();
try
{
await connection.ExecuteAsync(new CommandDefinition("""
UPDATE dbo.Marks
SET MarksObtained = @MarksObtained, IsAbsent = @IsAbsent, Remarks = @Remarks, UpdatedAt = SYSUTCDATETIME()
WHERE Id = @Id;
""", changedMarks.Select(m => new { m.Id, m.MarksObtained, m.IsAbsent, m.Remarks }),
transaction, cancellationToken: cancellationToken));
await connection.ExecuteAsync(new CommandDefinition("""
INSERT INTO dbo.Marks (ExamSubjectId, EnrollmentId, MarksObtained, IsAbsent, Remarks, CreatedBy)
VALUES (@ExamSubjectId, @EnrollmentId, @MarksObtained, @IsAbsent, @Remarks, @CreatedBy);
""", newMarks.Select(m => new { m.ExamSubjectId, m.EnrollmentId, m.MarksObtained, m.IsAbsent, m.Remarks, m.CreatedBy }),
transaction, cancellationToken: cancellationToken));
await transaction.CommitAsync(cancellationToken);
}
catch (SqlException ex) when (ex.Number == 50001)
{
// tr_Marks_ValidateMaxMarks is the database's own check. Its THROW 50001 arrives as the same
// school error the service raises, whichever program wrote the mark.
throw new SchoolRuleException(50001, ex.Message, ex);
}
}
// The register, each student with that paper's mark if one was entered.
public async Task<List<MarksSheetLineDto>> GetMarksSheetAsync(int examSubjectId, int sectionId, int academicYearId,
CancellationToken cancellationToken = default)
{
await using SqlConnection connection = _connections.Create();
IEnumerable<SheetRow> rows = await connection.QueryAsync<SheetRow>(new CommandDefinition("""
SELECT e.RollNumber, s.AdmissionNumber, s.FirstName + ' ' + s.LastName AS StudentName,
m.MarksObtained, m.IsAbsent, m.Remarks
FROM dbo.Enrollments AS e
JOIN dbo.Students AS s ON s.Id = e.StudentId AND s.IsDeleted = 0
LEFT JOIN dbo.Marks AS m ON m.EnrollmentId = e.Id AND m.ExamSubjectId = @ExamSubjectId
WHERE e.SectionId = @SectionId AND e.AcademicYearId = @AcademicYearId
ORDER BY e.RollNumber;
""", new { ExamSubjectId = examSubjectId, SectionId = sectionId, AcademicYearId = academicYearId },
cancellationToken: cancellationToken));
return rows.Select(r => new MarksSheetLineDto(r.RollNumber, r.AdmissionNumber, r.StudentName,
r.MarksObtained, r.IsAbsent, r.Remarks)).ToList();
}
// Three statements, one round trip: the exam, the student's enrollment in the exam's year, and every
// paper of the exam for the student's class with the student's mark where there is one.
public async Task<ReportCardData?> GetReportCardDataAsync(string admissionNumber, int examId,
CancellationToken cancellationToken = default)
{
const string sql = """
SELECT Name, AcademicYearId FROM dbo.Exams WHERE Id = @ExamId;
SELECT e.Id AS EnrollmentId, s.AdmissionNumber, s.FirstName + ' ' + s.LastName AS StudentName,
sec.ClassId, c.Name AS ClassName, sec.Name AS SectionName
FROM dbo.Enrollments AS e
JOIN dbo.Students AS s ON s.Id = e.StudentId AND s.IsDeleted = 0
JOIN dbo.Sections AS sec ON sec.Id = e.SectionId
JOIN dbo.Classes AS c ON c.Id = sec.ClassId
JOIN dbo.Exams AS x ON x.AcademicYearId = e.AcademicYearId AND x.Id = @ExamId
WHERE s.AdmissionNumber = @AdmissionNumber;
SELECT sub.Name AS Subject, es.MaxMarks, es.PassMarks,
CAST(CASE WHEN m.Id IS NULL THEN 0 ELSE 1 END AS BIT) AS HasMark,
m.MarksObtained, ISNULL(m.IsAbsent, CAST(0 AS BIT)) AS IsAbsent
FROM dbo.Enrollments AS e
JOIN dbo.Students AS s ON s.Id = e.StudentId AND s.IsDeleted = 0
JOIN dbo.Sections AS sec ON sec.Id = e.SectionId
JOIN dbo.Exams AS x ON x.AcademicYearId = e.AcademicYearId AND x.Id = @ExamId
JOIN dbo.ExamSubjects AS es ON es.ExamId = x.Id AND es.ClassId = sec.ClassId
JOIN dbo.Subjects AS sub ON sub.Id = es.SubjectId
LEFT JOIN dbo.Marks AS m ON m.ExamSubjectId = es.Id AND m.EnrollmentId = e.Id
WHERE s.AdmissionNumber = @AdmissionNumber
ORDER BY es.ExamDate, sub.Name;
""";
await using SqlConnection connection = _connections.Create();
await using SqlMapper.GridReader results = await connection.QueryMultipleAsync(new CommandDefinition(sql, new
{
ExamId = examId,
AdmissionNumber = new DbString { Value = admissionNumber, IsAnsi = true, Length = 15 }
}, cancellationToken: cancellationToken));
ExamHead? exam = await results.ReadSingleOrDefaultAsync<ExamHead>();
StudentInExam? student = await results.ReadSingleOrDefaultAsync<StudentInExam>();
List<PaperLine> papers = (await results.ReadAsync<PaperLine>()).ToList();
if (exam is null || student is null)
{
return null; // no such exam, or the student was not enrolled in the exam's year
}
return new ReportCardData(student.AdmissionNumber, student.StudentName, student.ClassId, student.ClassName,
student.SectionName, exam.Name,
papers.Select(p => new ReportCardPaper(p.Subject, p.MaxMarks, p.PassMarks, p.HasMark, p.MarksObtained, p.IsAbsent)).ToList());
}
// Rows with DATE columns or nullable values land in classes first (Lessons 58 and 81).
private sealed class ExamRow
{
public int Id { get; set; }
public string Name { get; set; } = "";
public string ExamType { get; set; } = "";
public DateOnly StartDate { get; set; }
public DateOnly EndDate { get; set; }
}
private sealed class PaperRow
{
public int Id { get; set; }
public int ExamId { get; set; }
public int ClassId { get; set; }
public string SubjectCode { get; set; } = "";
public string SubjectName { get; set; } = "";
public DateOnly ExamDate { get; set; }
public decimal MaxMarks { get; set; }
public decimal PassMarks { get; set; }
public PaperDto ToDto() => new PaperDto(Id, ExamId, ClassId, SubjectCode, SubjectName, ExamDate, MaxMarks, PassMarks);
}
private sealed class SheetRow
{
public short RollNumber { get; set; }
public string AdmissionNumber { get; set; } = "";
public string StudentName { get; set; } = "";
public decimal? MarksObtained { get; set; }
public bool? IsAbsent { get; set; }
public string? Remarks { get; set; }
}
private sealed class ExamHead
{
public string Name { get; set; } = "";
public int AcademicYearId { get; set; }
}
private sealed class StudentInExam
{
public int EnrollmentId { get; set; }
public string AdmissionNumber { get; set; } = "";
public string StudentName { get; set; } = "";
public int ClassId { get; set; }
public string ClassName { get; set; } = "";
public string SectionName { get; set; } = "";
}
private sealed class PaperLine
{
public string Subject { get; set; } = "";
public decimal MaxMarks { get; set; }
public decimal PassMarks { get; set; }
public bool HasMark { get; set; }
public decimal? MarksObtained { get; set; }
public bool IsAbsent { get; set; }
}
}
Two details are new. The report card query sends three SELECTs in one round trip with QueryMultiple.
The method SaveAsync turns the trigger's SqlException 50001 into the same SchoolRuleException the
service uses. The client sees one error, whoever caught the mistake.
Step 6: the controller
Show src/SchoolApp.Api/Controllers/ExamsController.cs
using System.ComponentModel.DataAnnotations;
using Microsoft.AspNetCore.Mvc;
using SchoolApp.Api.Models;
using SchoolApp.Core.Dtos;
using SchoolApp.Core.Interfaces;
using SchoolApp.Core.Services;
namespace SchoolApp.Api.Controllers;
// Lesson 82: exams, their papers, marks entry, and report cards.
// GET /api/exams this year's exams
// GET /api/exams/{examId}/papers?classId=10 one class's papers
// GET and PUT /api/exam-papers/{paperId}/marks?sectionId=19
// GET /api/students/{admissionNumber}/report-cards/{examId}
[ApiController]
[Produces("application/json")]
[ProducesErrorResponseType(typeof(ApiResponse))] // Lesson 80: errors are documented as ApiResponse
public class ExamsController : ControllerBase
{
private readonly IMarksRepository _marks;
private readonly IMasterDataRepository _masterData;
private readonly MarksService _marksService;
private readonly ReportCardService _reportCards;
public ExamsController(IMarksRepository marks, IMasterDataRepository masterData, MarksService marksService,
ReportCardService reportCards)
{
_marks = marks;
_masterData = masterData;
_marksService = marksService;
_reportCards = reportCards;
}
/// <summary>Gets the current academic year's exams.</summary>
[HttpGet("/api/exams")]
[ProducesResponseType(StatusCodes.Status200OK)]
public async Task<ApiResponse<List<ExamDto>>> GetExams(CancellationToken cancellationToken)
{
AcademicYearDto? year = await _masterData.GetCurrentAcademicYearAsync(cancellationToken);
List<ExamDto> exams = year is null ? [] : await _marks.GetExamsAsync(year.Id, cancellationToken);
return ApiResponse.Ok(exams, $"{exams.Count} exams this year.");
}
/// <summary>Gets one class's papers for an exam, in date order.</summary>
[HttpGet("/api/exams/{examId:int}/papers")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ApiResponse<List<PaperDto>>> GetPapers(int examId, [FromQuery, Range(1, 10)] int classId,
CancellationToken cancellationToken)
{
List<PaperDto> papers = await _marks.GetPapersAsync(examId, classId, cancellationToken);
return ApiResponse.Ok(papers, $"{papers.Count} papers for Class {classId}.");
}
/// <summary>Gets a section's marks sheet for one paper; marks are null where none is entered yet.</summary>
/// <response code="200">One line per student, in roll number order.</response>
/// <response code="404">No such paper (50050), or the section has no students this year (50031).</response>
[HttpGet("/api/exam-papers/{paperId:int}/marks")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<ApiResponse<List<MarksSheetLineDto>>>> GetMarksSheet(
int paperId, [FromQuery, Required] int sectionId, CancellationToken cancellationToken)
{
PaperDto? paper = await _marks.GetPaperAsync(paperId, cancellationToken);
if (paper is null)
{
return NotFound(ApiResponse.Fail($"No exam paper has id {paperId}.", 50050));
}
AcademicYearDto? year = await _masterData.GetCurrentAcademicYearAsync(cancellationToken);
List<MarksSheetLineDto> sheet = year is null ? [] : await _marks.GetMarksSheetAsync(paperId, sectionId, year.Id, cancellationToken);
if (sheet.Count == 0)
{
return NotFound(ApiResponse.Fail($"Section {sectionId} does not exist or has no students this year.", 50031));
}
return ApiResponse.Ok(sheet, $"{paper.SubjectName} marks for section {sectionId}.");
}
/// <summary>Enters (or corrects) a section's marks for one paper. The whole marks sheet is required.</summary>
/// <response code="200">Saved, with the paper's results for the section.</response>
/// <response code="400">The request is invalid.</response>
/// <response code="404">No such paper (50050) or section (50031).</response>
/// <response code="422">A marks rule was broken (50001, 50043 to 50045, 50051 to 50053).</response>
[HttpPut("/api/exam-papers/{paperId:int}/marks")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<ApiResponse<EnterMarksResult>> EnterMarks(int paperId, [FromQuery, Required] int sectionId,
EnterMarksRequest request, CancellationToken cancellationToken)
{
EnterMarksResult result = await _marksService.EnterAsync(paperId, sectionId, request, cancellationToken);
return ApiResponse.Ok(result, "Marks saved.");
}
/// <summary>Gets a student's report card for one exam.</summary>
/// <response code="200">The report card.</response>
/// <response code="404">No such student or exam, or the student was not enrolled in the exam's year.</response>
[HttpGet("/api/students/{admissionNumber}/report-cards/{examId:int}")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<ApiResponse<ReportCardDto>>> GetReportCard(string admissionNumber, int examId,
CancellationToken cancellationToken)
{
ReportCardDto? card = await _reportCards.GetAsync(admissionNumber, examId, cancellationToken);
if (card is null)
{
return NotFound(ApiResponse.Fail($"No report card for {admissionNumber} in exam {examId}."));
}
return ApiResponse.Ok(card, $"{card.ExamName} report card.");
}
}
Error 50050 joins the 404 group in SchoolRuleExceptionHandler, next to the other "not found" numbers.
Step 7: enter a sheet, three times
10-A is section 19 (Aarav, Diya, Kabir, and Meera). Paper 19 is the Half Yearly English paper, written on 2026-09-21:
PUT /api/exam-papers/19/marks?sectionId=19 (1: 88, 2: 74.5, 3: 31 "Needs revision", 4: absent "Medical leave")
{"status":"success","message":"Marks saved.","data":{"examSubjectId":19,"sectionId":19,"created":4,"updated":0,"absent":1,"passed":2,"failed":1,"highest":88,"average":64.5}}
PUT (the same sheet again)
{"status":"success","message":"Marks saved.","data":{"examSubjectId":19,"sectionId":19,"created":0,"updated":0,"absent":1,"passed":2,"failed":1,"highest":88,"average":64.5}}
PUT (Kabir rechecked: 36 "Rechecked")
{"status":"success","message":"Marks saved.","data":{"examSubjectId":19,"sectionId":19,"created":0,"updated":1,"absent":1,"passed":3,"failed":0,"highest":88,"average":66.17}}
Kabir's correction turned a Fail into a Pass and moved the average from 64.5 to 66.17. The audit trigger from
Lesson 23 also wrote one row for the change: {"MarksObtained":31.00} to {"MarksObtained":36.00}.
Step 8: every rule
| Request | Status | Message |
|---|---|---|
| Paper 999 | 404, 50050 | No exam paper has id 999. |
| Section 9 (5-A) for a Class 10 paper | 422, 50052 | This paper is for Class 10, not for section 9. |
| 105 marks | 422, 50001 | Marks must be between 0 and 100.00; got 105. |
| Roll 2 with marks and absent | 422, 50053 | Roll number 2: give marks, or mark the student absent, not both. |
| Roll 4 missing | 422, 50044 | The marks sheet is incomplete: roll number 4 missing. |
Every exam in the sample data has already happened, so the date rule (50051) is shown by a unit test instead. The test stops the clock at 2026-10-08 and uses a paper dated 2026-12-07.
Step 9: two report cards
Meera's Unit Test 1 card. She scored 95 in Mathematics, was absent for Science, and the other papers have no marks yet:
GET /api/students/ADM-2017-0004/report-cards/1
English: Not entered · Mathematics: 95, Pass, A1 · Science: Absent · Social Studies, Computer Science, Hindi: Not entered
"totalObtained":95.00, "totalMax":200.00, "percentage":null, "grade":null, "result":"Incomplete"
Kabir's Half Yearly card shows the corrected English mark: 36, Pass, grade D. It is also "Incomplete", because five papers have no marks yet. Meera's card took about 6 ms each time.
All demo marks and audit rows were removed from SchoolDb afterwards.
Step 10: tests, including the database's guard
MarksServiceTests covers a full sheet, a repeat and a correction, four paper and section errors, the
"both" rule, and marks above the maximum. Two report card tests check a complete card (88.5%, A2, Pass) and
an incomplete one.
DapperMarksTests skips the service and saves 150 out of 100 straight through the repository. The trigger
refuses it, the repository reports 50001, and no row is saved. The suite has 76 tests, all passing.
Common mistakes
- One request per mark. Forty requests per paper, and half-saved sheets. Fix: the whole sheet in one request.
- Zero for absent. An absent student and a student who scored 0 look the same. Fix:
IsAbsentwithnullmarks. - Grades in SQL and in C#. Two places drift apart. Fix: one grade scale, used by one service.
- Percentages on incomplete cards. A missing paper makes the percentage look higher. Fix: no percentage until every paper is entered.
- Letting trigger errors through raw. The client gets a 500. Fix: translate SQL error 50001 into the school error.
In real projects
A school's report cards were printed with percentages calculated from the papers entered so far. One teacher entered her marks two days late.
Several students' printed percentages were 10 points too high, and parents compared cards at the gate. The school had to reprint and apologise.
We changed the rule exactly as in this lesson: no percentage, and "Incomplete", until every paper is entered. The office now sees which papers are missing before anything is printed.
Best practices
- Save marks per paper and section, as one idempotent request.
- Keep "absent" separate from "zero".
- Keep result and grade rules in one service, and test them without a database.
- Check important rules in the API and in the database, with the same error number.
- Share a check once a second feature needs it, not before.
Recap
- A paper is one subject for one class in one exam; marks are entered per paper and section.
- The same sheet sent twice changed nothing; Kabir's correction changed one mark.
- Rules 50050 to 50053 joined 50001 and the shared register rules.
- Report cards say "Incomplete" until every paper is entered.
- The trigger's error 50001 reaches the client as the same school error.
Homework
Library module task: Add GET /api/students/{admissionNumber}/library-card?asOf= to a single-file
library API. It is built like the report card: a Dapper query brings the loans, and rules decide each line.
Each loan is "Returned", "Returned late", "On loan", or "Overdue", with days late and a fine of 2 per day. The card shows the books on loan, the total fine, and a result: "Clear", "Fine due", or "Overdue books".
A correct result looks like:
GET /api/students/ADM-2021-0001/library-card?asOf=2026-08-01
{"status":"success","message":"Library card as on 2026-08-01.","data":{"admissionNumber":"ADM-2021-0001","studentName":"Advika Sharma","asOf":"2026-08-01","loans":[{"title":"The Jungle Book","issuedOn":"2026-07-06","dueOn":"2026-07-20","returnedOn":null,"status":"Overdue","daysLate":12,"fine":24}],"onLoan":1,"totalFine":24,"result":"Overdue books"}}
GET /api/students/ADM-2017-0001/library-card?asOf=2026-08-01
... "loans":[{"title":"Wings of Fire", ... "returnedOn":"2026-07-14","status":"Returned","daysLate":0,"fine":0}],"onLoan":0,"totalFine":0,"result":"Clear"
Show the solution
// Library homework, Lesson 82: a student's library card, built like the report card. The query brings the
// raw loans; the rules decide each line's status and the card's result. Every answer is an ApiResponse.
// Run with: dotnet run library-assignment/082-library-card.cs
#:project ../src/SchoolApp.Data/SchoolApp.Data.csproj
#:sdk Microsoft.NET.Sdk.Web
#:property PublishAot=false
using System.Text.Json.Serialization;
using Dapper;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Data.SqlClient;
using SchoolApp.Data;
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddSingleton(SqlConnectionFactory.FromEnvironment());
WebApplication app = builder.Build();
app.MapControllers();
app.Run("http://localhost:5082");
// The Lesson 80 shape, cut down to what this file needs.
public class ApiResponse
{
public string Status { get; init; } = "success";
public string Message { get; init; } = "";
public static ApiResponse<T> Ok<T>(T data, string message) => new ApiResponse<T> { Message = message, Data = data };
public static ApiResponse Fail(string message) => new ApiResponse { Status = "error", Message = message };
}
public class ApiResponse<T> : ApiResponse
{
[JsonPropertyOrder(1)]
public T? Data { get; init; }
}
// One loan as stored. A class, not a record: DATE columns into DateOnly (Lesson 58).
public class LoanRow
{
public string Title { get; set; } = "";
public DateOnly IssuedOn { get; set; }
public DateOnly DueOn { get; set; }
public DateOnly? ReturnedOn { get; set; }
}
public record CardLine(string Title, DateOnly IssuedOn, DateOnly DueOn, DateOnly? ReturnedOn, string Status, int DaysLate, decimal Fine);
public record LibraryCard(string AdmissionNumber, string StudentName, DateOnly AsOf, IReadOnlyList<CardLine> Loans,
int OnLoan, decimal TotalFine, string Result);
[ApiController]
[Route("api/students/{admissionNumber}/library-card")]
public class LibraryCardController : ControllerBase
{
private const decimal FinePerDay = 2m; // sms-reference.md, section 5
private readonly SqlConnectionFactory _connections;
public LibraryCardController(SqlConnectionFactory connections)
{
_connections = connections;
}
// GET /api/students/ADM-2021-0001/library-card?asOf=2026-08-01
[HttpGet]
public async Task<ActionResult<ApiResponse<LibraryCard>>> Get(string admissionNumber, [FromQuery] DateOnly asOf)
{
await using SqlConnection connection = _connections.Create();
await using SqlMapper.GridReader results = await connection.QueryMultipleAsync("""
SELECT FirstName + ' ' + LastName FROM dbo.Students WHERE AdmissionNumber = @AdmissionNumber AND IsDeleted = 0;
SELECT b.Title, i.IssuedOn, i.DueOn, i.ReturnedOn
FROM dbo.BookIssues AS i
JOIN dbo.BookCopies AS c ON c.Id = i.BookCopyId
JOIN dbo.Books AS b ON b.Id = c.BookId
JOIN dbo.Students AS s ON s.Id = i.StudentId
WHERE s.AdmissionNumber = @AdmissionNumber AND i.IssuedOn <= @AsOf
ORDER BY i.IssuedOn;
""", new { AdmissionNumber = admissionNumber, AsOf = asOf });
string? name = await results.ReadSingleOrDefaultAsync<string>();
if (name is null)
{
return NotFound(ApiResponse.Fail($"No student has admission number {admissionNumber}."));
}
List<LoanRow> loans = (await results.ReadAsync<LoanRow>()).ToList();
// The rules: returned (late or on time), overdue, or on loan; 2 per day late.
List<CardLine> lines = loans.Select(l =>
{
DateOnly endsOn = l.ReturnedOn is DateOnly returned && returned <= asOf ? returned : asOf;
int daysLate = Math.Max(0, endsOn.DayNumber - l.DueOn.DayNumber);
bool returnedByThen = l.ReturnedOn is DateOnly r && r <= asOf;
string status = returnedByThen ? (daysLate > 0 ? "Returned late" : "Returned")
: daysLate > 0 ? "Overdue" : "On loan";
return new CardLine(l.Title, l.IssuedOn, l.DueOn, returnedByThen ? l.ReturnedOn : null, status, daysLate, daysLate * FinePerDay);
}).ToList();
int onLoan = lines.Count(l => l.ReturnedOn is null);
decimal totalFine = lines.Sum(l => l.Fine);
string result = lines.Any(l => l.Status == "Overdue") ? "Overdue books" : totalFine > 0 ? "Fine due" : "Clear";
LibraryCard card = new LibraryCard(admissionNumber, name, asOf, lines, onLoan, totalFine, result);
return ApiResponse.Ok(card, $"Library card as on {asOf:yyyy-MM-dd}.");
}
}
Try earlier dates too. On 2026-07-15, Advika's book is "On loan" and her card is "Clear"; on 2026-07-10, Aarav's book is still on loan.
Quiz
What is a paper in the SMS?
Show answer
Answer: B. One subject's exam for one class in one exam (a row of ExamSubjects) Marks are entered per paper and per section.
Why is an absent student not given 0 marks?
Show answer
Answer: B. Absent and 0 would look the same; IsAbsent keeps them apart A report card shows Absent, not a failing score.
When does a report card show a percentage?
Show answer
Answer: B. Only when every paper has been entered Until then the result is Incomplete and the percentage is null.
Why did the register check move into RegisterCheck?
Show answer
Answer: B. Marks needed the same check as attendance, so it is written once A shared rule lives in one place once a second feature needs it.
What happens when code saves 150 out of 100 without MarksService?
Show answer
Answer: B. The trigger refuses it, and the repository reports school error 50001 DapperMarksTests proves it, with no row saved.
Interview questions
Questions are grouped by experience level. Click a question to see a model answer. All questions: Web API interview questions.
Fresher (0-2 yrs)
F How would you design REST routes for entering exam marks in a school system? design
Give each paper (one subject's exam for one class) its own address and treat a section's marks sheet as one resource: GET and PUT /api/exam-papers/{paperId}/marks?sectionId=19. Exams and their papers are read with GET /api/exams and /api/exams/{examId}/papers?classId=10, and a report card with GET /api/students/{admissionNumber}/report-cards/{examId}.
F Why should an absent student not simply get 0 marks? concept
Absent and a score of 0 mean different things, and reports must show the difference. The SMS stores IsAbsent = true with null marks; the report card shows "Absent", and a line must have marks or absent, never both (error 50053).
F When does the SMS report card show a percentage and a final result? concept
Only when every paper of the exam has been entered. Until then the result is "Incomplete" and the percentage is null, because a missing paper would make the percentage look higher. A complete card is "Pass" only if every paper reached its pass marks.
Mid (3-5 yrs)
M When should a validation check move into a shared class? design
When a second feature needs exactly the same rule. In the SMS, the register check (every student once, nobody else) was written for attendance; marks sheets needed it too, so it moved to RegisterCheck, and both services call it. Sharing earlier, with only one user, often guesses the wrong shape.
M Why keep report card rules in a service instead of in the SQL query? design
The query only returns what is stored: each paper and the student's mark if there is one. The service decides Pass, Fail, Absent, grades, and the result. Rules change between schools and boards, and in C# they can be unit tested without a database, as the SMS report card tests do.
M A database trigger raises THROW 50001 for invalid marks. How should the API handle it? coding
Catch the SqlException with Number 50001 in the repository and throw the same SchoolRuleException 50001 that the service raises for the same rule. The exception handler then answers 422 with errorCode 50001, whether the C# check or the database caught the problem. Without this, the client gets a 500.
Senior (6-9 yrs)
S Printed report cards showed percentages that were too high for some students. How do you investigate and prevent it? scenario
Compare a wrong card with its marks: the usual cause is a percentage calculated from the papers entered so far while some papers were still missing. Fix the rule (no percentage, result "Incomplete", until every paper is entered), add tests for incomplete cards, and give the office a list of missing papers before printing. Reprint the affected cards.
How to answer: Reproduce with real data, name the rule that was wrong, fix it with a test, and add a process check so incomplete data cannot reach print again.
S Should the 'marks within the maximum' rule live in the API, the database, or both? design
Both. The API checks first and gives a clear message for the user. The database trigger is the final guard for every program that writes marks, such as imports or scripts. Both use error number 50001, and the repository translates the database's error, so clients see one consistent error.
How to answer: Explain defence in depth, who writes to the database besides the API, and how to keep both checks consistent for clients.
Architect / Lead (10+ yrs)
A Each school in a group uses different grade scales and pass rules. How would you design the report card logic? design
Keep the data query the same and make the rules configurable per school: grade scales as data or as strategies (the IGradeScale idea from Lesson 56), pass marks per paper, and result rules per board. Version the rules so old report cards can be reproduced exactly, cache them per school, and cover each school's rules with tests built from real past results.
How to answer: Separate stored data from rules, show how rules vary per school, and cover versioning, reproducibility, and testing.
Resources
- Code for this lesson: git tag
lesson-82-exams-marks(code repository coming soon) - Files:
src/SchoolApp.Core/Services/MarksService.cs,src/SchoolApp.Core/Services/ReportCardService.cs,src/SchoolApp.Core/Services/RegisterCheck.cs,src/SchoolApp.Data/Dapper/DapperMarksRepository.cs,src/SchoolApp.Api/Controllers/ExamsController.cs,tests/SchoolApp.Tests/Unit/MarksServiceTests.cs,library-assignment/082-library-card.cs - Video: coming soon
What's next
Marks and report cards are done. Next: fees, from fee structures to a whole class's invoices and the outstanding fees report. Lesson 83: Fees and invoices
Want live sessions, code reviews, and interview preparation? Join the next batch at Sahasra Technologies.