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
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.
Make it yours · 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).
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 repoExample 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
Nightly run02:00, every client
Notice servicequery · text · send
OverdueNoticeService.cs
640 lines, 4 jobs
1/9Before: each night one class, the notice service, reads the database, writes the text, sends email.Navistar's SMS change edited that one class, and BMW FS emails went out cut to 160 characters.Fix: split it by job. The service only runs the steps; finding and sending get their own classes.Dependency inversion: the service asks for interfaces. Program.cs decides the real classes.Single responsibility: the service asks the repository for overdue contracts. One job each.Open/closed: each contract goes to the sender for its channel. A new channel needs no edit here.Liskov: the service calls the same Send on every sender and never checks which one it got.Interface segregation: delivery tracking is its own interface. Letters don't have to fake it.The run: three overdue contracts, each one sent by its own client's channel.
In plain words
An overdue notice is the reminder a customer gets when a payment is late. Every night a job in our lending platform finds the overdue contracts and sends one notice for each: by email for BMW FS, by SMS (text message) for Navistar Financial, and by posted letter for UDC Finance.
SOLID is five design rules for classes. They don't add features. They decide where a future change lands, so it stays small and safe.
S, single responsibility. One class, one job, so one reason to change. A notice service that reads the database, writes the text and sends email has three reasons to change. Split it.
O, open/closed. Open to new behaviour, closed to edits (bug fixes still edit a class; new behaviour arrives as new classes). A new channel is a new class that fits an existing (a contract: a list of methods a class promises to have). The old classes are not touched.
L, Liskov substitution (named after Barbara Liskov, who described it). Any class that implements an interface, or inherits from a base class (), must work wherever that interface or base class is expected. If the caller has to check which one it got, or one of them throws on input the others accept, the rule is broken.
I, interface segregation. Keep interfaces small. A class should not be forced to write methods it can't support. If you see (.NET's error for "this method has no code"), the interface asks for too much. The same rule seen from the other side: a caller should not depend on methods it never calls.
D, dependency inversion.High-level code (the code that decides what happens, like the notice service) depends on abstractions (interfaces or base classes), not on concrete classes (the real ones, like the SQL class or the email class). Something outside, usually the , picks the real class and passes it into the constructor. That is also how a test passes in a fake.
A God class is one class that does everything. It is the usual starting point for a SOLID question: the interviewer shows one and asks which rules it breaks.
A seam is a place where you can swap one class for another without editing the code that uses it. Usually it is an interface in a constructor. SOLID is mostly about putting seams where change really happens, and nowhere else.
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.
Containerset up in Program.cs
Notice serviceruns the steps
SendersEmail, SMS, Letter
Statusscreen
new(repository, senders)
1/7D. 02:00 the job starts. The container builds the service, passing a repository and 3 senders.S. The service asks the repository for overdue contracts. It never sees SQL.O. BMW-10442 wants email. The service hands it to the sender whose channel is Email.L. NAV-20917 goes to SMS with the same call. A long notice is shortened inside the SMS sender.O again. UDC-50213: letters came in release 6.2 as a new class. The service didn't change.I. 09:00 support opens the dashboard. It asks only trackers: Email and SMS. Letter isn't asked.Recap: D wired it, S split jobs, O added letters, L let any sender stand in, I kept letters honest.
The story
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.
Notice servicequery · text · email · SMS
OverdueNoticeService.cs
640 lines, 4 jobs
1/4Before: one class, the notice service, reads overdue contracts, writes the text and sends email.Three clients, three kinds of change. Every one of them lands in that same class.Earlier: Navistar's SMS change cut a shared text to 160 characters. BMW FS emails came out cut too.Fix: split the class by job. Each channel becomes its own class behind one interface.
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#.
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.
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.
ReportedIf a constructor needs too many dependencies, which SOLID principle is broken, and why is preferred with DI?
Single responsibility, usually. Eight parameters mean the class does several jobs, and each one is a different reason to change. I group the parameters by job and split the class: in our notice service, the query, the text and each channel moved out. DI didn't cause the problem; it made it visible in one line. means the field can only be set where it's declared or in the constructor. So once the container has handed a dependency in, no method can replace it or set it to null by mistake.
ReportedExplain the Liskov substitution principle with a real example of breaking it.
Any subtype must work wherever its base type or interface is expected, without the caller knowing which one it has. Our SMS sender broke it. The interface said "send this notice". The SMS class added its own rule, "only if it's under 160 characters", by throwing an exception. The service couldn't know, and one long notice stopped the whole Navistar run. The fix kept the promise: the SMS sender shortens the text itself.
ReportedExplain the D in SOLID. Is it the same as dependency injection?
No. Dependency inversion is a design rule: high-level code, like the notice service, depends on an abstraction, like , and the low-level class implements it. Dependency injection is a technique for handing that abstraction in, usually through the constructor, and the does it for you. You can follow the principle without a container, by passing the object in yourself, as a unit test does.
Answer, then open the next level. That is the order an interviewer digs in.
Reporteda candidate was asked Intervieweran interviewer asks it
ReportedIf a constructor needs too many dependencies, which SOLID principle is broken, and why is preferred with DI?
Single responsibility, usually. Eight parameters mean the class does several jobs, and each one is a different reason to change. I group the parameters by job and split the class: in our notice service, the query, the text and each channel moved out. DI didn't cause the problem; it made it visible in one line. means the field can only be set where it's declared or in the constructor. So once the container has handed a dependency in, no method can replace it or set it to null by mistake.
There's no number in the docs. Past four or five I ask whether they all serve one job. If two parameters are always used together, they're often a missing class.
Why not…Can't I just wrap them in one "aggregate" service?
Only if that wrapper is a real job with a name, like sending notices. A bag that holds five dependencies just hides the count; the class still has five reasons to change.
Where else does this come up?
The asks the same question from the DI side, and links back here.
ReportedExplain the Liskov substitution principle with a real example of breaking it.
Any subtype must work wherever its base type or interface is expected, without the caller knowing which one it has. Our SMS sender broke it. The interface said "send this notice". The SMS class added its own rule, "only if it's under 160 characters", by throwing an exception. The service couldn't know, and one long notice stopped the whole Navistar run. The fix kept the promise: the SMS sender shortens the text itself.
A Square class that inherits from Rectangle. Setting the width also sets the height, so code that sets width 5 and height 4 and expects an area of 20 gets 16. A square is a rectangle in maths, but it doesn't behave like one in code.
Rectangle r = newSquare();
r.Width = 5;
r.Height = 4; // Square sets both to 4int area = r.Width * r.Height;
// expected 20, got 16// (Width and Height are virtual;// Square overrides both)
What exactly is a subtype not allowed to do?
Ask for more than the parent did (a stricter input rule, like the 160 characters), promise less than the parent did (return something the caller doesn't expect), or throw exceptions the caller has no reason to expect. If the caller needs an is check ("is this object a Square?") to cope, it's broken.
How do you catch it before production?
One shared test that every implementation must pass, with the same inputs for each. We added one for every , including a 400-character notice.
ReportedExplain the D in SOLID. Is it the same as dependency injection?
No. Dependency inversion is a design rule: high-level code, like the notice service, depends on an abstraction, like , and the low-level class implements it. Dependency injection is a technique for handing that abstraction in, usually through the constructor, and the does it for you. You can follow the principle without a container, by passing the object in yourself, as a unit test does.
Without it, the notice service would depend on the email class. With it, both depend on , and the interface belongs to the high-level side, next to the service. The email class now points up at it: the arrow is turned around.
And inversion of control?
The broad idea that a framework, not your class, controls something, here how parts are built. The covers it.
Open/closed: does it mean you never edit a class again?
No. Bug fixes still edit it. It means: for the kind of change you expect again and again, like a new channel or a new tax regime, the class has an extension point, so the change is a new class. Usually that's an interface with one class per variation, called the strategy pattern. I don't build extension points in advance; I add one when the second variation arrives.
Which principle does a switch on client name break?
Open/closed. Every new client means editing that switch in shared code and retesting the others. One class per variation, picked by a key or from the container, closes it.
Single responsibility: does it mean one method per class?
No. It means one reason to change: one person or rule that asks for edits. A repository can have ten methods and one responsibility, data access. A 40-line class that formats the email and decides who is overdue has two.
How do you spot it in a code review?
A long constructor, a name like Manager, Helper or Processor, unit tests that need lots of setup, and a file that shows up in commits for unrelated tickets.
Aren't interface segregation and single responsibility the same thing?
Close, but they look from different sides. Single responsibility is about a class: how many reasons it has to change. Interface segregation is about the caller: it shouldn't depend on methods it never calls. The dashboard only needs a status, so it shouldn't depend on an interface that also has Send.
Why not…When is SOLID overkill?
When the seams serve nobody. A small class with one implementation, no test that needs a fake, and nothing that varies doesn't need an interface. Interfaces everywhere make a reader jump through files to find the one real class. I split along real reasons to change, and leave simple code simple.
Production bugs, and how to tell them
Each one: what you see, why, the fix in code, and the line to say.
A test run emailed real BMW FS customers
Complaint, BMW FS customer care, 14:40:
"Overdue? I paid on the 1st."
312 customers emailed at 14:20, all from
the UAT environment.
(no exception, no error log)
Cause
built its own with .'s Program.cs did register , a sender that only writes a log line, but the service never asked the container, so it used the real one. That week had been refreshed with a copy of production data, and the customer email addresses had not been masked (swapped for fake ones).
Fix
Dependency inversion: the service asks for in its constructor and never creates one. Program.cs registers the real in production and everywhere else. A unit test passes a fake sender and counts what was sent. The team also added masking of contact details to every refresh.
Diagram · step 1's Program.cs registers a sender that only writes a log line. The right idea.
UAT test runcopy of prod data
Containersender: log only
Before · Program.cs · line 1 · 1 of 1builder.Environment says which environment the job runs in. In , register the safe sender. Correct, but nothing used it.
Say it
The class created its email sender itself with new, so the test environment's settings were ignored and real customers got emails. We inverted the dependency: the class asks for an interface, and each environment decides what's behind it. A unit test proves the service only sends through what it's given.
UDC's delivery dashboard crashed on letters
System.NotImplementedException: The method or
operation is not implemented.
at LetterSender.GetStatus(String contractNo)
at DeliveryDashboard.Load(String contractNo)
Cause
had grown a third method, , for the support dashboard. Email and SMS can report delivery; a posted letter can't. had to implement it anyway, so it threw . The dashboard loops over every sender, so for every contract it reached the letter sender and the whole screen failed.
Fix
Interface segregation: moves to its own interface, . Email and SMS implement both interfaces; letters implement only . The dashboard asks for trackers only, so letters are never asked, and with the generic AddTransient<IDeliveryTracker, LetterSender>() the compiler won't let anyone register a letter sender as a tracker.
Diagram · step 1Before: one big interface. Every sender must have Send and GetStatus, letters too.
Emailcan report
SMScan report
LetterGetStatus: throws
Before · INoticeSender.cs · line 5 · 1 of 1Added for the dashboard. Now every sender must have it, whether it can do it or not.
Say it
The interface asked more of the letter sender than it could do, so it threw not implemented, and the dashboard crashed. We split the interface by what each caller needs: one for sending, one for tracking. Now a class only promises what it can keep.
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
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.
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.
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.
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.
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.
Nightly runNavistar: 2,311 due
Notice serviceloops the contracts
SMS sendermax 160 characters
Notices sent: 412 of 2,311
Job status: Failed
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
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.
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.
Run it: the loop stops at the long text, and the third text is never sent.
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."
SourcesSourced: 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
Don't saySingle responsibility means a class does one thing, or has one method.SayOne reason to change. A class can have many methods if one rule or person drives every change to it.
Don't sayOpen/closed means you never modify existing code.SayBug fixes still edit it. For changes you expect again and again, add new classes behind an extension point.
Don't sayDependency inversion is the same as dependency injection.SayInversion is the rule: depend on abstractions. Injection is one technique for supplying them.
Don't sayLiskov is only about inheritance.SayIt applies to every implementation of an interface too. Our SMS sender broke it without any base class.
Don't sayA SOLID codebase has an interface for every class.SayAdd an interface where behaviour varies or a test needs a fake. Otherwise it's an extra file to read.
Traps
What should you say instead? Tap to check.
Don't saySingle responsibility means a class does one thing, or has one method.
Say instead
One reason to change. A class can have many methods if one rule or person drives every change to it.
Don't sayOpen/closed means you never modify existing code.
Say instead
Bug fixes still edit it. For changes you expect again and again, add new classes behind an extension point.
Don't sayDependency inversion is the same as dependency injection.
Say instead
Inversion is the rule: depend on abstractions. Injection is one technique for supplying them.
Don't sayLiskov is only about inheritance.
Say instead
It applies to every implementation of an interface too. Our SMS sender broke it without any base class.
Don't sayA SOLID codebase has an interface for every class.
Say instead
Add an interface where behaviour varies or a test needs a fake. Otherwise it's an extra file to read.
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.
You are handed this class. The comments stand for about 200 lines of real code.
Lending.sln · where this file sits
Stuck? Hints
Count the jobs. Who would ask for each one to change?
Which lines create a real database or email object with new?
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.
Name the three jobs. Each one becomes an interface.
Which class decides the order of the steps?
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.
A sketch of the kind of file you get. Country names and rates are invented.
Lending.sln · where this file sits
Stuck? Hints
What changes when a new country arrives? Make that a new class.
What do the three regimes have in common? That's the interface.
Progressive: tax each slab only on the part of the salary inside it.
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.