← Home
.NETOOP and C# basicsReported by candidates

Explain SOLID with a real example. Which principle does a given class break?

15 reports in the question index (8 linked below), mostly first technical rounds: Capgemini (3), Infosys (2), Cognizant, Accenture, EPAM, TCS, Synechron and others, plus a take-home from a US payroll company. Usually asked as "explain SOLID", then "which principle does this class break?".

Say this first · about 30 seconds
Make it yours · Navistar Financial truck deal checks (invented example)
The code, simplified

Invented to show the shape, in C#. Tap Next to walk one deal: price 150,000, down payment 12,000, 420,000 miles.

Navistar.sln · where these 5 files sit
  • Deals/
Program.cs · line 1 · 1 of 1builder.Services: the container's list of registrations, filled at start-up. Transient: a new object each time one is asked for. Two rules registered for one interface; the container hands both to .

Fix later in your repo Example written for this card: invented, not from a real codebase.

Say it out loud, then check

Show the 30-second answer
  • SOLID is five design principles for object-oriented code, set out by Robert Martin. They don't add features; they decide where a future change lands, so a change for one client doesn't break another and each class can be tested on its own.
  • Our example on the BMW FS lending platform is the nightly overdue-notice job. It started as one class that read the database, wrote the text and sent the email. Single responsibility split it by job, so the email code only changes when email changes.
  • When Navistar wanted the notice by text message and UDC wanted posted letters, each channel became a new class behind one notice-sender interface. That's open-closed: we added classes instead of editing the one BMW FS already relied on.
  • Liskov substitution says every sender has to work wherever the interface is expected. Ours broke it once: the text-message sender threw on long messages, a stricter rule than the interface promised, and one long notice stopped the whole Navistar run.
  • Interface segregation kept interfaces small: letters can't report delivery, so delivery tracking became its own interface instead of a method that throws not implemented. And dependency inversion means the notice service asks for interfaces, not real classes. The DI container supplies the real ones, and a unit test hands in a fake.
Check your answer: tick what you said

You said 0 of 8

Then add your example: Navistar Financial truck deal checks (invented example)
  • Situation: before a Navistar Financial truck deal is approved, the deal API behind the dealer portal runs a set of checks, such as a big enough down payment and a mileage limit for used trucks. They all lived in one 300-line method full of if statements, and every new check from the credit team meant editing it and retesting all the others.
  • What I did: one small class per check, each implementing an interface with one method, Check. gets every rule from the DI container in its constructor and runs them in a loop, collecting the problems.
  • Result: when the credit team added a new check, it was one new class and one registration line, reviewed on its own. The approver and the old checks were not touched, and each check has its own small test.
  • What I'd say: each rule has one job (single responsibility), new rules are new classes (open/closed), and the approver depends on the interface, not the rules (dependency inversion).

See it

1/9Before: each night one class, the notice service, reads the database, writes the text, sends email.

In plain words

The senior answer · about 2 minutes
What SOLID is for, in my words
  • I see SOLID as five rules about where a change lands. On our lending platform the same code serves BMW FS, Navistar Financial and UDC Finance, so the real risk is a change for one client breaking another. SOLID is how I keep each change inside one small class.
  • Single responsibility means one reason to change, not one method. Our overdue-notice service used to run the database query, write the text and send the email. Three different people asked for changes to it, for three different reasons. Now each job is its own class, and the service only runs the steps in order.
The other four, on the same job
  • Open/closed: when UDC Finance wanted posted letters, that was a new class and one registration line in Program.cs. Nobody edited the service, so the BMW FS email path couldn't break. New behaviour came in as new code.
  • Liskov is the one people skip. Every sender has to accept any notice the service hands it. Our once threw on texts over 160 characters. That was a stricter rule than the interface promised, and one long notice stopped the whole Navistar run. The production incident further down is that night.
  • Interface segregation: when letters had to implement a delivery-status method they couldn't support, the method threw , and the support dashboard crashed. Moving that method to its own interface meant the compiler stopped it from happening again.
  • Dependency inversion: the service depends on and , not on the email class or the SQL class. The container picks the real ones. So in we register a sender that only writes a log line, and a unit test passes in a fake.
