Interface Segregation in Practice: Small Interfaces, Fewer Mocks
Use small, focused interfaces and role-based abstractions to reduce test setup boilerplate and improve code flexibility.
Your test file has ballooned. A single test now requires setting up a mock with 30 methods, most of which the code doesn’t even call. The test is brittle: if the interface gains a new method, every mock breaks. And debugging a test failure means wading through mountains of setup to find the one method that actually matters.
This is the cost of fat interfaces. Interface Segregation Principle says: don’t force clients to depend on methods they don’t use. Instead of one bloated interface, create many small ones, each modeling one responsibility. Tests become lighter, code becomes more flexible, and the intent becomes clearer.
This article shows how to recognize fat interfaces, refactor them into role-based abstractions, and use TypeScript’s type utilities to keep tests readable.
The Problem: Fat Interfaces and Giant Mocks
Imagine an order processing service:
interface DataService {
getOrder(id: string): Promise<Order>;
updateOrder(order: Order): Promise<void>;
getCustomer(id: string): Promise<Customer>;
updateCustomer(customer: Customer): Promise<void>;
getProduct(id: string): Promise<Product>;
logTransaction(orderId: string, message: string): Promise<void>;
sendEmail(to: string, subject: string, body: string): Promise<void>;
createInvoice(orderId: string): Promise<void>;
updateInventory(productId: string, quantity: number): Promise<void>;
getWarehouse(id: string): Promise<Warehouse>;
}
class OrderProcessor {
constructor(private dataService: DataService) {}
async process(orderId: string) {
const order = await this.dataService.getOrder(orderId);
const customer = await this.dataService.getCustomer(order.customerId);
const product = await this.dataService.getProduct(order.productId);
// Actually uses only 3 methods, but depends on 10
// ...
}
}
Writing a test requires mocking all 10 methods, even if process() only calls 3:
test("processes order correctly", async () => {
const mockDataService: DataService = {
getOrder: vi.fn().mockResolvedValue({ id: "1", customerId: "2", productId: "3" }),
updateOrder: vi.fn(),
getCustomer: vi.fn().mockResolvedValue({ id: "2", name: "Alice" }),
updateCustomer: vi.fn(),
getProduct: vi.fn().mockResolvedValue({ id: "3", price: 100 }),
logTransaction: vi.fn(),
sendEmail: vi.fn(),
createInvoice: vi.fn(),
updateInventory: vi.fn(),
getWarehouse: vi.fn()
};
const processor = new OrderProcessor(mockDataService);
await processor.process("1");
expect(mockDataService.getOrder).toHaveBeenCalledWith("1");
});
The test setup is verbose and brittle. Every new method added to DataService breaks this test, even if OrderProcessor doesn’t care.
Interface Segregation: Role-Based Abstraction
Instead of one fat interface, create focused ones based on what each client needs:
// OrderProcessor needs only to read orders and customers
interface OrderRepository {
getOrder(id: string): Promise<Order>;
getCustomer(id: string): Promise<Customer>;
}
// OrderProcessor also needs to read products
interface ProductRepository {
getProduct(id: string): Promise<Product>;
}
class OrderProcessor {
constructor(
private orderRepo: OrderRepository,
private productRepo: ProductRepository
) {}
async process(orderId: string) {
const order = await this.orderRepo.getOrder(orderId);
const customer = await this.orderRepo.getCustomer(order.customerId);
const product = await this.productRepo.getProduct(order.productId);
// ...
}
}
Now the test is simpler:
test("processes order correctly", async () => {
const mockOrderRepo: OrderRepository = {
getOrder: vi.fn().mockResolvedValue({ id: "1", customerId: "2", productId: "3" }),
getCustomer: vi.fn().mockResolvedValue({ id: "2", name: "Alice" })
};
const mockProductRepo: ProductRepository = {
getProduct: vi.fn().mockResolvedValue({ id: "3", price: 100 })
};
const processor = new OrderProcessor(mockOrderRepo, mockProductRepo);
await processor.process("1");
expect(mockOrderRepo.getOrder).toHaveBeenCalledWith("1");
});
Each mock is small and focused. If a new method is added to DataService, unrelated tests don’t break.
Role-Based Naming
The names matter. DataService is generic and vague. OrderRepository and ProductRepository describe the role: order storage, product storage. Each role has a single responsibility, making the code’s intent clearer.
In a real system, you might have:
interface OrderRepository {
getOrder(id: string): Promise<Order>;
listOrders(customerId: string): Promise<Order[]>;
}
interface OrderWriter {
updateOrder(order: Order): Promise<void>;
markOrderProcessed(orderId: string): Promise<void>;
}
interface OrderNotifier {
notifyCustomer(orderId: string, message: string): Promise<void>;
}
interface InventoryManager {
reserveItems(productId: string, quantity: number): Promise<void>;
releaseReserved(productId: string, quantity: number): Promise<void>;
}
class OrderProcessor {
constructor(
private orderRepo: OrderRepository,
private orderWriter: OrderWriter,
private notifier: OrderNotifier,
private inventory: InventoryManager
) {}
async process(orderId: string) {
const order = await this.orderRepo.getOrder(orderId);
await this.inventory.reserveItems(order.productId, order.quantity);
await this.orderWriter.markOrderProcessed(orderId);
await this.notifier.notifyCustomer(orderId, "Order processed");
}
}
Each dependency is a small interface addressing one concern. Tests mock only what’s needed.
TypeScript Utilities: Pick and Partial
For legacy code or complex refactors, use TypeScript’s type utilities to carve out smaller interfaces from fat ones:
// Start with a fat interface (from legacy code)
interface DataService {
getOrder(id: string): Promise<Order>;
updateOrder(order: Order): Promise<void>;
getCustomer(id: string): Promise<Customer>;
updateCustomer(customer: Customer): Promise<void>;
getProduct(id: string): Promise<Product>;
// ... 10 more methods
}
// Extract just the methods a specific client needs using Pick
type OrderReaderService = Pick<DataService, "getOrder" | "getCustomer">;
class OrderReader {
constructor(private service: OrderReaderService) {}
async readOrder(orderId: string) {
return await this.service.getOrder(orderId);
}
}
Pick lets you create a small interface from a large one without refactoring the base interface. The test is still simple:
test("reads order", async () => {
const mockService: OrderReaderService = {
getOrder: vi.fn().mockResolvedValue({ id: "1" }),
getCustomer: vi.fn()
};
const reader = new OrderReader(mockService);
await reader.readOrder("1");
expect(mockService.getOrder).toHaveBeenCalledWith("1");
});
Use Partial to make all fields optional, which is useful for test mocks that only implement a subset:
type PartialDataService = Partial<DataService>;
const mockService: PartialDataService = {
getOrder: vi.fn().mockResolvedValue({ id: "1" })
// No need to implement updateOrder, getCustomer, etc.
};
Dependency Injection Over Global State
Segregated interfaces work best with explicit dependency injection. Compare:
Without segregation (global, hard to test):
// Somewhere in the codebase
global.dataService = new DataService();
class OrderProcessor {
async process(orderId: string) {
const order = await global.dataService.getOrder(orderId);
// Can't mock; must mock the global
}
}
With segregation (explicit, testable):
class OrderProcessor {
constructor(
private orderRepo: OrderRepository,
private productRepo: ProductRepository
) {}
async process(orderId: string) {
const order = await this.orderRepo.getOrder(orderId);
// Each dependency is explicit; mocking is trivial
}
}
Explicit dependencies make the code’s requirements visible. You can see at a glance what OrderProcessor depends on. Global state hides dependencies and makes testing harder.
Refactoring a Fat Interface
Here’s a step-by-step approach to refactor an existing fat interface:
Step 1: List all clients and their needs.
OrderProcessor needs: getOrder, getCustomer, getProduct
InvoiceGenerator needs: getOrder, getCustomer, updateInvoice
InventorySync needs: getProduct, updateInventory
Step 2: Group by role.
OrderRepository: getOrder, getCustomer
ProductRepository: getProduct
InvoiceService: updateInvoice
InventoryManager: updateInventory
Step 3: Create small interfaces.
interface OrderRepository {
getOrder(id: string): Promise<Order>;
getCustomer(id: string): Promise<Customer>;
}
interface ProductRepository {
getProduct(id: string): Promise<Product>;
}
interface InvoiceService {
updateInvoice(invoice: Invoice): Promise<void>;
}
interface InventoryManager {
updateInventory(productId: string, quantity: number): Promise<void>;
}
Step 4: Update clients to depend on small interfaces.
class OrderProcessor {
constructor(
private orderRepo: OrderRepository,
private productRepo: ProductRepository
) {}
// ...
}
class InvoiceGenerator {
constructor(
private orderRepo: OrderRepository,
private invoiceService: InvoiceService
) {}
// ...
}
Step 5: Update the implementing class to still implement all interfaces.
class PostgresDataService
implements OrderRepository, ProductRepository, InvoiceService, InventoryManager
{
async getOrder(id: string): Promise<Order> { /* ... */ }
async getCustomer(id: string): Promise<Customer> { /* ... */ }
async getProduct(id: string): Promise<Product> { /* ... */ }
async updateInvoice(invoice: Invoice): Promise<void> { /* ... */ }
async updateInventory(productId: string, quantity: number): Promise<void> { /* ... */ }
}
The concrete class still implements everything, but each client depends only on what it needs. Tests are lighter, and the code is more honest about dependencies.
Real-World Example: Node.js Request Handler
// Before: fat interface
interface Services {
userDb: any;
orderDb: any;
paymentGateway: any;
emailService: any;
logger: any;
cache: any;
}
app.post("/orders", async (req, res, services) => {
// Depends on 6 services, actually uses 3
const user = await services.userDb.get(req.userId);
const order = await services.orderDb.create({ userId: user.id, amount: req.body.amount });
await services.paymentGateway.charge(user.id, order.amount);
// Doesn't use emailService, logger, cache
});
// After: segregated dependencies
interface CreateOrderDependencies {
userRepository: { get(id: string): Promise<User> };
orderRepository: { create(data: any): Promise<Order> };
paymentGateway: { charge(userId: string, amount: number): Promise<void> };
}
app.post("/orders", async (req, res, deps: CreateOrderDependencies) => {
const user = await deps.userRepository.get(req.userId);
const order = await deps.orderRepository.create({ userId: user.id, amount: req.body.amount });
await deps.paymentGateway.charge(user.id, order.amount);
});
// Test is now focused
test("creates order and charges payment", async () => {
const deps: CreateOrderDependencies = {
userRepository: { get: vi.fn().mockResolvedValue({ id: "123" }) },
orderRepository: { create: vi.fn().mockResolvedValue({ id: "ord1" }) },
paymentGateway: { charge: vi.fn() }
};
await handler(req, res, deps);
expect(deps.paymentGateway.charge).toHaveBeenCalledWith("123", 100);
});
The Stability Benefit
Segregated interfaces also protect against changes. Imagine your DataService gains a new method: deleteAccount(). If OrderProcessor depends on the full DataService, the type system requires you to handle the new method in mocks, even if the feature is unrelated.
With segregated interfaces, unrelated clients are unaffected:
// A change to ProductRepository doesn't break OrderProcessor's tests
interface ProductRepository {
getProduct(id: string): Promise<Product>;
updateProductImage(id: string, imageUrl: string): Promise<void>; // New method
}
// OrderProcessor still depends only on OrderRepository and ProductRepository
class OrderProcessor {
constructor(
private orderRepo: OrderRepository,
private productRepo: ProductRepository
) {}
// No test changes needed; OrderProcessor doesn't care about updateProductImage
}
This is the stability aspect of Interface Segregation: clients are stable against changes in unrelated parts of the interface.
Common Mistakes
Mistake 1: Creating too many tiny interfaces. One method per interface is excessive.
// Too much
interface GetOrder {
getOrder(id: string): Promise<Order>;
}
interface GetCustomer {
getCustomer(id: string): Promise<Customer>;
}
// Better: group by role
interface OrderRepository {
getOrder(id: string): Promise<Order>;
getCustomer(id: string): Promise<Customer>;
}
A good rule: an interface represents one “role” or “capability.” If a client uses both getOrder and getCustomer for order processing, they belong in the same interface.
Mistake 2: Misaligned interfaces and reality. Defining an interface that no one implements.
// This interface is aspirational; nothing implements it
interface OrderNotificationService {
notifyByEmail(orderId: string): Promise<void>;
notifyBySMS(orderId: string): Promise<void>;
notifyBySlack(orderId: string): Promise<void>;
}
// But in reality, you only have email
class EmailOrderNotifier implements OrderNotificationService {
async notifyByEmail(orderId: string) { /* ... */ }
async notifyBySMS() { throw new Error("Not implemented"); }
async notifyBySlack() { throw new Error("Not implemented"); }
}
Instead, create interfaces that reflect what you actually need:
interface OrderNotifier {
notify(orderId: string, message: string): Promise<void>;
}
class EmailOrderNotifier implements OrderNotifier {
async notify(orderId: string, message: string) {
// Send email
}
}
Key Takeaways
- Fat interfaces force tests to mock unused methods; segregate into small, role-based interfaces instead.
- Role-based naming (
OrderRepository,ProductRepository) clarifies intent and responsibility. - Explicit dependencies (constructor injection) make requirements visible and testing straightforward.
- TypeScript’s
PickandPartiallet you carve out small interfaces from large ones without major refactoring. - Small interfaces are easier to implement, mock, and reason about. They reduce coupling and improve code reusability.
- Refactoring a fat interface is incremental: identify clients, group by role, create small interfaces, update clients, keep the concrete class as the single implementation of all of them.
- Test setup shrinks when you depend only on what you use. Fewer mock methods means clearer tests and less brittle test code.