Skip to content

Interfaces, contracts and service APIs in LLD

TL;DR

  • An interface is a promise: what the caller must supply, what comes back, what may fail, and what is still true afterwards. A method list is not a contract.
  • Take a command DTO, return a view DTO or a result object. Raise only when the caller broke the contract; return a value when the domain says no.
  • Every write that a client can retry needs an idempotency key. Every list method needs a cursor, not an offset.
  • Small Protocols per role; add fields, never change them.

Concepts

The running example is code/fundamentals/contracts.py: reserving stock, the call an e-commerce checkout makes before it takes money. Tests are in code/fundamentals/tests/test_contracts.py.

An interface is a promise, not a method list

The reservation contract: two commands in, a result union out, and two small Protocols underneath.

classDiagram
    class ReserveStock {
        +sku: str
        +quantity: int
        +order_id: str
        +idempotency_key: str
    }
    class Reserved {
        +reservation_id: str
        +sku: str
        +quantity: int
        +expires_at: float
    }
    class Rejected {
        +sku: str
        +reason: RejectionReason
        +available: int
    }
    class ReservationService {
        +reserve(command: ReserveStock) ReservationResult
        +reservations_for(sku: str, limit: int, cursor: str) Page~Reserved~
    }
    class StockRepository {
        <<interface>>
        +get(sku: str) StockItem
        +save(item: StockItem)
    }
    class ReservationLog {
        <<interface>>
        +append(reserved: Reserved)
        +page_for_sku(sku: str, limit: int, cursor: str) Page~Reserved~
    }
    class StockItem {
        +sku: str
        +on_hand: int
        +available: int
        +reserve(quantity: int)
    }
    class Page~T~ {
        +items: tuple
        +next_cursor: str
    }
    ReservationService ..> ReserveStock : accepts
    ReservationService ..> Reserved : returns
    ReservationService ..> Rejected : returns
    ReservationService o-- StockRepository
    ReservationService o-- ReservationLog
    StockRepository ..> StockItem : loads
    ReservationLog ..> Page : returns

Both collaborators are Protocols sized to what the service calls - two methods each. That is what makes the service testable without a database, and it is interface segregation applied at the point where it pays.

code/fundamentals/contracts.py - two Protocols and one real implementation
@runtime_checkable
class StockRepository(Protocol):
    """Two methods, because two is all the service calls."""

    def get(self, sku: str) -> StockItem: ...

    def save(self, item: StockItem) -> None: ...


class ReservationLog(Protocol):
    """Append and read: the service is given no way to delete history."""

    def append(self, reserved: Reserved) -> None: ...

    def page_for_sku(self, sku: str, limit: int, cursor: str | None) -> Page[Reserved]: ...


class InMemoryStockRepository:
    def __init__(self, items: dict[str, StockItem] | None = None) -> None:
        self._items: dict[str, StockItem] = dict(items or {})

    def get(self, sku: str) -> StockItem:
        try:
            return self._items[sku]
        except KeyError:
            raise NotFoundError(f"no stock record for {sku!r}") from None

    def save(self, item: StockItem) -> None:
        self._items[item.sku] = item

Method names carry the promise. reserve says a hold is taken and can be released; get says the thing exists or you get an error; page_for_sku says you will not receive the whole table. Compare process(data), which promises nothing.

DTOs: commands in, views out

Do not let a service signature grow four positional strings, and do not hand your domain object to a caller. Take a command - a frozen dataclass that validates itself - and return a view.

code/fundamentals/contracts.py - the command and the two outcomes
class RejectionReason(StrEnum):
    OUT_OF_STOCK = auto()
    ITEM_WITHDRAWN = auto()


@dataclass(frozen=True, slots=True)
class ReserveStock:
    """The command DTO: everything the call needs, checked once at the boundary.

    A frozen dataclass beats four positional arguments - the signature stops growing,
    the caller cannot swap two strings, and ``replace`` builds a variant in one line.
    """

    sku: str
    quantity: int
    order_id: str
    idempotency_key: str

    def __post_init__(self) -> None:
        if self.quantity <= 0:
            raise ValidationError(f"quantity must be positive, got {self.quantity}")
        if not (self.sku and self.order_id and self.idempotency_key):
            raise ValidationError("sku, order_id and idempotency_key are all required")


