Model Classes for Every Table: 22 Entities – C# | Lesson 44
Introduction
Since Lesson 37, SchoolApp.Core has grown one class at a time: Student, FeeInvoice,
Teacher, Guardian. SchoolDb has 22 tables. Before Module 9 connects C# to the
database, every table needs a matching class.
A class that represents one row of a table is called an entity or model class. This
lesson writes all 22, using everything from Module 7: inheritance for shared columns,
enums for statuses, nullable types for NULL columns, and a new keyword, required.
By the end of this lesson, you will be able to:
- Map every SQL Server column to the right C# type and nullability.
- Share audit and soft-delete columns through base classes.
- Use
requiredproperties so an object cannot be created half-filled. - Link entities with foreign key properties and navigation properties.
- Check a set of classes against the real database.
Why this matters
Every data access tool in Module 9, whether ADO.NET, Dapper, or EF Core, moves rows into these
classes. If a class says string where the column allows NULL, or int where the column is
BIGINT, the read fails or the data quietly changes.
A wrong type found now costs one line. Found in production, it costs a failed report, an investigation, and a fix across every layer that copied the mistake.
The concept
Example: the mapping rules
| SQL Server column | C# property |
|---|---|
INT / BIGINT / SMALLINT / TINYINT | int / long / short / byte |
DECIMAL(10,2) | decimal |
BIT | bool |
DATE / DATETIME2 | DateOnly / DateTime |
VARCHAR, NVARCHAR, CHAR | string |
| A status column with a CHECK list | An enum from Lesson 43 |
| Column allows NULL | Add ?: decimal?, string?, DateOnly? |
| Table name, plural | Class name, singular: Marks becomes Mark |
The same rules from Lesson 31, now applied to every column. One SMS-specific rule from the
reference file: the Classes table becomes SchoolClass, so the class name never looks
like the class keyword.
Example: required properties
Plain entities, such as Subject, have no constructor. To make sure nobody creates one
without a name, the property is marked required:
public required string Name { get; set; }
Leaving it out fails the build: error CS9035: Required member 'Subject.Name' must be set in the object initializer or attribute constructor. Unlike a constructor parameter, required
works with the { ... } object initializer, which is how most code creates entities.
Example: foreign keys and navigation properties
An Enrollment points to a student in two ways:
| Property | Type | Purpose |
|---|---|---|
StudentId | int | The foreign key: the value stored in the database |
Student | Student? | A navigation property: the linked object itself |
The Id is always there; the linked object is there only when the code that loaded the enrollment also loaded its student. That is why navigation properties are nullable.
Think of a student's file in the office. It always lists the class teacher's employee code. Whether the teacher's full record is clipped to the file depends on who prepared it.
Example: base classes for shared columns
Thirteen tables have the same four audit columns, and five also have the soft-delete pair. Instead of copying them, two base classes hold them once:
| Base class | Columns | Used by |
|---|---|---|
AuditableEntity | CreatedAt, CreatedBy, UpdatedAt, UpdatedBy | Enrollments, Exams, Marks, FeeInvoices, and more |
SoftDeletableEntity | The audit columns, plus IsDeleted, DeletedAt | Students, Teachers, Guardians, FeePayments, Users |
Person now inherits from SoftDeletableEntity, so students, teachers, and guardians get
every audit and soft-delete column without writing them.
The chain is now four classes deep: AuditableEntity, SoftDeletableEntity, Person,
Student. That is deeper than Lesson 39 recommends. It stays acceptable only because the top
two classes hold plain columns and no rules, so nothing surprising hides up the chain.
Apply it to the SMS
Step 1: the two base classes
namespace SchoolApp.Core.Entities;
// Lesson 44: the audit columns from Lesson 14, shared by every audited table.
public abstract class AuditableEntity
{
public DateTime CreatedAt { get; set; } // UTC; the database default fills it
public int? CreatedBy { get; set; } // Users.Id; null means system or seed data
public DateTime? UpdatedAt { get; set; }
public int? UpdatedBy { get; set; }
}
using SchoolApp.Core.Interfaces;
namespace SchoolApp.Core.Entities;
// Lesson 44: the soft-delete columns from Lesson 14, used by Students, Teachers, Guardians,
// Users, and FeePayments. Moved here from Person so every soft-deletable table shares them.
public abstract class SoftDeletableEntity : AuditableEntity, ISoftDeletable
{
public bool IsDeleted { get; private set; }
public DateTime? DeletedAt { get; private set; }
public void SoftDelete(DateTime deletedAtUtc)
{
IsDeleted = true;
DeletedAt = deletedAtUtc;
}
}
The soft-delete members moved here from Person, which now starts with
public abstract class Person : SoftDeletableEntity.
Step 2: the existing classes, completed
Student and Teacher gain their missing columns. Their constructors now require Gender,
which fixes the "gender never set" trap from Lesson 43. FeeInvoice gains its dates and
EnrollmentId, and the audit columns.
Show Student.cs, Teacher.cs, and FeeInvoice.cs
using SchoolApp.Core.Enums;
namespace SchoolApp.Core.Entities;
// Lesson 37: a class describes what every student has (properties) and can do (methods).
// Lesson 38: names are checked and trimmed; the admission number can never change.
// Lesson 39: names, Id, and FullName now come from the Person base class.
// Lesson 42: sealed (no class may inherit from Student) and partial: this file holds the data,
// Student.Behaviour.cs holds the methods. Both parts make one class.
// Lesson 43: Gender and Status are enums.
// Lesson 44: every column of the Students table; Gender is required in the constructor.
public sealed partial class Student : Person
{
public string AdmissionNumber { get; } // set once, in the constructor
public DateOnly DateOfBirth { get; set; }
public Gender Gender { get; set; }
public DateOnly AdmissionDate { get; set; }
public string? Address { get; set; }
public StudentStatus Status { get; set; } = StudentStatus.Active; // the same default as DF_Students_Status
// base(...) runs Person's constructor first, which checks the names.
public Student(string admissionNumber, string firstName, string lastName, DateOnly dateOfBirth, Gender gender)
: base(firstName, lastName)
{
AdmissionNumber = RequireText(admissionNumber, nameof(AdmissionNumber));
DateOfBirth = dateOfBirth;
Gender = gender;
}
}
using SchoolApp.Core.Enums;
namespace SchoolApp.Core.Entities;
// Lesson 39: a teacher is a Person with an employee code and contact details.
// Lesson 44: every column of the Teachers table; Gender is required in the constructor.
public sealed class Teacher : Person
{
public string EmployeeCode { get; }
public string Email { get; set; }
public string Phone { get; set; }
public Gender Gender { get; set; }
public DateOnly DateOfBirth { get; set; }
public DateOnly JoiningDate { get; set; }
public string? Qualification { get; set; }
public bool IsActive { get; set; } = true; // the same default as DF_Teachers_IsActive
public Teacher(string employeeCode, string firstName, string lastName, string email, string phone, Gender gender)
: base(firstName, lastName)
{
EmployeeCode = RequireText(employeeCode, nameof(EmployeeCode));
Email = RequireText(email, nameof(Email));
Phone = RequireText(phone, nameof(Phone));
Gender = gender;
}
public override string Role => "Teacher";
public override string Describe() => $"{base.Describe()} (teacher {EmployeeCode})";
}
using SchoolApp.Core.Enums;
using SchoolApp.Core.Exceptions;
namespace SchoolApp.Core.Entities;
// Lesson 38: an invoice that protects its own rules (encapsulation).
// Lesson 43: Status is an InvoiceStatus enum instead of a string.
// Lesson 44: every column of the FeeInvoices table, plus the audit columns.
public class FeeInvoice : AuditableEntity
{
private decimal _amountPaid; // a private field: only code inside this class can change it
public int Id { get; set; }
public string InvoiceNumber { get; init; } // init: can be set only while the object is created
public int EnrollmentId { get; set; }
public DateOnly InvoiceDate { get; set; }
public DateOnly DueDate { get; set; }
public decimal TotalAmount { get; } // no setter: fixed by the constructor
public decimal DiscountAmount { get; }
public InvoiceStatus Status { get; private set; } = InvoiceStatus.Unpaid; // only this class can write
public Enrollment? Enrollment { get; set; } // navigation property (Lesson 44)
public FeeInvoice(string invoiceNumber, decimal totalAmount, decimal discountAmount = 0m)
{
if (totalAmount < 0)
{
throw new ArgumentOutOfRangeException(nameof(totalAmount), "The total cannot be negative.");
}
if (discountAmount < 0 || discountAmount > totalAmount)
{
throw new ArgumentOutOfRangeException(nameof(discountAmount), "The discount must be between 0 and the total.");
}
InvoiceNumber = invoiceNumber;
TotalAmount = totalAmount;
DiscountAmount = discountAmount;
}
public decimal AmountPaid => _amountPaid;
public decimal Balance => TotalAmount - DiscountAmount - _amountPaid;
// The only way to pay. The same rules and error numbers as usp_RecordFeePayment (Lesson 22).
public void RecordPayment(decimal amount)
{
if (Status == InvoiceStatus.Cancelled)
{
throw new SchoolRuleException(50011, "Invoice is cancelled.");
}
if (amount <= 0)
{
throw new SchoolRuleException(50012, "Amount must be more than zero.");
}
if (amount > Balance)
{
throw new SchoolRuleException(50013, "Amount is more than the balance due.");
}
_amountPaid += amount;
Status = Balance == 0 ? InvoiceStatus.Paid : InvoiceStatus.PartiallyPaid;
}
// An invoice with payments on it cannot be cancelled; the payments must be refunded first.
public void Cancel()
{
if (_amountPaid > 0)
{
throw new InvalidOperationException("An invoice with payments cannot be cancelled.");
}
Status = InvoiceStatus.Cancelled;
}
}
Step 3: the new entities
Eighteen new classes complete the set. Three typical ones:
namespace SchoolApp.Core.Entities;
// Lesson 44: one row of the Marks table.
public class Mark : AuditableEntity
{
public long Id { get; set; } // BIGINT: a high-volume table
public int ExamSubjectId { get; set; }
public int EnrollmentId { get; set; }
public decimal? MarksObtained { get; set; } // NULL when the student was absent
public bool IsAbsent { get; set; }
public string? Remarks { get; set; }
public ExamSubject? ExamSubject { get; set; }
public Enrollment? Enrollment { get; set; }
}
namespace SchoolApp.Core.Entities;
// Lesson 44: one row of the Enrollments table.
public class Enrollment : AuditableEntity
{
public int Id { get; set; }
public int StudentId { get; set; }
public int AcademicYearId { get; set; }
public int SectionId { get; set; }
public short RollNumber { get; set; }
public DateOnly EnrolledOn { get; set; }
public Student? Student { get; set; }
public AcademicYear? AcademicYear { get; set; }
public Section? Section { get; set; }
}
using SchoolApp.Core.Common;
namespace SchoolApp.Core.Entities;
// Lesson 44: one row of the AcademicYears table.
public class AcademicYear
{
public int Id { get; set; }
public required string Name { get; set; } // such as 2026-27
public DateOnly StartDate { get; set; }
public DateOnly EndDate { get; set; }
public bool IsCurrent { get; set; }
public DateRange Period => new DateRange(StartDate, EndDate); // computed, not a column
}
Show the other 15 entity classes
namespace SchoolApp.Core.Entities;
// Lesson 44: one row of the Classes table.
// Named SchoolClass because "class" is a C# keyword (the SMS reference rule).
public class SchoolClass
{
public int Id { get; set; }
public required string Name { get; set; } // such as Class 10
public byte DisplayOrder { get; set; }
}
namespace SchoolApp.Core.Entities;
// Lesson 44: one row of the Sections table.
public class Section
{
public int Id { get; set; }
public int ClassId { get; set; }
public required string Name { get; set; } // A, B, ...
public byte Capacity { get; set; } = 40; // the same default as DF_Sections_Capacity
public SchoolClass? SchoolClass { get; set; } // navigation: the class this section belongs to
}
namespace SchoolApp.Core.Entities;
// Lesson 44: one row of the Subjects table.
public class Subject
{
public int Id { get; set; }
public required string Code { get; set; } // such as MATH
public required string Name { get; set; }
}
namespace SchoolApp.Core.Entities;
// Lesson 44: one row of the StudentGuardians table. A junction table: no Id of its own.
public class StudentGuardian
{
public int StudentId { get; set; }
public int GuardianId { get; set; }
public required string Relationship { get; set; } // Father, Mother, or Guardian
public bool IsPrimaryContact { get; set; }
public Student? Student { get; set; }
public Guardian? Guardian { get; set; }
}
namespace SchoolApp.Core.Entities;
// Lesson 44: one row of the SubjectAllocations table.
public class SubjectAllocation : AuditableEntity
{
public int Id { get; set; }
public int AcademicYearId { get; set; }
public int SectionId { get; set; }
public int SubjectId { get; set; }
public int TeacherId { get; set; }
}
namespace SchoolApp.Core.Entities;
// Lesson 44: one row of the ClassTeachers table.
public class ClassTeacher : AuditableEntity
{
public int Id { get; set; }
public int AcademicYearId { get; set; }
public int SectionId { get; set; }
public int TeacherId { get; set; }
}
using SchoolApp.Core.Enums;
namespace SchoolApp.Core.Entities;
// Lesson 44: one row of the AttendanceRecords table.
public class AttendanceRecord
{
public long Id { get; set; } // BIGINT: a high-volume table
public int EnrollmentId { get; set; }
public DateOnly AttendanceDate { get; set; }
public AttendanceStatus Status { get; set; }
public string? Remarks { get; set; }
public DateTime CreatedAt { get; set; } // only CreatedAt and CreatedBy, because of the volume
public int? CreatedBy { get; set; }
public Enrollment? Enrollment { get; set; }
}
using SchoolApp.Core.Enums;
namespace SchoolApp.Core.Entities;
// Lesson 44: one row of the Exams table.
public class Exam : AuditableEntity
{
public int Id { get; set; }
public int AcademicYearId { get; set; }
public required string Name { get; set; } // such as Unit Test 1
public ExamType ExamType { get; set; }
public DateOnly StartDate { get; set; }
public DateOnly EndDate { get; set; }
}
namespace SchoolApp.Core.Entities;
// Lesson 44: one row of the ExamSubjects table. One subject paper of an exam for one class.
public class ExamSubject : AuditableEntity
{
public int Id { get; set; }
public int ExamId { get; set; }
public int ClassId { get; set; }
public int SubjectId { get; set; }
public DateOnly ExamDate { get; set; }
public decimal MaxMarks { get; set; }
public decimal PassMarks { get; set; }
public Exam? Exam { get; set; }
public Subject? Subject { get; set; }
}
using SchoolApp.Core.Enums;
namespace SchoolApp.Core.Entities;
// Lesson 44: one row of the FeeStructures table.
public class FeeStructure : AuditableEntity
{
public int Id { get; set; }
public int AcademicYearId { get; set; }
public int ClassId { get; set; }
public FeeType FeeType { get; set; }
public decimal Amount { get; set; }
}
namespace SchoolApp.Core.Entities;
// Lesson 44: one row of the FeeInvoiceItems table. One line of an invoice.
public class FeeInvoiceItem
{
public int Id { get; set; }
public int FeeInvoiceId { get; set; }
public int FeeStructureId { get; set; }
public decimal Amount { get; set; }
}
using SchoolApp.Core.Enums;
namespace SchoolApp.Core.Entities;
// Lesson 44: one row of the FeePayments table.
public class FeePayment : SoftDeletableEntity
{
public int Id { get; set; }
public required string ReceiptNumber { get; set; } // such as RCP-2026-000001
public int FeeInvoiceId { get; set; }
public decimal Amount { get; set; }
public DateTime PaidOn { get; set; } // UTC
public PaymentMode PaymentMode { get; set; }
public string? TransactionReference { get; set; }
public FeeInvoice? FeeInvoice { get; set; }
}
namespace SchoolApp.Core.Entities;
// Lesson 44: one row of the Users table.
public class User : SoftDeletableEntity
{
public int Id { get; set; }
public required string UserName { get; set; }
public required string Email { get; set; }
public required string PasswordHash { get; set; } // a hash, never the password itself
public int? TeacherId { get; set; } // a user is linked to a teacher,
public int? GuardianId { get; set; } // or a guardian, or neither
public bool IsActive { get; set; } = true;
public DateTime? LastLoginAt { get; set; }
}
namespace SchoolApp.Core.Entities;
// Lesson 44: one row of the Roles table.
public class Role
{
public int Id { get; set; }
public required string Name { get; set; } // Admin, Principal, Teacher, Accountant, Parent
}
namespace SchoolApp.Core.Entities;
// Lesson 44: one row of the UserRoles table. A junction table: no Id of its own.
public class UserRole
{
public int UserId { get; set; }
public int RoleId { get; set; }
}
Step 4: the program
The program builds Aarav's real SchoolDb rows as linked objects, then walks the links:
Show src/SchoolApp.Console/Program.cs
// Lesson 44: model classes for every table. Aarav's real SchoolDb rows, as linked objects.
using SchoolApp.Core.Entities;
using SchoolApp.Core.Enums;
// 1. Master data: required properties must be set in the object initializer.
AcademicYear year = new AcademicYear
{
Id = 2, Name = "2026-27", StartDate = new DateOnly(2026, 6, 1), EndDate = new DateOnly(2027, 3, 31), IsCurrent = true
};
SchoolClass classTen = new SchoolClass { Id = 10, Name = "Class 10", DisplayOrder = 10 };
Section tenA = new Section { Id = 19, ClassId = 10, Name = "A", Capacity = 40, SchoolClass = classTen };
Subject maths = new Subject { Id = 2, Code = "MATH", Name = "Mathematics" };
// 2. People: Student, Teacher, and Guardian keep their constructors; Gender is now required.
Student aarav = new Student("ADM-2017-0001", "Aarav", "Sharma", new DateOnly(2011, 4, 12), Gender.Male)
{
Id = 1, AdmissionDate = new DateOnly(2017, 6, 1)
};
// 3. Rows that link the others: Ids for the database, navigation properties for C#.
Enrollment enrollment = new Enrollment
{
Id = 13, StudentId = 1, AcademicYearId = 2, SectionId = 19, RollNumber = 1, EnrolledOn = new DateOnly(2026, 6, 1),
Student = aarav, AcademicYear = year, Section = tenA
};
Exam unitTest1 = new Exam
{
Id = 1, AcademicYearId = 2, Name = "Unit Test 1", ExamType = ExamType.UnitTest,
StartDate = new DateOnly(2026, 7, 20), EndDate = new DateOnly(2026, 7, 24)
};
ExamSubject mathsPaper = new ExamSubject
{
Id = 8, ExamId = 1, ClassId = 10, SubjectId = 2, ExamDate = new DateOnly(2026, 7, 21),
MaxMarks = 100m, PassMarks = 35m, Exam = unitTest1, Subject = maths
};
Mark mark = new Mark { Id = 1, ExamSubjectId = 8, EnrollmentId = 13, MarksObtained = 92m, ExamSubject = mathsPaper, Enrollment = enrollment };
FeeInvoice invoice = new FeeInvoice("INV-2026-000001", 45500m)
{
Id = 1, EnrollmentId = 13, InvoiceDate = new DateOnly(2026, 6, 1), DueDate = new DateOnly(2026, 6, 30), Enrollment = enrollment
};
invoice.RecordPayment(45500m);
FeePayment payment = new FeePayment
{
Id = 1, ReceiptNumber = "RCP-2026-000001", FeeInvoiceId = 1, Amount = 45500m,
PaidOn = new DateTime(2026, 6, 10, 5, 0, 0, DateTimeKind.Utc), PaymentMode = PaymentMode.Online,
TransactionReference = "TXN-81726354", FeeInvoice = invoice
};
// 4. Navigation properties let you walk from one object to the next.
Enrollment e = mark.Enrollment!;
Console.WriteLine($"{e.Student!.FullName}, roll {e.RollNumber}, {e.Section!.SchoolClass!.Name}-{e.Section.Name}, {e.AcademicYear!.Name}");
Console.WriteLine($"{mark.ExamSubject!.Exam!.Name} ({mark.ExamSubject.Exam.ExamType}), {mark.ExamSubject.Subject!.Name}: " +
$"{mark.MarksObtained} / {mark.ExamSubject.MaxMarks}, pass mark {mark.ExamSubject.PassMarks}");
Console.WriteLine($"{invoice.InvoiceNumber}: {invoice.Status}; receipt {payment.ReceiptNumber} by {payment.PaymentMode} on {payment.PaidOn:yyyy-MM-dd}");
Console.WriteLine($"Academic year {year.Name}: {year.Period}, {year.Period.Days} days");
// 5. Base classes add the audit and soft-delete columns to every table that has them.
Console.WriteLine($"Enrollment is auditable: {enrollment is AuditableEntity}; payment is soft-deletable: {payment is SoftDeletableEntity}");
Console.WriteLine($"Mark.CreatedBy before saving: {(mark.CreatedBy is null ? "null (system)" : mark.CreatedBy)}");
Aarav Sharma, roll 1, Class 10-A, 2026-27
Unit Test 1 (UnitTest), Mathematics: 92 / 100, pass mark 35
INV-2026-000001: Paid; receipt RCP-2026-000001 by Online on 2026-06-10
Academic year 2026-27: 2026-06-01 to 2027-03-31, 304 days
Enrollment is auditable: True; payment is soft-deletable: True
Mark.CreatedBy before saving: null (system)
Example: walking the links
mark.Enrollment!.Student!.FullName goes from a mark to its enrollment, then to the student.
The second line goes from the mark to its paper, then to the exam and the subject. Every
value printed matches the real rows in SchoolDb.
The ! operator from Lesson 35 is safe here only because this program set every link
itself. In Module 9, which links are filled depends on the query, and the code must check.
Example: one paper, two pass marks
The Mathematics paper has its own PassMarks of 35, stored in ExamSubjects. The grade
scale in SchoolRules uses a pass mark of 33 percent. Both are real: the school sets a pass
mark per paper, and the grade scale is a separate, school-wide rule.
Writing the entity class is often when such details surface. Ask "which rule applies where?" before Module 11 builds the results screen on top of it.
Example: rich and plain entities
The 22 classes come in two styles. Student, Teacher, Guardian, and FeeInvoice are
rich: constructors check names, and RecordPayment protects the invoice's status. The
other eighteen are plain: properties only, with required as the one safety net.
| Style | Strength | Cost |
|---|---|---|
| Rich | Rules live with the data; invalid objects cannot exist | More code; data access tools need more setup |
| Plain | Simple; every tool maps them without effort | Rules must live somewhere else, such as a service |
Real projects mix both, as this one does. Put rules inside the entity where a broken rule would be expensive, such as money and identity, and keep simple lookup tables plain.
Example: junction tables have no Id
StudentGuardian and UserRole have no Id property, because their tables have none.
Each row is identified by the pair of foreign keys, such as StudentId plus GuardianId.
These tables exist to connect many to many: one student has two guardians, and one guardian,
like Rajesh Sharma, can have two children at the school. StudentGuardian also carries data
about the link itself: the relationship and whether this is the primary contact.
Example: what is not here yet
A student has many enrollments over the years, and an invoice has many payments. Those "one to many" links need collection properties, such as a list of enrollments. Lists arrive in Module 8, so the collections are added then.
Two other tables are left out on purpose. AuditLogs is written by the trigger from Lesson 23,
not by application code, and the library tables belong to your own Library module, which is
this lesson's homework.
Example: checking the classes against SchoolDb
To prove the 22 classes match, every column was listed from the database:
SELECT t.name AS TableName, c.name AS ColumnName, ty.name AS TypeName, c.is_nullable
FROM sys.tables AS t
JOIN sys.columns AS c ON c.object_id = t.object_id
JOIN sys.types AS ty ON ty.user_type_id = c.user_type_id
ORDER BY t.name, c.column_id;
A short C# program then read every property of every entity, using reflection: .NET's way of letting code look at its own classes and properties while it runs.
The two lists were compared. The result: 184 columns in 22 tables, 0 differences in
name, type, or nullability. The only extra properties are navigation and computed ones, such
as Balance and Period.
Common mistakes
- A non-nullable type for a NULL-able column.
decimalforMarksObtainedcannot hold an absent student. Fix: add?exactly where the column allows NULL. intfor aBIGINTkey.AttendanceRecordspassed 600,000 rows; Ids can outgrowint. Fix:longforBIGINT.- Forgetting
requiredon text that cannot be empty. Objects start with null names. Fix:required, or a constructor. - Non-nullable navigation properties. They claim the linked object is always loaded. Fix: make them nullable, and check them.
- Copying audit columns into every class. One fix needs 13 edits. Fix: a base class.
In real projects
On one project, entity classes were typed by hand from a printed schema. One column,
Discount, was int in C# and DECIMAL(10,2) in SQL. Every discount of 2.50 became 2.
It took a parent's complaint to find it. Afterwards, we added an automated test that compared every entity with the database, much like the check in this lesson. It failed the build the next time someone added a column to only one side.
Entity classes are boring code, and that is exactly why mistakes hide in them. Check them by machine, not by eye.
Best practices
- One entity class per table, named in the singular, with one property per column.
- Match types and nullability exactly, using the mapping table in this lesson.
- Use enums for every CHECK-constrained status column.
- Put shared columns in base classes; mark required text with
required. - Keep navigation properties nullable, and verify entities against the database automatically.
Recap
- An entity class represents one row of a table; SchoolApp.Core now has all 22.
- Types and nullability follow fixed mapping rules; enums replace status strings.
AuditableEntityandSoftDeletableEntityhold the shared columns once.requiredforces a property to be set when the object is created.- Foreign key properties hold Ids; nullable navigation properties hold linked objects.
- A comparison with
sys.columnsfound 184 matching columns and 0 differences.
Homework
Library module task: Write entity classes for Books, BookCopies, and BookIssues,
matching every column, including the audit and soft-delete columns that Books got in the
Lesson 14 homework.
Use a CopyStatus enum, add navigation properties from issue to copy to book, and build the
real issue of The Jungle Book to Advika as objects.
A correct result looks like:
The Jungle Book, copy 1 (Issued)
Issued 2026-07-06, due 2026-07-20, returned: not yet
Book is soft-deletable: True; fine so far: 0
Show the solution
// Library homework, Lesson 44: one class per library table, matching SchoolDb exactly.
// The #:project line lets this single-file app use the SMS base classes from SchoolApp.Core.
// Run with: dotnet run library-assignment/044-library-entities.cs
#:project ../src/SchoolApp.Core/SchoolApp.Core.csproj
using SchoolApp.Core.Entities;
Book book = new Book { Id = 2, Isbn = "9780141325293", Title = "The Jungle Book", Author = "Rudyard Kipling", Category = "Fiction", Publisher = "Puffin", PublishedYear = 1894 };
BookCopy copy = new BookCopy { Id = 3, BookId = 2, CopyNumber = 1, Status = CopyStatus.Issued, Book = book };
BookIssue issue = new BookIssue
{
Id = 2, BookCopyId = 3, StudentId = 7, IssuedOn = new DateOnly(2026, 7, 6), DueOn = new DateOnly(2026, 7, 20), BookCopy = copy
};
Console.WriteLine($"{issue.BookCopy!.Book!.Title}, copy {issue.BookCopy.CopyNumber} ({issue.BookCopy.Status})");
Console.WriteLine($"Issued {issue.IssuedOn:yyyy-MM-dd}, due {issue.DueOn:yyyy-MM-dd}, returned: {(issue.ReturnedOn is null ? "not yet" : issue.ReturnedOn)}");
Console.WriteLine($"Book is soft-deletable: {book is SoftDeletableEntity}; fine so far: {issue.FineAmount}");
// Books got audit and soft-delete columns in the Lesson 14 homework.
class Book : SoftDeletableEntity
{
public int Id { get; set; }
public required string Isbn { get; set; }
public required string Title { get; set; }
public required string Author { get; set; }
public string? Publisher { get; set; }
public short? PublishedYear { get; set; }
public required string Category { get; set; }
}
class BookCopy
{
public int Id { get; set; }
public int BookId { get; set; }
public short CopyNumber { get; set; }
public CopyStatus Status { get; set; } // Available is 0, the same default as DF_BookCopies_Status
public Book? Book { get; set; }
}
class BookIssue
{
public int Id { get; set; }
public int BookCopyId { get; set; }
public int StudentId { get; set; }
public DateOnly IssuedOn { get; set; }
public DateOnly DueOn { get; set; }
public DateOnly? ReturnedOn { get; set; } // NULL until the book comes back
public decimal FineAmount { get; set; }
public BookCopy? BookCopy { get; set; }
}
enum CopyStatus
{
Available,
Issued,
Lost
}
The #:project line lets a single-file app use a real project, here SchoolApp.Core, so
Book can inherit from the course's SoftDeletableEntity.
Quiz
Marks.MarksObtained is DECIMAL(5,2) NULL. Which C# type matches?
Show answer
Answer: B. decimal? DECIMAL maps to decimal, and NULL adds the ?.
What does the required keyword do?
Show answer
Answer: B. Forces the property to be set when the object is created Leaving it out of the object initializer fails with CS9035.
Why is Enrollment.Student nullable while StudentId is not?
Show answer
Answer: B. The Id is always stored; the linked object is loaded only when asked for The foreign key is a column; the navigation property depends on the query.
Why does AttendanceRecord use long for its Id?
Show answer
Answer: B. The column is BIGINT, for a high-volume table BIGINT maps to long; attendance has a row per student per day.
Why do Person, FeePayment, and User inherit from SoftDeletableEntity?
Show answer
Answer: B. Their tables share the audit and soft-delete columns The shared columns are written once in the base class.
Interview questions
Questions are grouped by experience level. Click a question to see a model answer. All questions: OOP interview questions.
Fresher (0-2 yrs)
F What is an entity (model) class? concept
A class that represents one row of a database table, with one property per column, such as
Mark for the Marks table. Data access code reads rows into entities and saves entities back
as rows.
F What does the required keyword do on a property? concept
Introduced in C# 11, it forces the property to be set in the object initializer when the object
is created; otherwise the build fails with CS9035. It suits entities without constructors, such
as Subject.Name.
F How do you represent a column that allows NULL in an entity class? concept
Add ? to the type: decimal? for MarksObtained, string? for Remarks, DateOnly? for a
return date. NOT NULL columns use the plain type, so the class tells callers exactly what may be missing.
Mid (3-5 yrs)
M What is the difference between a foreign key property and a navigation property? concept
The foreign key property, such as StudentId, holds the stored Id and is always present. The
navigation property, such as Student, holds the linked object and is filled only if the code
loaded it, so it is nullable.
M How do you avoid repeating audit columns in many entity classes? design
Put them in an abstract base class, such as AuditableEntity with CreatedAt, CreatedBy,
UpdatedAt, and UpdatedBy, and a SoftDeletableEntity for soft-delete columns. Entities
inherit what their table has.
M How are junction tables such as StudentGuardians modeled in C#? concept
As a class with the two foreign keys, which together form the key, plus any data about the link,
such as Relationship and IsPrimaryContact, and optional navigation properties to both sides.
There is no separate Id.
Senior (6-9 yrs)
S Discounts of 2.50 are saved as 2. The column is DECIMAL(10,2). What do you check, and how do you prevent it? scenario
The entity property type: it is probably int, which drops the fraction. Change it to decimal,
fix affected data, and add an automated test that compares every entity's property types and
nullability with the database columns, so a mismatch fails the build.
How to answer: Trace the value through the layers to find where the fraction is lost, then fix code, data, and process. Mention schema-comparison tests or scaffolding as long-term protection.
S Rich entities or plain (anemic) entities: which do you prefer? design
A mix. Put rules inside entities where breaking them is expensive, such as payments and identity, so invalid objects cannot exist. Keep simple lookup tables plain, with rules in services. Fully anemic models scatter rules; fully rich models can fight data access tools.
How to answer: Define both styles, give their trade-offs, and show judgment per entity. Mention that EF Core supports private setters and constructors, which makes rich entities practical.
Architect / Lead (10+ yrs)
A Hand-written entities or entities generated from the database: what would you choose for a large system? design
For a database-first system with many tables, generate (scaffold) them so they always match the schema, and keep custom code in partial classes. Hand-write them when the domain model must differ from the tables. Either way, verify them automatically against the schema.
How to answer: Compare effort, accuracy, and flexibility. Mention partial classes for custom code, regeneration on schema changes, and code-first migrations as the alternative where C# owns the schema.
Resources
- Code for this lesson: git tag
lesson-44-model-classes(code repository coming soon) - Files:
src/SchoolApp.Core/Entities/AuditableEntity.cs,src/SchoolApp.Core/Entities/SoftDeletableEntity.cs,src/SchoolApp.Core/Entities/Mark.cs,src/SchoolApp.Core/Entities/Enrollment.cs,src/SchoolApp.Console/Program.cs,library-assignment/044-library-entities.cs - Video: coming soon
What's next
Entities mirror the database, including things a mobile app must never see, such as
PasswordHash and audit columns. Next: DTOs, the shapes you actually send and receive, and
how to map between them. Lesson 45: DTOs and mapping
Want live sessions, code reviews, and interview preparation? Join the next batch at Sahasra Technologies.