Interface segregation and dependency inversion: Two principles that make testing easy
By day six of working through design patterns, you've likely felt the pain of fat interfaces and tightly coupled code. Interface segregation and dependency inversion are the two SOLID principles that directly solve this pain—they shrink your interfaces, decouple your dependencies, and turn your code into something you can actually test without wrestling a framework to the ground.
I learned this lesson the hard way. Early in my career at an e-commerce platform, I built a massive IRepository interface with 40 methods. Services that only needed to read data still had to import write methods they'd never call. When I tried to mock it for unit tests, I had to implement every single method. That's when I realized: small, focused interfaces aren't just clean—they're a testing prerequisite.
Small interfaces—Clients depend only on the methods they use
The principle of interface segregation is straightforward: never force a client to depend on methods it doesn't use. If a service only reads data, it shouldn't know about delete or update operations. This keeps your codebase honest and your test doubles lean.
Consider a real scenario from a CRM system I built. Initially, every service that touched leads received the same bloated interface:
interface ILeadRepository {
getLeadById(id: string): Promise<Lead>;
getAllLeads(): Promise<Lead[]>;
createLead(data: CreateLeadDto): Promise<Lead>;
updateLead(id: string, data: UpdateLeadDto): Promise<Lead>;
deleteLead(id: string): Promise<void>;
bulkUpdateLeads(ids: string[], data: Partial<Lead>): Promise<void>;
archiveLead(id: string): Promise<void>;
restoreLead(id: string): Promise<void>;
}A service that only fetched a lead for display didn't need deleteLead or bulkUpdateLeads. A lead notification service didn't need any write operations at all. This violates interface segregation. When testing, I had to stub methods that the service would never call, bloating my test setup.
The fix is surgical: split by actual client needs. A read-heavy service gets a small interface:
interface ILeadReader {
getLeadById(id: string): Promise<Lead>;
getAllLeads(): Promise<Lead[]>;
}A write-heavy operation gets its own:
interface ILeadWriter {
createLead(data: CreateLeadDto): Promise<Lead>;
updateLead(id: string, data: UpdateLeadDto): Promise<Lead>;
deleteLead(id: string): Promise<void>;
}Now when a service injects only what it needs, testing becomes trivial. A notification service that injects ILeadReader never has to know write methods exist:
class LeadNotificationService {
constructor(private leadReader: ILeadReader) {}
async notifyLeadUpdate(leadId: string): Promise<void> {
const lead = await this.leadReader.getLeadById(leadId);
// send notification...
}
}
// In tests:
const mockLeadReader = {
getLeadById: jest.fn().mockResolvedValue({ id: '123', name: 'Alice' })
};
const service = new LeadNotificationService(mockLeadReader);
await service.notifyLeadUpdate('123');
expect(mockLeadReader.getLeadById).toHaveBeenCalledWith('123');That's it. No boilerplate. No stubbing methods that don't matter. The interface is so small it's impossible to misuse.
Role interfaces—IReadLeads and IWriteLeads instead of one giant ILeadRepository
Naming matters. When you segregate interfaces, give them names that express intent. I don't just use ILeadReader and ILeadWriter—I think about the role each interface plays in the system.
A role interface answers: "What business capability does this dependency provide?" In a trading platform I worked on, I didn't call it IOrderData. I split it into roles:
// Role: I can fetch orders for analysis
interface IOrderQuery {
getOrderById(id: string): Promise<Order>;
getOrdersByStatus(status: OrderStatus): Promise<Order[]>;
getOrderHistory(userId: string): Promise<Order[]>;
}
// Role: I can record new orders and state changes
interface IOrderRecorder {
recordOrder(order: Order): Promise<void>;
recordStateChange(orderId: string, newState: OrderState): Promise<void>;
}
// Role: I can clear historical data (compliance requirement)
interface IOrderArchiver {
archiveOldOrders(before: Date): Promise<number>;
}Each role is tiny and focused. A service that calculates trading fees injects IOrderQuery. A service that publishes order events injects IOrderRecorder. An overnight compliance job injects IOrderArchiver.
The concrete implementation might still be a single class that implements all three roles:
class PostgresOrderRepository
implements IOrderQuery, IOrderRecorder, IOrderArchiver {
async getOrderById(id: string): Promise<Order> {
// query implementation
}
async recordOrder(order: Order): Promise<void> {
// insert implementation
}
async archiveOldOrders(before: Date): Promise<number> {
// delete implementation
}
}But from the service's perspective, it only sees the role it needs. This is powerful because:
- Services are smaller and easier to understand—they're not bloated with unused methods.
- Tests are faster—you mock only what's used.
- You can swap implementations per role. Maybe you query orders from PostgreSQL but archive to a data warehouse.
Depend on abstractions—High-level policy shouldn't import low-level details
Dependency inversion flips the typical pyramid. Normally, high-level business logic depends on low-level technical details (database, mail service, HTTP client). Inverting it means high-level policy defines the interface and low-level details implement it.
Here's the pattern I use. A service (high-level policy) defines what it needs:
interface Mailer {
send(to: string, subject: string, body: string): Promise<void>;
}
class SignupService {
constructor(private mailer: Mailer) {}
async signup(email: string, password: string): Promise<void> {
// create user...
await this.mailer.send(email, 'Welcome', 'Thanks for signing up!');
}
}SignupService doesn't know or care how mail gets sent. It depends on the abstraction Mailer. Now I can implement it in multiple ways:
// Production: AWS SES
class SesMailer implements Mailer {
constructor(private ses: SESClient) {}
async send(to: string, subject: string, body: string): Promise<void> {
await this.ses.send({
Destination: { ToAddresses: [to] },
Message: {
Subject: { Data: subject },
Body: { Text: { Data: body } }
}
});
}
}
// Development: Log to console
class LogMailer implements Mailer {
async send(to: string, subject: string, body: string): Promise<void> {
console.log(`[MAIL] To: ${to}, Subject: ${subject}`);
}
}
// Testing: Capture calls
class FakeMailer implements Mailer {
sentEmails: Array<{to: string; subject: string; body: string}> = [];
async send(to: string, subject: string, body: string): Promise<void> {
this.sentEmails.push({ to, subject, body });
}
}In production, inject SesMailer. In tests, inject FakeMailer:
const fakeMailer = new FakeMailer();
const signupService = new SignupService(fakeMailer);
await signupService.signup('alice@example.com', 'secret123');
expect(fakeMailer.sentEmails).toHaveLength(1);
expect(fakeMailer.sentEmails[0].to).toBe('alice@example.com');No mocking framework needed. The test is readable and fast because the abstraction is thin. The high-level policy (signup logic) never imports AWS SDK, SMTP libraries, or anything low-level. That's dependency inversion.
Abstractions live with the consumer—Define the interface where it's needed, implement it elsewhere
This is a critical rule I learned from Robert Martin's work. Don't put abstractions in a "shared interfaces" folder. Define them in the module that needs them.
In a ride-hailing platform I worked on, payment processing was a cross-cutting concern. The naïve approach would be to create a shared/interfaces/IPaymentProvider.ts. But that couples the entire codebase to a payment abstraction that only a few services actually need.
Instead, define the interface where it's injected:
// payments/domain/RidePaymentProcessor.ts
export interface PaymentGateway {
charge(cardToken: string, amountCents: number): Promise<ChargeResult>;
refund(chargeId: string): Promise<void>;
}
export class RidePaymentProcessor {
constructor(private gateway: PaymentGateway) {}
async processRidePayment(
rideId: string,
cardToken: string,
amountCents: number
): Promise<void> {
const result = await this.gateway.charge(cardToken, amountCents);
// record result...
}
}
// payments/infrastructure/StripePaymentGateway.ts
import { PaymentGateway } from '../domain/RidePaymentProcessor';
import Stripe from 'stripe';
export class StripePaymentGateway implements PaymentGateway {
constructor(private stripe: Stripe) {}
async charge(cardToken: string, amountCents: number): Promise<ChargeResult> {
// implementation
}
async refund(chargeId: string): Promise<void> {
// implementation
}
}The interface lives in the module that needs it. The implementation lives where the external library is used. This achieves inversion: the policy module doesn't reach out to find abstractions. Instead, implementations reach back to satisfy the contract.
Benefits:
- Your domain layer is self-contained. It explicitly declares its dependencies.
- Different modules can define their own versions of an abstraction (e.g., a billing module has its own
PaymentGateway). - Wiring is explicit—you always know where an implementation connects to its interface.
Common mistakes
Mistake 1: Segregating too early. You don't need to split interfaces the moment you write them. Wait until a second use case emerges that doesn't need all the methods. Premature segregation creates fragmentation.
Mistake 2: Forgetting that implementations can still be monolithic. A single class can implement five segregated interfaces. The point is that clients see only what they need, not that you have to split your code into five files.
Mistake 3: Putting abstractions in "shared" folders. If you create a shared/Mailer.ts, you've centralized the dependency definition. Instead, let the consumer own the interface and let implementations import it. This keeps your architecture flexible.
Mistake 4: Ignoring role-based naming. Call your interfaces ILeadReader, not ILeadRepositoryRead. The former says what the role is. The latter is just a technical prefix.
Key takeaways
- Segregate interfaces around client needs, not around all possible operations. A service should never import methods it doesn't use.
- Name interfaces by role, not by entity.
IOrderQueryis clearer thanIOrderData. Roles express intent. - Invert dependencies so high-level policy defines what it needs and low-level details provide it. High-level code never imports low-level libraries directly.
- Define abstractions in the module that needs them, implement them where the external library lives. This keeps your domain layer independent and wiring explicit.
- Testing becomes trivial when your interfaces are small and focused. You mock only what's used, and tests run fast and stay readable.






Comments
Loading comments…