@dataclass(frozen=True, slots=True)
class Reserved:
    """The success outcome. Carries the id the client needs for the next call."""

    reservation_id: str
    sku: str
    quantity: int
    expires_at: float


@dataclass(frozen=True, slots=True)
class Rejected:
    """The *expected* failure. A value, not an exception: the caller must handle it,
    and it carries the number that lets the caller decide what to do next.
    """

    sku: str
    reason: RejectionReason
    available: int


type ReservationResult = Reserved | Rejected

__post_init__ means an invalid ReserveStock cannot exist, so no downstream code re-checks the quantity. The DTO is also the natural place for the idempotency_key: it is part of the request, not an extra argument someone forgets to thread through.

Result objects or exceptions?

The dividing line is whose fault it is. If the caller broke the contract - an unknown SKU, a malformed command, a page size of zero - raise: the caller has a bug and a stack trace is the right feedback. If the domain simply says no - out of stock, item withdrawn, coupon expired - return a value. Those outcomes are part of the normal flow, they carry data the caller needs (available=1 tells the UI what to offer), and a returned union forces the caller to handle them:

match service.reserve(command):
    case Reserved(reservation_id=reservation_id):
        take_payment(reservation_id)
    case Rejected(reason=reason, available=available):
        offer_alternative(reason, available)

type ReservationResult = Reserved | Rejected is the whole "result object" machinery Python needs; no Either library. match checks nothing at runtime; close it with case _ as other: assert_never(other) so the type checker fails the build when a third outcome arrives. Reach for exceptions when the failure must unwind several frames, and for results when the immediate caller has a decision to make.

Preconditions, postconditions and invariants

Three different promises, and interviewers score you for naming them separately.

  • Precondition: what must be true when the method is called. It is the caller's obligation, so a violation raises a domain error.
  • Postcondition: what the method guarantees on return - "available drops by exactly quantity".
  • Invariant: what is true of the object at every observable moment - here, 0 <= reserved <= on_hand.
code/fundamentals/contracts.py - guard the pre, assert the invariant
@dataclass(slots=True)
class StockItem:
    """Invariant: ``0 <= reserved <= on_hand`` at every observable moment.

    Preconditions are guard clauses that raise a domain error - the caller broke the
    contract. The invariant is re-checked before returning; if it fails, *this* method
    is the bug, so it raises ``AssertionError`` rather than a domain error.
    """

    sku: str
    on_hand: int
    reserved: int = 0
    withdrawn: bool = False

    @property
    def available(self) -> int:
        return self.on_hand - self.reserved

    def reserve(self, quantity: int) -> None:
        """Pre: ``0 < quantity <= available``. Post: ``available`` drops by exactly that."""
        if quantity <= 0:
            raise ValidationError(f"quantity must be positive, got {quantity}")
        if quantity > self.available:
            raise ValidationError(f"only {self.available} of {self.sku} available")
        self.reserved += quantity
        self._check_invariant()

    def release(self, quantity: int) -> None:
        """Pre: ``0 < quantity <= reserved``. Post: ``available`` rises by exactly that."""
        if not 0 < quantity <= self.reserved:
            raise ValidationError(f"cannot release {quantity} of {self.reserved} reserved")
        self.reserved -= quantity
        self._check_invariant()

    def _check_invariant(self) -> None:
        if not 0 <= self.reserved <= self.on_hand:
            raise AssertionError(f"{self.sku}: 0 <= {self.reserved} <= {self.on_hand} is false")

The asymmetry is deliberate. A precondition failure raises ValidationError, because the caller sent something impossible. An invariant failure raises AssertionError, because this class is broken - it should be impossible, and if it happens you want the crash in the method that caused it rather than three calls downstream. Put the invariant in one private method and call it at the end of every mutator; it costs a comparison and it turns a future refactor's silent corruption into a failing test.

Idempotent service methods

Any write a client can retry - and every network client retries - needs to be safe to call twice. The mechanism is a client-supplied idempotency key: the service records the key with the result it produced, and a replay returns the stored result instead of doing the work again.

