Inheritance feels natural just after you learn class, virtual and overriding, so every notification feature becomes another subtype. The first two classes look tidy, but channel, formatting and retry choices change independently. Refactoring those choices out of eight leaf classes into three composed policies fixes the growth, and tracing Alert{42, "Disk usage 92%"} through statuses [temporaryFailure, sent] under a maximum of three attempts shows exactly what each object contributes. For the wider placement-coding track, explore Coding & DSA Courses for Placements.
C++ composition vs inheritance starts with is-a and has-a
Inheritance makes an is-a claim and promises substitution. EmailChannel should inherit from Channel only if every caller written for Channel can use it without learning extra preconditions. Composition expresses has-a or uses-a: a Notifier has a channel, formatter and retry policy, and delegates one job to each.
Ask three questions:
Must the derived object honour the same contract?
Will the base abstraction remain meaningful as features change?
Do the behaviours vary on independent dimensions?
Stable answers to the first two can justify inheritance. Independent dimensions usually favour composition. Review C++ Tutorial: Complete Learning Path if classes, references or virtual are new.
The inheritance-first design turns three choices into eight classes
Suppose channel is {Email, SMS}, formatting is {Plain, Urgent}, and attempt policy is {Once, UpTo3}. One legitimate polymorphic root can be:
struct Alert { int id; std::string text; };
struct NotificationOutcome {
bool sent;
int attempts;
std::string payload;
};
struct NotifierBase {
virtual NotificationOutcome notify(const Alert&) = 0;
virtual ~NotifierBase() = default;
};A subtype for each combination creates 2 x 2 x 2 = 8 leaves: PlainOnceEmailNotifier, PlainRetryingEmailNotifier, UrgentOnceEmailNotifier, UrgentRetryingEmailNotifier, PlainOnceSmsNotifier, PlainRetryingSmsNotifier, UrgentOnceSmsNotifier and UrgentRetryingSmsNotifier.
Add Push while keeping two formats and two policies: 3 x 2 x 2 = 12, so four classes appear. Then add a third formatter: 3 x 3 x 2 = 18, another six leaves. Orchestration and retry fixes may be copied across siblings, while clients learn combination names. Inheritance did not cause the multiplication. Encoding three independent axes as subclasses did.
Refactor the notifier into channel, formatter and retry policies
Give each changing dimension a narrow contract. A send can succeed, fail temporarily or be rejected permanently:
enum class SendStatus { sent, temporaryFailure, rejected };
struct SendResult { SendStatus status; };
struct Channel {
virtual SendResult send(std::string_view payload) = 0;
virtual ~Channel() = default;
};
struct Formatter {
virtual std::string format(const Alert&) const = 0;
virtual ~Formatter() = default;
};
struct RetryPolicy {
virtual bool shouldRetry(int attempts, SendResult last) const = 0;
virtual ~RetryPolicy() = default;
};Virtual destructors keep destruction through base pointers safe in owning variants. The orchestration class contains no Email, SMS, urgent or retry branch:
class Notifier {
Channel& channel_;
const Formatter& formatter_;
const RetryPolicy& retry_;
public:
Notifier(Channel& c, const Formatter& f, const RetryPolicy& r)
: channel_(c), formatter_(f), retry_(r) {}
NotificationOutcome notify(const Alert& alert) {
std::string payload = formatter_.format(alert);
SendResult last{SendStatus::temporaryFailure};
int attempts = 0;
do {
++attempts;
last = channel_.send(payload);
} while (retry_.shouldRetry(attempts, last));
return {last.status == SendStatus::sent, attempts, payload};
}
};The do loop ensures at least one send. The three references are non-owning, so their objects must outlive Notifier. Use std::unique_ptr if the notifier should own replaceable policies.
Work the same alert through composition
UrgentFormatter::format(Alert{42, "Disk usage 92%"}) returns [URGENT #42] Disk usage 92%. ScriptedEmailChannel is a deterministic teaching fake: it returns the next configured status and records every payload. The retry rule is:
bool shouldRetry(int attempts, SendResult last) const override {
return last.status == SendStatus::temporaryFailure
&& attempts < maxAttempts;
}Assemble and call the objects:
ScriptedEmailChannel email{{
SendStatus::temporaryFailure,
SendStatus::sent
}};
UrgentFormatter urgent;
UpToMaxAttempts retry{3};
Notifier notifier{email, urgent, retry};
auto outcome = notifier.notify(Alert{42, "Disk usage 92%"});Formatting runs once. Attempt 1 sends [URGENT #42] Disk usage 92% and returns temporaryFailure. Because 1 < 3, the policy retries. Attempt 2 sends the identical payload and returns sent, so retrying stops although one allowed attempt remains.
The result is {sent=true, attempts=2, payload="[URGENT #42] Disk usage 92%"}. The recorded payload list contains that string twice. For {temporaryFailure, temporaryFailure, temporaryFailure}, the checks are 1 < 3, 2 < 3, then 3 < 3, which is false. The outcome has sent=false after exactly three attempts, with no attempt 4.

Compare coupling and extension cost after the refactor
Change | Combination-subclass design | Composed-policy design |
|---|---|---|
Add Push | 4 new leaves | 1 new |
Add Markdown formatting after three channels | 6 new leaves | 1 new |
Change temporary-failure retry | Edit every retrying leaf that owns the loop | Edit |
Test Email failure | Construct a combination subtype | Inject |
Notifier::notify owns format -> send -> ask retry policy; each component owns its local decision. A client assembles Email + Urgent + UpTo3 without naming the Cartesian product.
Composition does create more objects and constructor wiring. Runtime interface calls may also be unnecessary in a closed, performance-sensitive design, where template policies can provide compile-time composition. The architectural gain here is independent replaceability, not a universal speed advantage.

Substitution decides where inheritance still belongs
Channel::send receives a payload view valid only during the call and returns sent, temporaryFailure or rejected. A channel must not retain the std::string_view. Channel-specific rejection belongs in the result, not a hidden precondition. Derived channels may return different statuses for the same payload.
Suppose an older base contract promises to accept lengths 1..500 without throwing. This application's SmsChannel has a configured 160-character limit. Throwing std::invalid_argument for std::string(170, 'x') strengthens the precondition, so a valid base caller breaks after substitution. Let the base contract permit rejected, return it from SmsChannel, and never retry it. Here, 160 is an application setting, not a universal SMS rule.
Inheritance fits the narrow Channel, Formatter and RetryPolicy seams while implementations honour their contracts. Composition assembles them. Put an Email-only capability behind an adapter or specific interface, not sendEmailReceipt() on every channel.
Composition and inheritance traps, exam questions and interview reasoning
Mistake | What fails | Correction |
|---|---|---|
Inherit for code reuse | False is-a claim | Extract a component |
Put format and retry flags in one notifier | Coupled branches | Inject policies |
Downcast | Lost substitution | Improve the contract |
Retry | Permanent failure loops | Retry only |
Reference short-lived policies | Dangling references | Enforce lifetime or own them |
Vivas and interviews compress the topic into five question shapes:
Classify: is
Notifier -> Channelis-a or has-a? Has-a; the is-a edge isEmailChannel -> Channel.Calculate: how many leaf classes do two channels, two formats and two policies force?
2 x 2 x 2 = 8, and adding Push makes3 x 2 x 2 = 12.Trace: how many attempts does
{temporaryFailure, sent}take under a maximum of3? Two, becausesentstops the loop early.Diagnose: why does throwing on a 170-character payload break substitution? It strengthens the base contract's 1..500 no-throw precondition.
Decide: when is composition the right choice? When channel, format and retry vary on independent axes.
All five answers fall out of the worked notifier design, so rebuild it from memory before an interview. Use OOP for Teaching CS Exams: Classes and Inheritance for broader revision.
C++ composition vs inheritance: the short version and next step
Inherit only for a stable, substitutable is-a contract.
Compose behaviours that change on independent axes.
Keep orchestration in one place.
Test components with scripted outcomes: Alert 42,
[temporaryFailure, sent], maximum3, thensent=trueafter2attempts.
As an exercise, add PushChannel and MarkdownFormatter, then assemble Push + Markdown + UpTo3 without a new notifier subtype. For structured practice, use C++ Programming: Concepts, MCQs and Coding. For multi-language placement preparation, see Coding for Placements: C, C++, Java and Python.




