Card 08/ 09

ExerciseDifficulty: Advanced2 min

Practice: Removing a Singleton From a Payment Service

The service calls Database.instance() inside every method, and its tests need a real database.

A payment service that reaches its database through a singleton cannot be tested without that database, and this exercise asks you to remove the singleton while keeping one database connection. Here is the service as it stands.

py
from typing import Optional


class Database:
    _instance: Optional["Database"] = None

    @classmethod
    def instance(cls) -> "Database":
        if cls._instance is None:
            print("connecting to the production database")
            cls._instance = cls()
        return cls._instance

    def save_payment(self, order_id: str, amount: int) -> None:
        print(f"INSERT payment {order_id} {amount}")

    def balance(self, customer: str) -> int:
        return 120


class PaymentService:
    def charge(self, customer: str, order_id: str, amount: int) -> bool:
        if Database.instance().balance(customer) < amount:
            return False
        Database.instance().save_payment(order_id, amount)
        return True


print(PaymentService().charge("ada", "A-1042", 80))
OutputRan with python:3.12-slim (Python 3.12.13)
connecting to the production database
INSERT payment A-1042 80
True
`charge` reaches the database through the class, twice.
  1. Say why a test of charge with a low balance would need the production database.
  2. Change PaymentService so that it receives what it needs, and leave Database with no class-level instance.
  3. Write two tests, a low balance and an enough balance, that run without connecting to anything.
  4. Show that the application still creates exactly one database connection.
Show a solution
py
from typing import Protocol


class PaymentStore(Protocol):
    def save_payment(self, order_id: str, amount: int) -> None: ...

    def balance(self, customer: str) -> int: ...


class Database:
    def __init__(self) -> None:
        print("connecting to the production database")

    def save_payment(self, order_id: str, amount: int) -> None:
        print(f"INSERT payment {order_id} {amount}")

    def balance(self, customer: str) -> int:
        return 120


class PaymentService:
    def __init__(self, store: PaymentStore) -> None:
        self._store = store

    def charge(self, customer: str, order_id: str, amount: int) -> bool:
        if self._store.balance(customer) < amount:
            return False
        self._store.save_payment(order_id, amount)
        return True


class FakeStore:
    def __init__(self, balance: int) -> None:
        self._balance = balance
        self.saved: list[tuple[str, int]] = []

    def save_payment(self, order_id: str, amount: int) -> None:
        self.saved.append((order_id, amount))

    def balance(self, customer: str) -> int:
        return self._balance


poor = FakeStore(balance=50)
print("test, low balance:", PaymentService(poor).charge("ada", "A-1", 80), poor.saved)
rich = FakeStore(balance=500)
print("test, enough:     ", PaymentService(rich).charge("ada", "A-2", 80), rich.saved)

database = Database()
service = PaymentService(database)
print("application:", service.charge("ada", "A-1042", 80))
OutputRan with python:3.12-slim (Python 3.12.13)
test, low balance: False []
test, enough:      True [('A-2', 80)]
connecting to the production database
INSERT payment A-1042 80
application: True
The store is passed in; tests pass fakes, and the application passes one real database.

charge called Database.instance(), which connects to production on first use, so any test of it connected too. In the solution, PaymentService receives a PaymentStore in its constructor, a Protocol that names the two methods the service uses. The tests pass a FakeStore with the balance they need and then check what it saved, and "connecting to the production database" does not appear until the application creates its one Database.

The program still has one database connection, made once and passed to the service. The singleton's first promise, one instance, still holds, and only its second, the global point of access that made testing hard, has been removed.