code/fundamentals/contracts.py - one lock, one key, one effect
class ReservationService:
    """One intention-revealing method per use case, and a contract for each.

    ``reserve`` is idempotent: replaying a command with the same ``idempotency_key``
    returns the first result instead of taking stock twice, which is what makes a
    client retry after a timeout safe. It raises only when the *caller* is wrong
    (unknown sku, malformed command) and returns ``Rejected`` when the *domain* says
    no, so an out-of-stock answer never travels as a stack trace.
    """

    def __init__(
        self,
        stock: StockRepository,
        log: ReservationLog,
        ids: IdGenerator,
        clock: Clock,
    ) -> None:
        self._stock = stock
        self._log = log
        self._ids = ids
        self._clock = clock
        self._results: dict[str, ReservationResult] = {}
        self._lock = threading.Lock()  # guards _results and the read-modify-write on stock

    def reserve(self, command: ReserveStock) -> ReservationResult:
        with self._lock:
            replayed = self._results.get(command.idempotency_key)
            if replayed is not None:
                return replayed
            item = self._stock.get(command.sku)
            result = self._decide(item, command)
            self._results[command.idempotency_key] = result
            return result

    def _decide(self, item: StockItem, command: ReserveStock) -> ReservationResult:
        if item.withdrawn:
            return Rejected(command.sku, RejectionReason.ITEM_WITHDRAWN, item.available)
        if command.quantity > item.available:
            return Rejected(command.sku, RejectionReason.OUT_OF_STOCK, item.available)
        item.reserve(command.quantity)
        self._stock.save(item)
        reserved = Reserved(
            self._ids.next_id(), command.sku, command.quantity, self._clock.now() + HOLD_SECONDS
        )
        self._log.append(reserved)
        return reserved

    def reservations_for(
        self, sku: str, limit: int = DEFAULT_PAGE_SIZE, cursor: str | None = None
    ) -> Page[Reserved]:
        return self._log.page_for_sku(sku, limit, cursor)

Three details worth saying out loud. The key is stored with the result, not as a "seen" flag, so the replay returns the same reservation id rather than a fresh rejection. The lookup, the decision and the store all happen under one lock, so two threads replaying the same key cannot both take stock - the concurrency test fires 60 commands over 30 keys at 10 units and asserts exactly 10 reservations. And in production the key store needs a TTL and an in-progress marker, which is where this design meets API design for HLD rounds.

Note what is not idempotent by key: a natural release(reservation_id) is idempotent by identity, because releasing an already-released hold is a no-op. Prefer that shape when you can get it.

Pagination belongs in the repository contract

A method that returns list[T] has promised to return the whole table. Return a page instead, and page by cursor, not offset.

code/fundamentals/contracts.py - a page is a value, the cursor is a key
@dataclass(frozen=True, slots=True)
class Page[T]:
    """A page is a value: the items plus the cursor that resumes after the last one.

    ``next_cursor is None`` means "that was the end". An empty page with a cursor is
    legal, so the field is a cursor rather than a ``has_more`` boolean.
    """

    items: tuple[T, ...]
    next_cursor: str | None = None


class SortableIdGenerator:
    """Zero-padded ids, because a keyset cursor must be unique *and* totally ordered."""

    def __init__(self, prefix: str = "RES", width: int = 4) -> None:
        self._prefix = prefix
        self._width = width
        self._counter = itertools.count(1)
        self._lock = threading.Lock()

    def next_id(self) -> str:
        with self._lock:
            return f"{self._prefix}-{next(self._counter):0{self._width}d}"


class InMemoryReservationLog:
    """Keyset pagination: order by a unique immutable key, resume strictly after it.

    An offset drifts when rows are inserted between two pages; a cursor over the id
    does not, which is why repositories in this handbook page by cursor.
    """

    def __init__(self) -> None:
        self._by_sku: dict[str, list[Reserved]] = {}

    def append(self, reserved: Reserved) -> None:
        self._by_sku.setdefault(reserved.sku, []).append(reserved)

    def page_for_sku(
        self, sku: str, limit: int = DEFAULT_PAGE_SIZE, cursor: str | None = None
    ) -> Page[Reserved]:
        if not 0 < limit <= MAX_PAGE_SIZE:
            raise ValidationError(f"limit must be in 1..{MAX_PAGE_SIZE}, got {limit}")
        rows = sorted(self._by_sku.get(sku, []), key=lambda row: row.reservation_id)
        start = 0 if cursor is None else bisect_right([row.reservation_id for row in rows], cursor)
        window = tuple(rows[start : start + limit])
        exhausted = start + limit >= len(rows)
        return Page(window, None if exhausted or not window else window[-1].reservation_id)