Where I stop
  • SOLID is a means, not a score. I don't put an interface on every class. If there's one implementation, nothing varies and no test needs a fake, the interface is just an extra file to read. I add it when the second channel, or the first test, needs it.
  • Splitting too far is a smell too. Twenty one-line classes are harder to follow than one clear class. I split along reasons to change: the people or rules that ask for edits.
In production
  • In code review I look for four signs: a constructor with many dependencies, a switch on a client name or a type in shared code, , and business code that creates an infrastructure class with .
  • What I left behind after the text-message incident: one shared test that every must pass, including a 400-character notice.

One night of notices, letter by letter

The nightly notice job, in time order. Each step names the SOLID letter at work.

1/7D. 02:00 the job starts. The container builds the service, passing a repository and 3 senders.

The story

  1. Before

    Every night, a job in our lending platform finds contracts with a late payment and sends each customer an overdue notice. It started for BMW FS only: one class, , read the database, wrote the text and sent the email. Then Navistar Financial wanted the notice by SMS, and UDC Finance by posted letter.

    1/4Before: one class, the notice service, reads overdue contracts, writes the text and sends email.

  2. Why it had to change

    A class with many jobs has many reasons to change. Every change for one client is a risk for the others, and the class can't be tested without a real database and a real email server. Nothing in the compiler warns you: one big class is perfectly legal C#.

  3. How it works now

    SOLID is five design rules, collected by Robert C. Martin, for exactly this. Together they say: give each class one job; add new behaviour as new classes; make every implementation keep its interface's promise; keep interfaces small; and depend on interfaces, not on concrete classes. The animation below walks each letter on this notice job, in the order the code runs.

  4. What we gained

    Adding UDC letters became one new class and one line in Program.cs (the file where the app is wired together at start-up). Nobody touched the BMW FS email code, and each piece can be tested alone with a fake: a stand-in class used in a test instead of the real database or email server. The rest of the card shows what happens when one of the five is broken.

In the lending API

The notice service after the split. Each marked line is one SOLID letter at work; the diagram above shows them running.

Only the key lines are shown; Deep walks the whole file.

Lending.sln · where this file sits
  • src/Application/Notices/
⋯ 13 lines in Deep⋯ 1 line in Deep
OverdueNoticeService.cs · line 8 · 1 of 4D, dependency inversion. The constructor asks only for interfaces. It never says which class sends or which database is read; Program.cs decides.

The code, file by file

The nightly notice job after the split, in the order it runs. Tap Next to walk the diagram with the code. Everything here was built and run on .NET 10; the output is the last step of the diagram.

Lending.sln · where these 5 files sit
  • src/
    • Jobs/
    • Application/Notices/
    • Infrastructure/Notices/
Program.cs · line 1 · 1 of 6Program.cs runs first when the nightly job starts. builder collects the list of classes the app can build ().

What they ask next

The follow-ups interviews really ask. Answer out loud, then tap to check.

Follow-up tree

Answer, then open the next level. That is the order an interviewer digs in.

Reporteda candidate was asked Intervieweran interviewer asks it

Production bugs, and how to tell them

Each one: what you see, why, the fix in code, and the line to say.

Production incident: One long text stopped the Navistar overdue run

P2 · serious, has a workaroundNavistar Financiallatent: hidden for weeksNightly overdue-notice job, SMS channel

Composed, not a real event at Navistar Financial. The mechanism is real C#, checked with a run on .NET 10: an implementation that throws on input its interface accepts ends the caller's loop at the first such input, and every item after it is skipped. Times, counts, names, ticket and PR numbers are composed.

