Skip to main content

Service Lifecycle and Singleton Pattern

The BookmarkService acts as the central orchestrator for the application, managing the lifecycle of data and coordinating interactions between the persistence layer, the search index, and the caching mechanism. Because the application relies on in-memory storage, the service implementation uses specific patterns to ensure data consistency and availability across different modules.

The Singleton Pattern

The BookmarkService is implemented as a singleton to ensure that all parts of the application—specifically the various Flask blueprints—interact with the same in-memory data set. In app/services/bookmark_service.py, this is achieved by overriding the __new__ method:

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

def __new__(cls) -> "BookmarkService":
"""Singleton — share state across blueprint modules."""
if cls._instance is None:
cls._instance = super().__new__(cls)
cls._instance._init_services()
return cls._instance

This design choice is critical because the BookmarkRepository stores data in local memory. If multiple instances of the service were created, each would have its own isolated repository, leading to inconsistent states where a bookmark created in one route would be invisible to another. By using a singleton, modules like app/routes/bookmarks.py and app/routes/tags.py can both instantiate the service locally while sharing the same underlying state:

# app/routes/bookmarks.py
from app.services.bookmark_service import BookmarkService

bookmarks_bp = Blueprint("bookmarks", __name__)
_service = BookmarkService() # Returns the global singleton instance

Internal Initialization and Wiring

When the singleton instance is first created, it triggers the _init_services method. This method is responsible for bootstrapping the three core components that the service orchestrates:

  1. BookmarkRepository: The primary in-memory data store.
  2. LRUCache: A fixed-size cache (defaulting to 256 items) used to accelerate individual bookmark lookups.
  3. SearchIndex: A full-text search component that requires a reference to the repository to build its initial index.
def _init_services(self) -> None:
"""Bootstrap repository, cache, and search index."""
self._repo = BookmarkRepository()
self._cache: LRUCache[Bookmark] = LRUCache(max_size=256)
self._search = SearchIndex(self._repo)

This internal wiring ensures that the SearchIndex is populated immediately upon service startup, as the SearchIndex constructor typically iterates over the repository to build its inverted index.

Coordination and the Facade Pattern

The BookmarkService functions as a Facade, providing a simplified API to the route handlers while hiding the complexity of keeping the repository, search index, and cache in sync.

Every mutation operation (create, update, delete) follows a strict coordination sequence to maintain consistency. For example, in create_bookmark, the service performs validation, persists the object, updates the search index, and ensures the cache does not contain stale data:

def create_bookmark(self, data: Dict[str, Any]) -> Tuple[Optional[Bookmark], Optional[str]]:
# ... validation logic ...
bookmark = Bookmark.from_dict(data)

# 1. Persist to the primary store
self._repo.save_bookmark(bookmark)

# 2. Update the search index for full-text queries
self._search.index_bookmark(bookmark)

# 3. Invalidate the cache to prevent stale reads
self._cache.invalidate(bookmark.id)

return bookmark, None

This pattern is also vital for cross-entity operations. When a tag is deleted via delete_tag, the service iterates through all bookmarks associated with that tag, updates them in the repository, and invalidates their respective cache entries to ensure the change is reflected globally.

Lifecycle Management in Testing

Because the service is a singleton with in-memory state, it persists data across the entire lifetime of the process. This presents a challenge for automated testing, where a clean state is required for each test case. To address this, the service provides a _reset method:

def _reset(self) -> None:
"""Tear down and reinitialise — used in tests only."""
self._init_services()

By calling _reset, the service discards the existing repository, cache, and search index, effectively wiping the application state without requiring a process restart.

Design Tradeoffs and Constraints

The current implementation involves several architectural tradeoffs:

  • Manual Cache Invalidation: The service is responsible for manually calling self._cache.invalidate(). This places a burden on the developer to ensure that every new mutation method correctly handles the cache, as the repository itself has no awareness of the caching layer.
  • Thread Safety: The singleton implementation does not include explicit locking mechanisms. While suitable for single-threaded development servers, concurrent writes to the in-memory repository or search index could lead to race conditions in a multi-threaded production environment.
  • Memory Bound: Since the lifecycle of the data is tied to the lifecycle of the BookmarkService instance, the total volume of bookmarks is limited by the available RAM. There is no background persistence to disk, meaning all data is lost when the service stops.