Ran Wei / Object-Oriented Design / Module 2
中文
Object-Oriented Design — Ran Wei

Module 2: Encapsulation

Hide your data, design a clean public interface, and introduce the ShoppingCart — the third class in our bookshop domain.

Prereq: OOD Module 1 Python 3 @property Law of Demeter SysML v2 Bridge
1

The Encapsulation Problem

In Module 1 we built a Book class with fully public attributes. Any code anywhere can directly overwrite them — no rules, no validation:

Python
book1.price = -9.99    # invalid: price cannot be negative
book1.stock = "lots"   # invalid: stock must be an integer
book1.isbn  = ""       # invalid: ISBN cannot be blank

# Object is now in a broken state. No error raised.
# The bug surfaces elsewhere, much later.

Encapsulation solves this by enforcing a clear boundary:

ConcernMeaning
Public interfaceThe operations the outside world is allowed to call. Stable, intentional, documented.
Private implementationInternal data and helper logic. Hidden, changeable without breaking callers.
ℹ️ Key Principle
Program to the interface, not the implementation. External code should only depend on what a class promises to do, not on how it stores its data internally.
2

Access Modifiers in Python

Python does not enforce access control at the language level. Instead it uses naming conventions that communicate intent clearly:

ConventionVisibilityMeaning
namePublicPart of the class's official interface. Safe to use from anywhere.
_nameProtectedInternal use within the class and subclasses. External access is discouraged.
__namePrivateTriggers name-mangling. Strongly signals: internal detail, do not touch.

In UML diagrams these map to the standard visibility markers: + (public), # (protected), (private).

Python
class Book:
    def __init__(self, isbn, title, author, price, stock=0):
        self.isbn    = isbn    # public  — read externally is fine
        self.title   = title   # public
        self.author  = author  # public
        self._price  = price   # protected — use the property instead
        self._stock  = stock   # protected — use the property instead
3

Properties — Controlled Access with @property

The @property decorator lets you attach validation logic to what looks like a simple attribute access from the outside. The caller never needs to change their code:

Python
    # ── price property ───────────────────────────────────
    @property
    def price(self) -> float:
        return self._price

    @price.setter
    def price(self, value: float) -> None:
        if value < 0:
            raise ValueError(f"Price cannot be negative: {value}")
        self._price = value

    # ── stock property ───────────────────────────────────
    @property
    def stock(self) -> int:
        return self._stock

    @stock.setter
    def stock(self, value: int) -> None:
        if not isinstance(value, int) or value < 0:
            raise ValueError("Stock must be a non-negative integer")
        self._stock = value
Python — caller side
book1.price = 39.99      # writes via setter — transparent
book1.price = -5.00      # ValueError: Price cannot be negative: -5.0
book1.stock = "lots"     # ValueError: Stock must be a non-negative integer
💡 Design Rule
Expose attributes as properties only when you need validation, computed values, or future flexibility. Simple data with no invariant can remain a plain public attribute.
4

Invariants — Rules the Object Always Obeys

A class invariant is a condition that must be true for every valid instance at all times — before any method call and after every method call. For Book:

  • price ≥ 0 — a book cannot have a negative price.
  • stock ≥ 0 and stock is an integer.
  • isbn is non-empty — every book must have an identifier.
Python — constructor guard
    def __init__(self, isbn: str, ...):
        if not isbn or not isbn.strip():
            raise ValueError("ISBN cannot be blank")
        self.isbn = isbn
⚠️ Pitfall
Invariants must be checked in every method that can modify the relevant state — not just the constructor. Every entry point into mutable state is a potential invariant violation.
5

Introducing ShoppingCart

The third domain class. It holds a list of (Book, quantity) pairs and demonstrates encapsulation applied to a mutable collection:

