Ownership in Designs
In garbage-collected runtimes like Java or Go, developers rarely think about who deletes an object. In C++ Low-Level Design, object lifetime is an explicit architectural decision. Every pointer or reference in a class diagram answers a critical contract: who allocates this object, who is allowed to mutate it, and who is responsible for deleting it? Ambiguous ownership leads directly to memory leaks, dangling pointers, double-free crashes, and concurrency races. Modern C++ makes ownership explicit through its type system: std::unique_ptr for exclusive ownership, std::shared_ptr when multiple subsystems co-own a resource, and raw references/pointers T& or T* for non-owning observers.
The C++ Ownership Ladder:
- Exclusive Ownership (
std::unique_ptr<T>): One parent owns the child. The child lives and dies with its owner. Ownership transfers via move semantics (std::move). This is your default choice for 90% of designs (e.g., a Controller owning its Service, a Service owning its Strategy). - Shared Ownership (
std::shared_ptr<T>): Multiple components share lifetime via atomic reference counting. The resource is destroyed when the last owner releases it. Use only when lifetime is truly non-deterministic across concurrent threads or shared caches. - Weak Observation (
std::weak_ptr<T>): Observes astd::shared_ptrwithout keeping it alive and breaks reference cycles between collaborating objects (such as parent-child or observer-subject links). - Non-Owning Borrowing (
const T&orT*): The caller owns the object and temporarily lends it for the duration of a function call. Zero overhead, zero lifetime coupling.
Why this matters in LLD interviews
In garbage-collected runtimes like Java or Go, developers rarely think about who deletes an object. In C++ Low-Level Design, object lifetime is an explicit architectural decision. Every pointer or reference in a class diagram answers a critical contract: who allocates this object, who is allowed to mutate it, and who is responsible for deleting it? Ambiguous ownership leads directly to memory leaks, dangling pointers, double-free crashes, and concurrency races. Modern C++ makes ownership explicit through its type system: std::unique_ptr for exclusive ownership, std::shared_ptr when multiple subsystems co-own a resource, and raw references/pointers T& or T* for non-owning observers.
The C++ Ownership Ladder:
- Exclusive Ownership (
std::unique_ptr<T>): One parent owns the child. The child lives and dies with its owner. Ownership transfers via move semantics (std::move). This is your default choice for 90% of designs (e.g., a Controller owning its Service, a Service owning its Strategy). - Shared Ownership (
std::shared_ptr<T>): Multiple components share lifetime via atomic reference counting. The resource is destroyed when the last owner releases it. Use only when lifetime is truly non-deterministic across concurrent threads or shared caches. - Weak Observation (
std::weak_ptr<T>): Observes astd::shared_ptrwithout keeping it alive and breaks reference cycles between collaborating objects (such as parent-child or observer-subject links). - Non-Owning Borrowing (
const T&orT*): The caller owns the object and temporarily lends it for the duration of a function call. Zero overhead, zero lifetime coupling.
Core concepts
2.1Exclusive Ownership with std::unique_ptr & std::move
In classic object-oriented design, when class A composes class B, A has full responsibility for B's lifetime. In Modern C++, express this with std::unique_ptr<B>. A unique_ptr cannot be copied—only moved. Construct it using std::make_unique<T>(), which ensures exception safety and avoids raw new. Transfer ownership into constructors using std::move. Once moved, the caller's pointer becomes nullptr, preventing accidental use-after-transfer.
class OrderService {
std::unique_ptr<PaymentProcessor> processor_;
public:
// Takes exclusive ownership via move
explicit OrderService(std::unique_ptr<PaymentProcessor> processor)
: processor_(std::move(processor)) {}
};2.2Shared Ownership and the std::weak_ptr Cycle Breaker
Use std::shared_ptr<T> only when multiple independent entities co-own a resource and neither can predict when the other will finish (e.g. a shared user session across asynchronous worker threads). However, overusing shared_ptr is a design smell: it hides ownership boundaries and introduces atomic increment/decrement overhead. Even worse, if object A holds a shared_ptr to B, and B holds a shared_ptr to A, their reference counts will never hit zero—creating a permanent memory leak. Break cycles by turning one side into a std::weak_ptr<T>, which observes without incrementing the reference count and must be temporarily upgraded using .lock() before access.
class AuditObserver {
std::weak_ptr<UserSession> sessionWeak_; // Does not prevent session destruction
public:
void log() const {
if (auto session = sessionWeak_.lock()) {
// Safe to access session->userId()
}
}
};2.3Non-Owning Observers: Borrowing with T* and T&
A common beginner mistake is passing const std::unique_ptr<T>& or std::shared_ptr<T> to methods that just need to read data. This unnecessarily couples the function signature to the caller's memory management strategy. Pass a raw reference T& or pointer T* for non-owning borrowing. A raw pointer in modern C++ means: 'I am borrowing this object for the scope of this call; I do not own it, and I will not delete it.'
// Clean borrowing: accepts any logger, regardless of how caller allocates it
bool checkout(const Order& order, const ILogger& logger) {
logger.log(std::format("Order {}", order.orderId));
return processor_->process(order.amountRupees);
}2.4The Rule of 5 and Deleting Copy Operations (= delete)
Polymorphic resource owners should never be copied implicitly. Accidental copying can cause object slicing (copying only the base subobject) or duplicate handles to external hardware and sockets. In C++20, explicitly mark copy operations as = delete on your service and manager classes, while defaulting move operations if transfer of the entire manager is permitted.
class OrderService {
public:
// Prevent accidental slicing and duplicated ownership
OrderService(const OrderService&) = delete;
OrderService& operator=(const OrderService&) = delete;
OrderService(OrderService&&) noexcept = default;
OrderService& operator=(OrderService&&) noexcept = default;
};UML class structure
OrderService exclusively owns PaymentProcessor via unique_ptr (composes ◆──▶). It shares UserSession via shared_ptr (aggregates ◇──▶), while AuditObserver holds a weak_ptr observer. ILogger is borrowed as a non-owning reference (depends ╌╌▶).
- - processor_: unique_ptr<PaymentProcessor>
- - session_: shared_ptr<UserSession>
- + checkout(order: Order, logger: ILogger&): bool
- + session(): shared_ptr<UserSession>
- + ~PaymentProcessor() = default
- + process(amountRupees: int): bool = 0
- - vpa_: string
- + process(amountRupees: int): bool override
- + vpa(): string
- - userId_: string
- + userId(): string
- - sessionWeak_: weak_ptr<UserSession>
- + auditEvent(event: string_view): void
- + isSessionAlive(): bool
- + ~ILogger() = default
- + log(msg: string_view): void = 0
Runnable C++23 example
- 1.
Exclusive ownership via
std::make_unique: We create aUpiProcessoron the heap and pass it intoOrderServiceusingstd::move. In the caller,upibecomesnullptr, demonstrating that sole ownership transferred intoOrderService. - 2.
Shared ownership with atomic tracking:
UserSessionis wrapped instd::shared_ptr. When shared withOrderService, the reference count increments from 1 to 2.maindrops its handle with.reset(), leavingOrderServiceas the lone owner withuse_count == 1. - 3.
Weak observation without extending lifetime:
AuditObserverholds astd::weak_ptr<UserSession>. It audits events by calling.lock()to obtain a temporaryshared_ptr. OnceOrderServicegoes out of scope, the session is immediately destroyed; subsequent calls toauditor.auditEventgracefully detect the expired session instead of accessing dangling memory. - 4.
Non-owning borrowing:
ConsoleLoggerimplementsILoggeron the stack.OrderService::checkoutborrows it byconst ILogger&, decoupling the business logic from how the logger's memory is allocated.
#include <iostream>
#include <memory>
#include <print>
#include <string>
#include <string_view>
#include <utility>
// Non-owning observer interface
class ILogger {
public:
virtual ~ILogger() = default;
virtual void log(std::string_view msg) const = 0;
};
class ConsoleLogger final : public ILogger {
public:
void log(std::string_view msg) const override {
std::println("[LOG] {}", msg);
}
};
// Pure virtual interface for payment strategy
class PaymentProcessor {
public:
virtual ~PaymentProcessor() = default;
virtual bool process(int amountRupees) = 0;
};
class UpiProcessor final : public PaymentProcessor {
std::string vpa_;
public:
explicit UpiProcessor(std::string vpa) : vpa_(std::move(vpa)) {}
bool process(int amountRupees) override {
std::println("[UPI] Debited Rs.{} via {}", amountRupees, vpa_);
return true;
}
[[nodiscard]] const std::string& vpa() const noexcept { return vpa_; }
};
// Shared domain entity: UserSession
class UserSession {
std::string userId_;
public:
explicit UserSession(std::string userId) : userId_(std::move(userId)) {
std::println("UserSession created for {}", userId_);
}
~UserSession() {
std::println("UserSession destroyed for {}", userId_);
}
[[nodiscard]] const std::string& userId() const noexcept { return userId_; }
};
// Audit observer holding weak_ptr to prevent cycle and avoid extending lifetime
class AuditObserver {
std::weak_ptr<UserSession> sessionWeak_;
public:
explicit AuditObserver(std::weak_ptr<UserSession> session)
: sessionWeak_(std::move(session)) {}
void auditEvent(std::string_view event) const {
if (auto session = sessionWeak_.lock()) {
std::println("[AUDIT] User '{}' performed: {}", session->userId(), event);
} else {
std::println("[AUDIT] (Session expired) Unauthenticated event: {}", event);
}
}
[[nodiscard]] bool isSessionAlive() const noexcept {
return !sessionWeak_.expired();
}
};
// Order domain entity
struct Order {
std::string orderId;
int amountRupees;
};
// OrderService:
// 1. Exclusively owns PaymentProcessor via std::unique_ptr
// 2. Shares UserSession via std::shared_ptr
// 3. Borrows ILogger via non-owning reference ILogger&
// 4. Copies are explicitly deleted (= delete)
class OrderService {
std::unique_ptr<PaymentProcessor> processor_;
std::shared_ptr<UserSession> session_;
public:
OrderService(std::unique_ptr<PaymentProcessor> processor,
std::shared_ptr<UserSession> session)
: processor_(std::move(processor)), session_(std::move(session)) {}
// Rule of 5: Prevent accidental slicing and duplicated ownership
OrderService(const OrderService&) = delete;
OrderService& operator=(const OrderService&) = delete;
OrderService(OrderService&&) noexcept = default;
OrderService& operator=(OrderService&&) noexcept = default;
bool checkout(const Order& order, const ILogger& logger) {
logger.log(std::format("Checking out order {} for Rs.{}", order.orderId, order.amountRupees));
if (!processor_) {
logger.log("Error: No payment processor attached");
return false;
}
return processor_->process(order.amountRupees);
}
[[nodiscard]] const std::shared_ptr<UserSession>& session() const noexcept {
return session_;
}
[[nodiscard]] bool hasProcessor() const noexcept {
return processor_ != nullptr;
}
};
int main() {
ConsoleLogger logger;
// 1. Exclusive ownership: std::make_unique transferred via std::move
auto upi = std::make_unique<UpiProcessor>("naman@oksbi");
// 2. Shared ownership: session shared between OrderService and AuditObserver
auto session = std::make_shared<UserSession>("USR-404");
AuditObserver auditor(session);
std::println("Initial session use_count: {}", session.use_count());
// 3. Move unique_ptr into OrderService (ownership transfer)
OrderService service(std::move(upi), session);
std::println("Session use_count after service creation: {}", session.use_count());
// 4. Execute checkout borrowing logger as non-owning observer
Order order{"ORD-901", 1250};
service.checkout(order, logger);
auditor.auditEvent("PaymentCompleted");
// 5. Release main's shared ownership
session.reset();
std::println("Session use_count after main reset: {}", service.session().use_count());
// 6. Demonstrate weak_ptr expiration when all shared_ptrs are destroyed
{
OrderService tempService = std::move(service);
// tempService goes out of scope here
}
std::println("Checking auditor after session destruction:");
auditor.auditEvent("PostLogoutAction");
return 0;
}UserSession created for USR-404 Initial session use_count: 1 Session use_count after service creation: 2 [LOG] Checking out order ORD-901 for Rs.1250 [UPI] Debited Rs.1250 via naman@oksbi [AUDIT] User 'USR-404' performed: PaymentCompleted Session use_count after main reset: 1 UserSession destroyed for USR-404 Checking auditor after session destruction: [AUDIT] (Session expired) Unauthenticated event: PostLogoutAction
Modern C++ note
Manual new/delete pairs, and C++98 un-implemented private copy constructors.
Compile-time error diagnostics are immediate and obvious when an engineer attempts to copy a non-copyable resource owner. Combining = delete with std::make_unique guarantees exception safety and prevents object slicing across polymorphic interfaces.
Custom memory allocators (e.g. fixed-size pool or arena allocators in high-frequency trading) or interfacing with C APIs requiring raw pointer handles and custom free callbacks.
Rules of thumb
Default to
std::unique_ptrfor composition: Represent 1-to-1 or 1-to-N parent-child relationships with exclusive ownership. If a class creates or manages a collaborator, own it viaunique_ptr.Reserve
std::shared_ptrfor truly shared lifecycles: If two objects have clear parent-child or caller-callee relationships, do not useshared_ptr. Only use shared ownership for async task graphs, cache nodes, or multi-threaded pipelines.Pass raw references
T&or pointersT*to observe: Never writevoid process(const std::unique_ptr<T>& item). Writevoid process(const T& item). This lets callers pass stack objects, vector elements, or smart pointers without coupling to the storage mechanism.Break circular references with
std::weak_ptr: In bidirectional relationships (e.g., Parent ↔ Child, Publisher ↔ Subscriber), the back-pointer must be aweak_ptror raw reference; otherwise, neither object's destructor will ever run.Delete copy constructors on polymorphic types: Always declare
ClassName(const ClassName&) = delete;on polymorphic base classes and service owners to catch accidental slicing bugs at compile time.
Don't make this mistake
When two objects point to each other with std::shared_ptr, their reference counts never drop to zero even after all outside references are destroyed. The entire graph leaks permanently.
Passing std::unique_ptr by value forces the caller to call std::move(ptr), emptying their pointer. At the end of displayOrder, the object is destroyed. Only pass unique_ptr by value when the function's explicit purpose is to consume or store ownership.
std::make_unique avoids spelling the type twice, is exception-safe if multiple arguments are evaluated before constructor invocation, and avoids naked new keywords in application code.