androidengineers.Book a session

Python 08 · Objects and interfaces

Composition, protocols, and dependency injection

articleSelf-paced

Learn the concept

Composition builds an object from collaborators instead of making a deep inheritance hierarchy. A document service can use a repository and a model client without inheriting from either. This keeps responsibilities separate and makes replacement possible.

A protocol describes the operations a collaborator must provide for type checking. It does not automatically validate data or prove that an implementation behaves correctly. Tests still need to exercise the contract.

Dependency injection simply means supplying collaborators from outside instead of creating them invisibly inside the function. A fake client can then replace a paid model during tests. Keep the fake faithful to error behavior as well as successful results, otherwise tests only prove the happy path.

Run and inspect

from typing import Protocol

class Reader(Protocol):
    def read(self, identifier: str) -> str: ...

class FakeReader:
    def read(self, identifier: str) -> str:
        return f"document:{identifier}"

def load(reader: Reader, identifier: str) -> str:
    return reader.read(identifier)

assert load(FakeReader(), "d1") == "document:d1"

Your exercise

Inject a reader into a processing function. Add fake implementations for success, missing content, and timeout.

Check your understanding

Business logic can be tested without network access, and each fake failure produces the intended application outcome.

Guided workshop: replace infrastructure with a small interface

Your document importer should not need a running database just to test its text normalization. Pass storage behavior into the importer. This is dependency injection: the caller supplies a collaborator rather than the function constructing one invisibly.

# academy-check
from dataclasses import dataclass
from typing import Protocol

@dataclass(frozen=True)
class Document:
    id: str
    text: str

class Store(Protocol):
    def save(self, document: Document) -> None: ...

class MemoryStore:
    def __init__(self):
        self.documents = {}
    def save(self, document: Document) -> None:
        self.documents[document.id] = document

def import_document(store: Store, document: Document) -> None:
    store.save(Document(document.id, document.text.strip()))

store = MemoryStore()
import_document(store, Document("a", " hello "))
assert store.documents["a"].text == "hello"

MemoryStore does not inherit from Store; it supplies the required method. The protocol documents the expected shape. The frozen dataclass prevents assigning new field values after construction, but freezing an object would not recursively freeze a mutable list stored inside it.

Exercise: create a FailingStore whose save() raises an OSError. Decide whether the importer or outer job owns retries. Keep that responsibility in one place and ensure the importer does not return success when persistence fails.

Worked answer: let the error propagate from this small importer. The job boundary can classify it, record the failed document ID, and choose a retry policy. Do not append to an “imported” list before save() succeeds.

Design check: inheritance is useful when implementations share a meaningful substitutable contract. It is unnecessary to create a deep class hierarchy for every processing step. Begin with a function and a narrow storage protocol. Add methods when callers need them, and test the same storage contract against the in-memory implementation and the later SQLite implementation.

YOUR LEARNING JOURNEY

0 of 121 available lessons completed

Progress saved in this browser. No account needed.
Composition, protocols, and dependency injection | Agentic AI | Android Engineers