Python
class ShoppingCart:
    """A shopping basket belonging to one customer."""

    def __init__(self, customer):
        self._customer = customer
        self._items: list[tuple] = []

    def add_item(self, book, quantity: int = 1) -> None:
        if quantity <= 0:
            raise ValueError("Quantity must be positive")
        if not book.is_available():
            raise ValueError(f"{book.title} is out of stock")
        for i, (b, q) in enumerate(self._items):
            if b.isbn == book.isbn:
                self._items[i] = (b, q + quantity)
                return
        self._items.append((book, quantity))

    def get_total(self) -> float:
        return sum(b.price * q for b, q in self._items)

    def item_count(self) -> int:
        return sum(q for _, q in self._items)

    @property
    def items(self) -> list:
        return list(self._items)   # copy, not a reference
ℹ️ Key Detail — Returning a Copy
items returns list(self._items) — a copy of the internal list. If we returned self._items directly, callers could mutate the list from outside the class, bypassing all validation.
UML class diagram: Book and ShoppingCart
Figure 1 — UML class diagram: Book (with properties) and ShoppingCart. The «property» stereotype marks controlled attribute access.
6

Designing a Clean Public Interface

The Law of Demeter

A method should only call methods on: the object itself, parameters passed in, objects it creates locally, and direct attributes of self. Use only one dot — don't reach through one object to call methods on another.

Python
# BAD — violates Law of Demeter (two hops: cart → book → str)
for book, qty in cart.items:
    print(book.author.upper())

# GOOD — ask the book to do the work
for book, qty in cart.items:
    print(book.get_details())

Customer with Encapsulated Email

Python
class Customer:
    def __init__(self, customer_id: str, name: str, email: str):
        self._customer_id = customer_id
        self._name        = name
        self._email       = ""
        self.email        = email   # validates via setter

    @property
    def email(self) -> str:
        return self._email

    @email.setter
    def email(self, value: str) -> None:
        if "@" not in value:
            raise ValueError(f"Invalid email: {value}")
        self._email = value

    def update_email(self, new_email: str) -> None:
        self.email = new_email   # goes through the setter
Association diagram: Customer owns ShoppingCart
Figure 2 — Association: a Customer owns one ShoppingCart. Multiplicity 1 — 1..1.
7

SysML v2 Bridge

SysML v2 supports UML visibility markers directly on features. The mapping is direct:

Python conventionSysML v2
name — publicpublic attribute name : Type;
_name — protectedprotected attribute _name : Type;
__name — privateprivate attribute __name : Type;
+ public methodpublic action def method;
private methodprivate action def method;
SysML v2
part def Book {
    public  attribute isbn   : String;
    public  attribute title  : String;
    public  attribute author : String;
    private attribute _price : Real    := 0.0;
    private attribute _stock : Integer := 0;
    public  attribute price  : Real    := _price; // derived (≈ @property)

    public  action def get_details   : String;
    public  action def is_available  : Boolean;
    public  action def apply_discount { in percent : Real; }
}

part def ShoppingCart {
    private attribute _items    : OrderedSet (Book, Integer);
    private ref part  _customer : Customer;

    public action def add_item    { in book : Book; in qty : Integer; }
    public action def remove_item { in book : Book; }
    public action def get_total   : Real;
    public action def item_count  : Integer;
}
Python
SysML v2
public name
public attribute
protected _name
protected attribute
private __name
private attribute
@property
derived attribute
8

Summary

Encapsulation

Separates public interface from private implementation. External code depends only on the interface.

Access Conventions

name public, _name protected, __name private. Convention-based, respected by all serious Python code.

@property

Adds validation to what looks like plain attribute access. Use when you need to enforce invariants or compute values.

Class Invariants

Conditions that must hold for every valid instance at all times. Encapsulation is the mechanism that enforces them.

Return Copies

When exposing mutable internal state via a property, return a copy — not a reference — to prevent external mutation.

Law of Demeter

Only call methods on self, parameters, locally created objects, and direct attributes. Use one dot, not a chain.

🧬
Next — Module 3

Inheritance & Hierarchies

How one class can extend another, reuse and specialise behaviour, and how to model generalisation hierarchies in UML and SysML v2.