The story in 5 lines
  1. On Tue 6 Oct, Navistar Financial's collections team took calls from truck customers who were charged a late fee but never got the overdue text that should have warned them.
  2. The nightly job had sent 412 of 2,311 Navistar notices, then stopped. Its failure alert went to a shared inbox nobody watched at night.
  3. Release 6.4 had added the dealer's name to the notice text. threw on any text over 160 characters, and notice 413 was the first long one. The exception ended the whole run.
  4. That is a Liskov break: promised to send any notice, and the SMS class quietly added its own limit. The service had no way to know.
  5. The fix: shortens a long text itself, so it keeps the interface's promise. One shared test now runs against every sender, and the job alerts when it fails.
Who's who (4 people)
You
.NET developer on the vendor team. Finds the cause, writes the fix.
Tech lead
Vendor senior. Reviews every change, runs the postmortem.
L2 pod
Vendor support. First look, runs the log queries, reruns jobs.
Navistar collections lead
Owns overdue accounts. Decides what customers are told.

Tap a step. The map lights the place where it happens.

1/4 Detect

Detect: customers got late fees with no warning text. The run stopped after notice 412.

Tue 6 Oct, 08:30 CT, US Central time (19:00 IST, India time)Navistar collections lead, then the L2 pod

  • What: Navistar's collections team takes calls from truck customers: "you charged me a late fee, but I never got a reminder". Navistar sends the overdue reminder by SMS the night a payment becomes overdue.
  • Why it hid: the job failed at 02:07, and its failure alert went to a shared inbox nobody reads at night. The Navistar contracts came late in the job's list, so the BMW FS and UDC notices before them had already gone out and their customers were fine.
  • How we saw it: ran the query below in , the query language of (Azure's store for the app's logs and errors). The run summary said 412 of 2,311 Navistar notices sent, and one exception sat right after the last success.
App Insights: the notice job's exceptions, last night
exceptions
| where timestamp > ago(12h)
| where cloud_RoleName == "notice-job"
| project timestamp, type, outerMessage
| order by timestamp asc
In theory

A failed nightly job pages the engineer within minutes.

In practice

The alert went by email to a shared inbox. Six hours passed before a customer told us.

Try it yourself · 10 minutes, on your Mac
  1. dotnet new console -n LiskovLab. Add an interface with one method, Send(string text), and two classes: one prints the text, the other throws when the text is longer than 20 characters.
  2. In Program.cs, put both in a list and loop over three texts, calling Send on the right one, with the long text in the middle.
  3. Run it: the loop stops at the long text, and the third text is never sent.
  4. Change the second class to shorten the text instead of throwing, and run again: all three are sent, and the loop never had to know which class it held.
You should see

A class that throws where its siblings don't makes every caller fragile. Keep the interface's promise inside the class.

The senior move

"Every implementation has to keep its interface's promise. If one needs a special rule, it handles that rule inside itself, and one shared test proves every implementation behaves the same."

Sources Sourced: one SMS holds 160 characters in the standard GSM 7-bit alphabet (70 with accents or emoji; longer texts go as linked segments); the Liskov substitution principle is Barbara Liskov's (Liskov and Wing, 1994: a subtype must keep the promises of its parent type). An exception thrown inside a loop with no catch ends the loop, checked with a run on .NET 10. Composed: Navistar Financial's systems, dates, counts, names, ticket and numbers, and the (standard columns).

Traps

Traps

What should you say instead? Tap to check.

Live coding

Easy first, then harder. Set a timer and type it before you look.

Task 1

Reportedeasy · 10 minutes

Here is a class from the BMW FS statement job. Which SOLID principles does it break, and how would you fix it? Sketch the fixed class.

Question index: "which principle is violated in a given class?" (Capgemini, Infosys reports)
You are given

You are handed this class. The comments stand for about 200 lines of real code.

Lending.sln · where this file sits
Stuck? Hints
  1. Count the jobs. Who would ask for each one to change?
  2. Which lines create a real database or email object with new?
  3. How would a unit test run this without a database?