An OFFSET 40 query re-runs the sort and skips forty rows; if a row was inserted before position 40 between the two calls, the client sees one row twice and, on a delete, misses one entirely. A keyset cursor - "give me the rows whose id is greater than RES-0004" - is stable under concurrent writes and lets the database seek straight to the position on an index. The price is that the sort key must be unique and totally ordered, which is why SortableIdGenerator zero-pads: RES-0010 sorts after RES-0009, while RES-10 sorts before RES-9.

Bound the page size in the contract (1..100 here) so a caller cannot ask for the table by passing limit=10_000_000, and treat next_cursor is None as the only end-of-data signal.

From domain method to REST resource

The mapping is mechanical once the service interface is right: design the domain call first, then expose it. release is the method this service would grow next.

Service method HTTP Response
reserve(ReserveStock) POST /skus/{sku}/reservations with an Idempotency-Key header 201 and the Reserved body; 409 and the Rejected body with a machine-readable reason
reservations_for(sku, limit, cursor) GET /skus/{sku}/reservations?limit=20&cursor=RES-0004 200 with {"items": [...], "next_cursor": "..."}
release(reservation_id) DELETE /reservations/{id} 204, and 204 again on a repeat

Nouns become resources, verbs become methods, result objects become status codes, and the DTO becomes the request body. Command DTOs whose intent is not CRUD get an action sub-resource (POST /reservations/{id}/extend) rather than a verb in the path. The full treatment - error envelopes, opaque cursors, Retry-After, long-running operations - is in API design for HLD rounds.

Versioning and extensibility

A published contract can only grow. Adding an optional field with a default is compatible; removing a field, renaming it, changing its type, or turning an optional field into a required one is not.

code/fundamentals/contracts.py - additive by construction
@dataclass(frozen=True, slots=True)
class ReservationView:
    """The response DTO: additive, versioned, and never the domain object itself.

    A new field arrives with a default so an old client keeps working. A field is
    never removed or re-typed in place - that is a new version of the resource, not
    an edit to this one.
    """

    reservation_id: str
    sku: str
    quantity: int
    expires_at: float
    schema_version: int = CURRENT_SCHEMA_VERSION
    warehouse: str | None = None  # added in v2; absent on records written by v1

    @classmethod
    def of(cls, reserved: Reserved, warehouse: str | None = None) -> Self:
        return cls(
            reserved.reservation_id,
            reserved.sku,
            reserved.quantity,
            reserved.expires_at,
            warehouse=warehouse,
        )

    def to_payload(self) -> dict[str, object]:
        """Omit unset optional fields instead of sending nulls: a client that has not
        heard of ``warehouse`` sees the same document it saw before v2.
        """
        payload: dict[str, object] = {
            "reservation_id": self.reservation_id,
            "sku": self.sku,
            "quantity": self.quantity,
            "expires_at": self.expires_at,
            "schema_version": self.schema_version,
        }
        if self.warehouse is not None:
            payload["warehouse"] = self.warehouse
        return payload

Two habits make that easy. Omit unset optional fields from the payload rather than sending null, so a client written before warehouse existed receives byte-for-byte what it always received. And keep the view separate from the domain object, so adding an internal field to StockItem never leaks into a response. When a change really is breaking, it is a new resource version, not an edit.

Running python -m fundamentals.contracts:

--- reserve 3 of SKU-A -> RES-0001, holds until 1700000900.0 ---
same idempotency key replayed -> the same result object: True
expected failure is a value: out_of_stock, 1 left - no exception raised
--- keyset pagination over the reservation log ---
cursor=None -> ['RES-0001', 'RES-0002']
cursor='RES-0002' -> ['RES-0003', 'RES-0004']
cursor='RES-0004' -> ['RES-0005']
v2 payload: {'reservation_id': 'RES-0001', 'sku': 'SKU-A', 'quantity': 3, 'expires_at': 1700000900.0, 'schema_version': 2, 'warehouse': 'LON-1'}
v1-shaped payload omits the new field: {'reservation_id': 'RES-0001', 'sku': 'SKU-A', 'quantity': 3, 'expires_at': 1700000900.0, 'schema_version': 2}
broken precondition raises: no stock record for 'SKU-MISSING'
the DTO refuses to exist: quantity must be positive, got 0

