Skip to main content

SOLID Principles on Real Code: A Review of SchoolApp – C# | Lesson 46

Published / updated

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​

LetterPrincipleIn one lineIn SchoolApp
SSingle ResponsibilityA class has one reason to changeFeeNotice says what; INotificationSender says how (Lesson 41)
OOpen/ClosedAdd behaviour by adding code, not editing itA new sender is a new class; Notify never changes
LLiskov SubstitutionA derived class must work wherever its base is expectedAny ReportCard works with FormatLine
IInterface SegregationMany small interfaces beat one big oneISoftDeletable has three members, not thirty
DDependency InversionDepend on abstractions, not on detailsStudentService 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:

src/SchoolApp.Core/Interfaces/IStudentRepository.cs
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​

src/SchoolApp.Core/Services/StudentService.cs
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:

src/SchoolApp.Console/InMemoryStudentRepository.cs
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
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"}");
Output
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:

TermWhat it isHere
Dependency inversionA design principle: depend on abstractionsStudentService knows only IStudentRepository
Dependency injectionA technique: pass dependencies in from outsideThe constructor receives the repository
DI containerA tool that does the injecting automaticallyASP.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​

  1. Reciting definitions without examples. Fix: for each letter, know one place in your own code where it applies.
  2. A service that creates its own repository with new. It is welded to one implementation. Fix: receive it through the constructor.
  3. Huge "manager" interfaces. Every fake must implement everything. Fix: small interfaces per need.
  4. Subclasses that throw NotSupportedException. They break Liskov. Fix: rethink the hierarchy, or use separate interfaces.
  5. Applying every principle everywhere. Indirection without benefit. Fix: name the cost a principle removes before applying it.

In real projects​

From 16 years of .NET 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 IStudentRepository with 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:

Output
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-assignment/046-library-solid.cs
// 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​

  1. Which principle says a class should have one reason to change?

    1. Open/Closed
    2. Single Responsibility
    3. Liskov Substitution
    4. Interface Segregation
    Show answer

    Answer: B. Single Responsibility Separate jobs belong in separate classes.

  2. A new WhatsApp sender needs no change to Notify. Which principle is that?

    1. Open/Closed
    2. Dependency Inversion
    3. Single Responsibility
    4. Liskov Substitution
    Show answer

    Answer: A. Open/Closed Open for extension with new classes, closed for modification of working code.

  3. A ReportCard subclass throws NotSupportedException in FormatScore. Which principle does it break?

    1. Interface Segregation
    2. Liskov Substitution
    3. Single Responsibility
    4. None
    Show answer

    Answer: B. Liskov Substitution Code holding a ReportCard would crash; the subclass breaks the base's promise.

  4. How does StudentService get its repository?

    1. It creates one with new
    2. Through its constructor
    3. From a static field
    4. From SchoolRules
    Show answer

    Answer: B. Through its constructor Constructor injection: whoever creates the service provides the repository.

  5. What is the difference between dependency inversion and dependency injection?

    1. They are the same
    2. Inversion is a design principle; injection is a technique for supplying dependencies
    3. Injection is a principle; inversion is a tool
    4. Inversion needs a container
    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.

See the example · Permalink

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.

See the example · Permalink

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.

See the example · Permalink

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.

See the example · Permalink

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.

See the example · Permalink

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.

See the example · Permalink

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.

See the example · Permalink

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.

See the example · Permalink

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.

See the example · Permalink

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.