Show a solution
Lending.sln · where this file sits
StatementService.cs · line 7 · 1 of 3Each job is a separate interface, handed in. No connection string, no of a real database or email client.
What the interviewer checks
  • Names single responsibility: four jobs (read, build PDF, email, audit), so four reasons to change. The audit write can sit inside the mailer, so three interfaces are enough.
  • Names dependency inversion: it creates the SQL connection and the email client itself, and holds the connection string.
  • The fix: one small interface per job, passed in through the constructor, stored in fields.
  • Can say how to test the fixed class: pass fakes, no database.
  • Senior extra: doesn't split further than the jobs; the audit row is written with the send (in the mailer, or through an outbox table) so it records what was really sent.

Task 2

Question index: Mustmedium · 25 minutes

Refactor this God class. validates the request, saves the loan with raw SQL and sends a welcome email. Split it into SOLID pieces behind interfaces, and prove with a test that a bad request saves nothing and sends nothing.

Question index: refactor a God class (derived from the SOLID question, no report of this exact task)
You are given

The class as you are handed it.

Lending.sln · where this file sits
Stuck? Hints
  1. Name the three jobs. Each one becomes an interface.
  2. Which class decides the order of the steps?
  3. In the test, how do you know nothing was saved or sent?
Show a solution
Lending.sln · where these 3 files sit
  • src/Application/Loans/
  • tests/Application.UnitTests/
LoanService.cs · line 7 · 1 of 5Three jobs, three interfaces, handed in by the container (or by a test).
What the interviewer checks
  • Three interfaces: validation, saving, notifying. only runs the steps in order.
  • Constructor injection with fields; no of SQL or email classes in the service.
  • A test with fakes: a bad request throws, and the fake repository and notifier recorded nothing.
  • Mentions the raw SQL should be a parameterized query (or EF Core) inside the repository.
  • Senior extra: the email should not be lost if the save succeeds and the email server is down: an outbox or a queue, not a direct send.

Task 3

Reportedmedium · 60 to 90 minutes (take-home)

Take-home: a single-file payroll tax calculator, deliberately poor. Each country uses one of three tax regimes: a flat amount, a percentage, or progressive slabs. The code has hard-coded SQL, a hand-written decimal parser and one big loop with if/else per country. Fix the bugs and improve it using SOLID.

US payroll/HR company: online test, reported on r/dotnet (2026)
You are given

A sketch of the kind of file you get. Country names and rates are invented.

Lending.sln · where this file sits
Stuck? Hints
  1. What changes when a new country arrives? Make that a new class.
  2. What do the three regimes have in common? That's the interface.
  3. Progressive: tax each slab only on the part of the salary inside it.
  4. Where should parsing and saving live, so the tax code never sees them?
Show a solution
Lending.sln · where these 3 files sit
  • src/Payroll/
PayrollCalculator.cs · line 4 · 1 of 3Country → its rule. Filled from configuration at start-up. A new country is a new entry; the calculator is never edited (open/closed).
What the interviewer checks
  • An interface () with three classes: flat, percentage, progressive. Adding a country is configuration, not an edit to the calculator.
  • Progressive tax correct at the slab edges, with tests (50,000 gives 5,000 tax with these slabs; 8,000 gives none).
  • Money as decimal, parsed with decimal.Parse and the invariant culture instead of a hand-written parser.
  • SQL moved to a repository and parameterized: no SQL built by joining strings (SQL injection: an attacker's text becoming part of your SQL).
  • Senior extra: says which tests they wrote first and why, and what they left out given the time box.
With AI tools

Interviewers now ask how you check AI-written code, and SOLID is where assistants are weakest in both directions. Asked to "make this SOLID", they tend to add an interface for every class, each with one implementation, which is more files and no benefit. Asked to add a feature, they tend to add one more branch to the existing switch, which breaks open/closed. In review I check two things: that each new interface has a second implementation or a test fake, and that a new channel or rule arrived as a new class, not as an edit to shared code.

Glossary: every name, one line each →

My notes

Paste the NOTES block from your doubt chat here. The grader never reads notes; the coach reads "Still unclear".

Read moreWhere it was asked