Interfaces in C++
The golden rule of resilient software architecture is program to an interface, not an implementation. When a business service directly instantiates a concrete third-party SDK (like Twilio SMS or SendGrid Email), it binds tightly to external infrastructure. It cannot be unit-tested without network calls, cannot switch providers without editing business logic, and violates the Dependency Inversion Principle. An interface creates an architectural boundary that isolates business rules from operational details.
Think of an interface as a legal service contract signed by an external vendor. The contract specifies what work must be done (send notification) and the format of the outcome (bool success). The client company doesn't care whether the contractor delivers by motorcycle, electric van, or drone, as long as the terms of the contract are fulfilled.
Why this matters in LLD interviews
The golden rule of resilient software architecture is program to an interface, not an implementation. When a business service directly instantiates a concrete third-party SDK (like Twilio SMS or SendGrid Email), it binds tightly to external infrastructure. It cannot be unit-tested without network calls, cannot switch providers without editing business logic, and violates the Dependency Inversion Principle. An interface creates an architectural boundary that isolates business rules from operational details.
Think of an interface as a legal service contract signed by an external vendor. The contract specifies what work must be done (send notification) and the format of the outcome (bool success). The client company doesn't care whether the contractor delivers by motorcycle, electric van, or drone, as long as the terms of the contract are fulfilled.
Core concepts
2.1Pure Virtual Classes (= 0) as C++ Interfaces
C++ has no dedicated interface keyword like Java or C#. Instead, an interface in C++ is an abstract class with only pure virtual methods, no state variables, and a virtual destructor.
The syntax = 0 tells the compiler that this class cannot be instantiated directly; any concrete subclass must override every pure virtual method to be instantiated. The declaration virtual ~Notifier() = default; ensures clean polymorphic destruction through base interface pointers.
class Notifier {
public:
virtual ~Notifier() = default;
virtual bool send(std::string_view recipient, std::string_view msg) = 0;
virtual std::string channelName() const = 0;
};2.2Programming to an Interface, Not an Implementation
When OrderService accepts a std::shared_ptr<Notifier> (or std::unique_ptr<Notifier>), it knows nothing about HTTP endpoints, API tokens, or telecom protocols. It simply invokes notifier_->send(...).
This decouples the caller completely. When product management asks to replace Twilio with Gupshup or add a WhatsApp gateway, the payments and ordering teams do not touch or re-test OrderService. They simply implement a new WhatsAppNotifier : public Notifier.
class OrderService {
std::shared_ptr<Notifier> notifier_;
public:
explicit OrderService(std::shared_ptr<Notifier> notifier)
: notifier_(std::move(notifier)) {}
};2.3Dependency Injection & Test Doubles (Fakes)
The greatest practical benefit of interfaces is testability. In unit tests, passing real SMS or email gateways causes slow tests, random network flakiness, and incurred API bills.
With interfaces, tests inject a FakeNotifier that records notifications in an in-memory std::vector. Tests execute in microseconds, run 100% deterministically in CI environments, and can easily simulate failure scenarios (e.g. gateway timeouts or invalid recipients).
class FakeNotifier final : public Notifier {
public:
std::vector<std::string> sentMessages;
bool send(std::string_view, std::string_view msg) override {
sentMessages.push_back(std::string(msg));
return true;
}
};2.4Interface Segregation: Small Contracts over Monoliths
Keep interfaces lean and focused. Avoid fat interfaces that bundle unrelated operations like sendNotification(), chargeCreditCard(), and generatePdfReceipt() into one mega-contract. If a client only needs notifications, define a dedicated Notifier interface. This honors the Interface Segregation Principle (ISP) and ensures mock implementations stay simple.
// Prefer small, focused interfaces:
class Notifier { /* send only */ };
class ReceiptPrinter { /* print only */ };UML class structure
OrderService depends strictly on the Notifier interface. Concrete implementations and test fakes implement the pure virtual contract.
- + ~Notifier() = default
- + send(recipient, message): bool = 0
- + channelName(): string = 0
- - senderId_: string
- + send(recipient, message): bool override
- + channelName(): string override
- - fromAddress_: string
- + send(recipient, message): bool override
- + channelName(): string override
- + history: vector<SentItem>
- + shouldFail: bool
- + send(recipient, message): bool override
- + channelName(): string override
- - notifier_: shared_ptr<Notifier>
- + OrderService(notifier)
- + setNotifier(notifier): void
- + notifyOrderStatus(id, contact, status): bool
- + currentChannel(): string
Runnable C++23 example
- 1.
Interface Declaration:
Notifierspecifies the pure virtual contractsend()andchannelName()with zero member variables and a virtual destructor. - 2.
Concrete Implementations:
SmsNotifierandEmailNotifierimplement the contract according to channel-specific formatting and validation rules. - 3.
Caller Decoupling:
OrderServiceholds a pointer toNotifier. It constructs and delivers status updates without knowing whether SMS or Email is active. - 4.
Runtime Adaptability: Calling
setNotifier()swaps the active notification channel dynamically while keeping all service logic unchanged.
#include <format>
#include <memory>
#include <print>
#include <string>
#include <string_view>
#include <vector>
// Pure virtual interface representing a notification channel contract.
// Interfaces in C++ have no member variables and declare pure virtual (= 0) methods.
class Notifier {
public:
virtual ~Notifier() = default;
[[nodiscard]] virtual bool send(std::string_view recipient, std::string_view message) = 0;
[[nodiscard]] virtual std::string channelName() const = 0;
};
// Production SMS notification channel.
class SmsNotifier final : public Notifier {
private:
std::string senderId_;
public:
explicit SmsNotifier(std::string senderId) : senderId_(std::move(senderId)) {}
[[nodiscard]] bool send(std::string_view recipient, std::string_view message) override {
if (recipient.empty() || message.empty()) {
return false;
}
std::println("[SMS:{}] Sending to {}: \"{}\"", senderId_, recipient, message);
return true;
}
[[nodiscard]] std::string channelName() const override {
return "SMS";
}
};
// Production Email notification channel.
class EmailNotifier final : public Notifier {
private:
std::string fromAddress_;
public:
explicit EmailNotifier(std::string fromAddress) : fromAddress_(std::move(fromAddress)) {}
[[nodiscard]] bool send(std::string_view recipient, std::string_view message) override {
if (recipient.find('@') == std::string_view::npos || message.empty()) {
return false;
}
std::println("[Email:{}] Sending to <{}>: \"{}\"", fromAddress_, recipient, message);
return true;
}
[[nodiscard]] std::string channelName() const override {
return "Email";
}
};
// High-level business service: depends strictly on Notifier interface, never on concrete SMS or Email.
class OrderService {
private:
std::shared_ptr<Notifier> notifier_;
public:
explicit OrderService(std::shared_ptr<Notifier> notifier)
: notifier_(std::move(notifier)) {}
void setNotifier(std::shared_ptr<Notifier> notifier) noexcept {
notifier_ = std::move(notifier);
}
[[nodiscard]] bool notifyOrderStatus(std::string_view orderId,
std::string_view customerContact,
std::string_view status) {
std::string message = std::format("Order {}: status is now '{}'", orderId, status);
return notifier_->send(customerContact, message);
}
[[nodiscard]] std::string currentChannel() const {
return notifier_->channelName();
}
};
int main() {
auto sms = std::make_shared<SmsNotifier>("FOODAPP");
OrderService orderService(sms);
std::println("Using channel: {}", orderService.currentChannel());
(void)orderService.notifyOrderStatus("ORD-7001", "+919876543210", "Confirmed by Restaurant");
// Seamlessly swap to Email at runtime without touching OrderService
auto email = std::make_shared<EmailNotifier>("support@foodapp.com");
orderService.setNotifier(email);
std::println("Swapped channel: {}", orderService.currentChannel());
(void)orderService.notifyOrderStatus("ORD-7001", "customer@example.com", "Out for Delivery");
return 0;
}Using channel: SMS [SMS:FOODAPP] Sending to +919876543210: "Order ORD-7001: status is now 'Confirmed by Restaurant'" Swapped channel: Email [Email:support@foodapp.com] Sending to <customer@example.com>: "Order ORD-7001: status is now 'Out for Delivery'"
Modern C++ note
Runtime virtual tables when concrete types are known at compile time.
With C++20 concepts, you can define interface requirements at compile time without any virtual functions, vtable indirection, or heap allocations. The compiler inlines calls directly while still verifying function signatures.
When the concrete provider must be swapped dynamically at runtime (e.g. from user configuration or fallback routers), or when different implementations must be stored in the same heterogeneous collection (vector<unique_ptr<Notifier>>).
Rules of thumb
Never put data members inside an interface classβkeep it 100% pure virtual.
Always provide a public
virtual ~Interface() = default;in interface definitions.Inject dependencies as interface pointers or references into constructors rather than creating concrete instances internally.
Design interfaces from the client's perspective: declare what the caller needs, not what the provider happens to implement.
Don't make this mistake
Adding state to interfaces forces unnecessary memory layout and initialization coupling onto all derived classes. State belongs in concrete subclasses.
Constructing concrete classes inside services tightly couples them to real infrastructure, preventing unit testing and runtime channel swapping.
Monolithic interfaces force classes to implement stub or throw-away methods for operations they do not support, complicating unit tests and maintenance.