Applying it in the interview

Interface design is the step between the class diagram and the code, and it is where SDE2 candidates most often lose points by going straight to implementation. After you have drawn the entities, write the service signatures on the board - three or four lines - and narrate the contract for each: "reserve takes a command with an idempotency key, returns Reserved or Rejected, raises only for an unknown SKU, and leaves 0 <= reserved <= on_hand true." You have now answered the retry question, the error-handling question and the concurrency question before they were asked.

When the interviewer pushes on failure modes, use the fault line: caller error raises, domain outcome returns. When they push on scale, the list method is where you say "cursor, not offset, and a bounded limit". When they push on evolution, say "additive fields with defaults; a breaking change is a new version". These are the same moves Design Amazon (cart, order, inventory, payment) makes at full size, and the Repository pattern is where the persistence half of the contract lives.

Interview tip

Say the contract in one breath before you write the body: "preconditions, result, and what is still true afterwards." Interviewers grade whether you thought about the promise; writing def reserve(self, command: ReserveStock) -> ReservationResult: and then explaining those three things takes fifteen seconds and reads as senior.

Pitfalls

  • Returning the domain entity. Hand back StockItem and every client depends on on_hand, so you can never rename it. Return a view.
  • Exceptions for expected outcomes. OutOfStockError forces every caller into a try block and throws away the available count they needed. Out of stock is an answer, not an accident.
  • list[T] on a repository. It works with ten rows in a demo and falls over at a million, and changing the signature later breaks every caller. Return a page from the start.
  • Idempotency keys generated by the server. The point is that the client can retry the identical request; a server-side key changes on every attempt and buys nothing.
  • Boolean parameters in the signature. reserve(command, True) is unreadable at the call site. Use a keyword-only argument, an enum, or two methods.
  • A Protocol that mirrors one implementation. If StockRepository grows execute_sql, it has stopped being a contract and become a leaky wrapper around the database.

Common mistake

Adding a required field to a published DTO and calling it "just a small change". Every client that has not been redeployed now sends a request that fails validation, and the failure lands on whoever deployed last. New fields are optional with a default, always; if the field genuinely must be required, it is a new version of the endpoint running alongside the old one until the callers have moved.

Exercises

  1. Should release(reservation_id) take an idempotency key?

    Solution

    No. It is already idempotent by identity: the reservation id names the exact effect, so releasing twice is a no-op and the second call returns the same 204. Idempotency keys exist for creates, where the request carries no identifier for the thing it is about to make. Knowing when you do not need the mechanism is a stronger signal than knowing it.

  2. The reservation hold expires after fifteen minutes. Where does that belong in the contract?

    Solution

    In the returned value, as expires_at, so the caller can decide what to do - which is why Reserved carries it. The expiry itself is enforced by a sweeper that releases stale holds, and the invariant 0 <= reserved <= on_hand is what makes that sweeper safe to run concurrently. Do not put it in a constant the caller cannot see: a promise the client cannot read is not part of the contract.

  3. A caller asks for reservations_for(sku, limit=1000). What should happen, and why not just serve it?

    Solution

    Raise ValidationError, because the contract says 1..100. Serving it makes your tail latency a function of whatever a client types, and once one client depends on 1000 you can never lower the cap. State the maximum in the error message so the caller can fix it without reading your source.

  4. StockItem.reserve raises when the quantity exceeds availability, but ReservationService returns Rejected for the same situation. Is that inconsistent?

    Solution

    No - they are contracts at different layers. StockItem is an internal invariant-keeper: by the time anyone calls its reserve, availability has been checked, so exceeding it is a bug and raising is right. ReservationService is the boundary the outside world calls, where "not enough stock" is an ordinary business answer. Converting between the two is exactly what a service layer is for.