SOLID Principles on Real Code: A Review of SchoolApp – C# | Lesson 46
Introduction
SOLID is five design principles for object-oriented code, collected by Robert C. Martin. They help code stay easy to change as it grows.
Almost every C# interview past the fresher level asks about them.
Most tutorials explain SOLID with made-up shapes and animals. You do not need them: every principle is already visible somewhere in the SchoolApp code you wrote in Module 7. This lesson reviews that code principle by principle, and adds the last pieces Module 9 needs.
By the end of this lesson, you will be able to:
- Explain each SOLID principle with an example from your own code.
- Recognise code that breaks a principle, and the cost it causes.
- Write a service that depends on an interface, passed in through its constructor.
- Explain the difference between dependency inversion and dependency injection.
- Tell when applying SOLID further would be over-engineering.
Why this matters
In Module 9, the SMS reads students from SchoolDb three different ways: ADO.NET, Dapper, and EF Core. If the code that admits a student were written against one of them, it would be rewritten twice.
With the design from this lesson, it is written once, against an interface, and works with all three. That is SOLID's real value: changes stay small, local, and safe.
The concept
Example: SOLID in one table
| Letter | Principle | In one line | In SchoolApp |
|---|---|---|---|
| S | Single Responsibility | A class has one reason to change | FeeNotice says what; INotificationSender says how (Lesson 41) |
| O | Open/Closed | Add behaviour by adding code, not editing it | A new sender is a new class; Notify never changes |
| L | Liskov Substitution | A derived class must work wherever its base is expected | Any ReportCard works with FormatLine |
| I | Interface Segregation | Many small interfaces beat one big one | ISoftDeletable has three members, not thirty |
| D | Dependency Inversion | Depend on abstractions, not on details | StudentService depends on IStudentRepository |
Think of the school's staff. The accountant handles fees, the librarian handles books (S). A new club gets a new coordinator without rewriting anyone's job (O).
Any qualified substitute can take a class (L). Each person gets only the keys they need (I). The principal hires for "a Mathematics teacher", not for one particular person (D).
Example: S, single responsibility
"One reason to change" means one person or rule that could ask for a change. In Lesson 41, the text of a fee notice and the delivery channel were separated.
A new wording comes from the accounts office; a new text-message provider comes from IT. Each changes one class.
A class that composes, sends, and logs notices would change for all three reasons, and every change would risk breaking the other two jobs.
Example: O, open for extension, closed for modification
Notify from Lesson 41 loops over INotificationSender objects. Adding a WhatsApp sender
means writing a new class; Notify stays untouched and keeps working. Code that is tested
and running is not edited, so it cannot be broken.
The opposite is the switch on payment mode from Lesson 40's story: every new mode meant
editing working code, eleven times.
Example: L, Liskov substitution
PrimaryReportCard and SecondaryReportCard can each stand in for ReportCard anywhere:
FormatLine works the same, with every score formatted sensibly.
Imagine a third subclass whose FormatScore threw NotSupportedException. It would compile,
but any code holding a ReportCard would suddenly crash. That breaks Liskov: a derived class
must keep the promises of its base, not just its method names.
Example: I, interface segregation
IStudentRepository has two members, because StudentService needs exactly two. A giant
ISchoolRepository with methods for students, fees, marks, and attendance would force every
implementation and every test fake to provide all of them.
Small interfaces are cheap to implement, easy to fake, and say clearly what a class depends on.
Example: D, dependency inversion
High-level code, the school's rules, should not depend on low-level details, such as SQL code. Both should depend on an abstraction: an interface owned by the high-level side.
IStudentRepository lives in SchoolApp.Core, next to the rules. The database code in
Module 9 will live in SchoolApp.Data and implement it. Core never references Data; the
dependency points inward, toward the rules.
Apply it to the SMS
Step 1: the repository interface
A repository is an object that stores and finds entities, so the rest of the code never deals with SQL. The interface describes only what is needed today:
using SchoolApp.Core.Entities;
namespace SchoolApp.Core.Interfaces;
// Lesson 46: what the application needs from student storage, and nothing more.
// Module 9 implements it three ways: ADO.NET, Dapper, and EF Core.
public interface IStudentRepository
{
Student? GetByAdmissionNumber(string admissionNumber);
void Add(Student student);
}
Step 2: a service with one job
using SchoolApp.Core.Dtos;
using SchoolApp.Core.Entities;
using SchoolApp.Core.Interfaces;
using SchoolApp.Core.Mapping;
namespace SchoolApp.Core.Services;
// Lesson 46: one job (admitting and finding students), and it depends on an interface,
// not on any particular database code. The repository is passed in through the constructor.
public class StudentService
{
private readonly IStudentRepository _students;
public StudentService(IStudentRepository students)
{
_students = students;
}
public StudentDto Admit(CreateStudentRequest request)
{
if (_students.GetByAdmissionNumber(request.AdmissionNumber) is not null)
{
throw new InvalidOperationException($"Admission number {request.AdmissionNumber} is already in use.");
}
Student student = request.ToEntity();
_students.Add(student);
return student.ToDto();
}
public StudentDto? Find(string admissionNumber) => _students.GetByAdmissionNumber(admissionNumber)?.ToDto();
}
The repository arrives through the constructor and is stored in a readonly field. This is
called constructor injection: the service states what it needs, and whoever creates it
provides it.
Step 3: an in-memory stand-in
Until Module 9, a simple in-memory version implements the interface. It lives in the console project, because it is a stand-in, not part of the real design:
using SchoolApp.Core.Entities;
using SchoolApp.Core.Interfaces;
// Lesson 46: a stand-in for the database, kept in memory, for trying the service and for tests.
// Module 9 replaces it with real data access. (Dictionary is explained in Module 8.)
class InMemoryStudentRepository : IStudentRepository
{
private readonly Dictionary<string, Student> _byAdmissionNumber = new(StringComparer.OrdinalIgnoreCase);
private int _nextId = 10013; // SchoolDb already has 10,012 students
public Student? GetByAdmissionNumber(string admissionNumber)
{
return _byAdmissionNumber.GetValueOrDefault(admissionNumber);
}
public void Add(Student student)
{
student.Id = _nextId++;
_byAdmissionNumber.Add(student.AdmissionNumber, student);
}
}
It uses a Dictionary, a collection explained in Module 8, and an in-memory counter for
Ids. Lesson 42 warned against such counters for real data; here it is acceptable only
because this class is a stand-in for trying the service.
Step 4: the composition root
Show src/SchoolApp.Console/Program.cs
// Lesson 46: SOLID. The service depends on an interface; the program chooses the implementation.
using SchoolApp.Core.Dtos;
using SchoolApp.Core.Enums;
using SchoolApp.Core.Interfaces;
using SchoolApp.Core.Services;
// Composition: the one place that decides which implementation to use.
IStudentRepository repository = new InMemoryStudentRepository();
StudentService service = new StudentService(repository);
// 1. Admit two new students through the service.
StudentDto ishaan = service.Admit(new CreateStudentRequest(
"ADM-2026-10001", "Ishaan", "Verma", new DateOnly(2016, 2, 14), Gender.Male, new DateOnly(2026, 6, 1), null));
StudentDto myra = service.Admit(new CreateStudentRequest(
"ADM-2026-10002", "Myra", "Joshi", new DateOnly(2016, 5, 3), Gender.Female, new DateOnly(2026, 6, 1), null));
Console.WriteLine($"Admitted {ishaan.FullName} (Id {ishaan.Id}) and {myra.FullName} (Id {myra.Id})");
// 2. The rule lives in the service: an admission number can be used only once.
try
{
service.Admit(new CreateStudentRequest(
"adm-2026-10001", "Ishaan", "Verma", new DateOnly(2016, 2, 14), Gender.Male, new DateOnly(2026, 6, 1), null));
}
catch (InvalidOperationException ex)
{
Console.WriteLine($"Refused: {ex.Message}");
}
// 3. Finding a student returns a DTO, or null.
StudentDto? found = service.Find("ADM-2026-10002");
StudentDto? missing = service.Find("ADM-2026-99999");
Console.WriteLine($"Found: {found?.FullName ?? "nobody"}; ADM-2026-99999: {missing?.FullName ?? "nobody"}");
Admitted Ishaan Verma (Id 10013) and Myra Joshi (Id 10014)
Refused: Admission number adm-2026-10001 is already in use.
Found: Myra Joshi; ADM-2026-99999: nobody
The first two lines of the program are the only place that names InMemoryStudentRepository.
This place is called the composition root. In Module 9, one line changes to a real
repository; in the Web API, ASP.NET Core's dependency injection does this wiring for you.
The duplicate check caught adm-2026-10001 in lowercase, because the in-memory dictionary
ignores case. SchoolDb behaves the same: its collation, SQL_Latin1_General_CP1_CI_AS, is
case-insensitive (CI), so the unique constraint on AdmissionNumber would also refuse it.
Example: inversion versus injection
The two terms are often confused in interviews:
| Term | What it is | Here |
|---|---|---|
| Dependency inversion | A design principle: depend on abstractions | StudentService knows only IStudentRepository |
| Dependency injection | A technique: pass dependencies in from outside | The constructor receives the repository |
| DI container | A tool that does the injecting automatically | ASP.NET Core's built-in container, Module 10 |
You can practise inversion with plain constructor injection and no container at all, as this program does.
Example: where SOLID stops
SOLID is a set of tools, not a score to maximise. An interface for every class, a factory for every object, and five layers for a simple lookup make code harder to read, not easier.
SchoolRules from Lesson 42 is a static class with no interface, and that is fine: its rules
are pure, fast, and never need swapping. Apply a principle when it removes a real cost you can
name, such as "we will have three data access versions" or "this switch changes every term".
Common mistakes
- Reciting definitions without examples. Fix: for each letter, know one place in your own code where it applies.
- A service that creates its own repository with
new. It is welded to one implementation. Fix: receive it through the constructor. - Huge "manager" interfaces. Every fake must implement everything. Fix: small interfaces per need.
- Subclasses that throw
NotSupportedException. They break Liskov. Fix: rethink the hierarchy, or use separate interfaces. - Applying every principle everywhere. Indirection without benefit. Fix: name the cost a principle removes before applying it.
In real projects
On one SMS, the results service created its own SqlResultsRepository with new, deep
inside a method. When the school asked for an offline mode for exam halls with poor
internet, that line made it impossible without a rewrite.
We changed the service to receive an interface through its constructor. The offline version became one new class, and the tests finally ran without a database.
In interviews, I ask candidates to show me SOLID in code they wrote. The good ones point at a constructor and say: "This is where it was decided."
Best practices
- Give each class one reason to change, and name it after that one job.
- Extend behaviour with new classes behind existing interfaces.
- Keep derived classes true to their base class's promises.
- Design small interfaces from the point of view of the code that uses them.
- Inject dependencies through constructors, and wire them in one composition root.
- Stop applying principles when they no longer remove a real cost.
Recap
- S: one reason to change, such as separate wording and delivery for notices.
- O: add new classes instead of editing working code.
- L: any derived class must work wherever its base class is expected.
- I: small, focused interfaces, such as
IStudentRepositorywith two members. - D: rules depend on interfaces they own; details implement them.
- Constructor injection plus a composition root make the implementation swappable.
Homework
Library module task: Write an ICopyRepository interface with FindAvailableCopy(bookId)
and MarkIssued(copy), and a LibraryService that receives it through its constructor and
issues the first available copy.
Implement the interface in memory with the four copies in SchoolDb. Issue Wings of Fire twice, then The Jungle Book once.
A correct result looks like:
Book 1: copy 1 issued to Kabir Singh
Book 1: copy 2 issued to Meera Iyer
Book 2: no copy available for Rohan Das
Show the solution
// Library homework, Lesson 46: a LibraryService that depends on an interface (SOLID).
// Run with: dotnet run library-assignment/046-library-solid.cs
ICopyRepository copies = new InMemoryCopyRepository();
LibraryService library = new LibraryService(copies);
Console.WriteLine(library.Issue(bookId: 1, studentName: "Kabir Singh")); // Wings of Fire has two free copies
Console.WriteLine(library.Issue(bookId: 1, studentName: "Meera Iyer"));
Console.WriteLine(library.Issue(bookId: 2, studentName: "Rohan Das")); // The Jungle Book's only copy is out
// What LibraryService needs from storage, and nothing more.
interface ICopyRepository
{
Copy? FindAvailableCopy(int bookId);
void MarkIssued(Copy copy);
}
// One job: the issue rule. It never knows where copies are stored.
class LibraryService
{
private readonly ICopyRepository _copies;
public LibraryService(ICopyRepository copies)
{
_copies = copies;
}
public string Issue(int bookId, string studentName)
{
Copy? copy = _copies.FindAvailableCopy(bookId);
if (copy is null)
{
return $"Book {bookId}: no copy available for {studentName}";
}
_copies.MarkIssued(copy);
return $"Book {bookId}: copy {copy.CopyNumber} issued to {studentName}";
}
}
class Copy
{
public int BookId { get; init; }
public int CopyNumber { get; init; }
public bool IsAvailable { get; set; } = true;
}
// A stand-in for the BookCopies table, with the same copies as SchoolDb.
class InMemoryCopyRepository : ICopyRepository
{
private readonly Copy[] _copies =
{
new Copy { BookId = 1, CopyNumber = 1 },
new Copy { BookId = 1, CopyNumber = 2 },
new Copy { BookId = 2, CopyNumber = 1, IsAvailable = false },
new Copy { BookId = 3, CopyNumber = 1 },
};
public Copy? FindAvailableCopy(int bookId)
{
foreach (Copy copy in _copies)
{
if (copy.BookId == bookId && copy.IsAvailable)
{
return copy;
}
}
return null;
}
public void MarkIssued(Copy copy) => copy.IsAvailable = false;
}
LibraryService never knows the copies are in an array. In Module 9, a version that reads
BookCopies from SchoolDb can replace InMemoryCopyRepository without touching the service.
Quiz
Which principle says a class should have one reason to change?
Show answer
Answer: B. Single Responsibility Separate jobs belong in separate classes.
A new WhatsApp sender needs no change to Notify. Which principle is that?
Show answer
Answer: A. Open/Closed Open for extension with new classes, closed for modification of working code.
A ReportCard subclass throws NotSupportedException in FormatScore. Which principle does it break?
Show answer
Answer: B. Liskov Substitution Code holding a ReportCard would crash; the subclass breaks the base's promise.
How does StudentService get its repository?
Show answer
Answer: B. Through its constructor Constructor injection: whoever creates the service provides the repository.
What is the difference between dependency inversion and dependency injection?
Show answer
Answer: B. Inversion is a design principle; injection is a technique for supplying dependencies You can apply inversion with plain constructor injection and no container.
Interview questions
Questions are grouped by experience level. Click a question to see a model answer. All questions: Architecture interview questions.
Fresher (0-2 yrs)
F What does SOLID stand for? concept
Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation, and Dependency Inversion: five principles that keep object-oriented code easy to change as it grows.
F What is the Single Responsibility Principle? Give an example. concept
A class should have one reason to change. In the SMS, FeeNotice decides the wording and an
INotificationSender decides the delivery channel, so a wording change and a provider change
each touch one class.
F What is a repository? concept
An object that stores and finds entities so the rest of the code never deals with SQL, such as
IStudentRepository with GetByAdmissionNumber and Add. Its interface lives with the business
rules; implementations live in the data layer.
Mid (3-5 yrs)
M What is the Open/Closed Principle? concept
Code should be open for extension and closed for modification: add behavior by writing new
classes behind an existing abstraction, not by editing tested code. A new notification channel is
a new INotificationSender; the Notify method never changes.
M What is the Liskov Substitution Principle? Give an example of breaking it. concept
A derived class must work wherever its base class is expected, keeping the base class's
promises. A ReportCard subclass whose FormatScore throws NotSupportedException compiles, but
breaks any code holding a ReportCard.
M What is the difference between dependency inversion and dependency injection? concept
Dependency inversion is a design principle: high-level rules depend on abstractions they own, not on low-level details. Dependency injection is a technique: dependencies are passed in from outside, such as through a constructor. A DI container automates the injection.
Senior (6-9 yrs)
S A service creates its own SQL repository with new inside a method, and now an offline mode is needed. What do you change? scenario
Introduce or use a repository interface, have the service receive it through its constructor, and create the concrete repository in one composition root. The offline version becomes a new class implementing the interface, and the service can be tested with an in-memory fake.
How to answer: Name the principle (dependency inversion), show the refactoring steps safely, and mention the side benefit: unit tests without a database.
S What is a composition root? concept
The single place, usually at application startup, where concrete implementations are chosen and
wired together, such as new StudentService(new InMemoryStudentRepository()). The rest of the
code depends only on abstractions; in ASP.NET Core, Program.cs with the DI container plays this role.
How to answer: Explain why one place matters: swapping an implementation is one change, and nothing else knows concrete types. Connect it to service registration in ASP.NET Core.
Architect / Lead (10+ yrs)
A When does applying SOLID become over-engineering? design
When principles are applied without a real cost to remove: interfaces with one implementation and no tests, factories for simple objects, layers that only pass calls through. Apply a principle when you can name the change or test it makes easier; keep pure, stable code simple, like a static rules class.
How to answer: Show judgment with examples on both sides, and explain how you decide in reviews: name the expected change, weigh indirection against benefit, refactor when the need appears.
Resources
- Code for this lesson: git tag
lesson-46-solid(code repository coming soon) - Files:
src/SchoolApp.Core/Interfaces/IStudentRepository.cs,src/SchoolApp.Core/Services/StudentService.cs,src/SchoolApp.Console/InMemoryStudentRepository.cs,src/SchoolApp.Console/Program.cs,library-assignment/046-library-solid.cs - Video: coming soon
What's next
Module 7 is complete: the SMS has a full object model, DTOs, and a service that is ready for a real database. Module 8 adds the tools for working with many objects at once: collections, generics, and LINQ. Lesson 47: Collections
Want live sessions, code reviews, and interview preparation? Join the next batch at Sahasra Technologies.