The Service Layer Pattern
Overview
If we look at what the api with the repository endpoints are doing, we'll agree that are very simple. But what usually happens is that business applications typically require different kinds of interfaces (API, user interface, command line client, etc) to the data and the logic they implement (3rd party integrations, repositories,...).
The same concept used in the repository pattern to create a common interface for the data, can be used to define a common interaction with the application to invoke the business logic: fetching data from the repository, validating inputs against current state, handling errors, etc. And most of this things don't have anything to do with having a web aPI endpoint.
This is what we might call orchestration layer , use-case layer or service layer.
Example
To continue with our simple examples, we'll create a service to paginate results.
Let's start creating the test file, as usual:
touch tests/test_04_api_with_services_and_repository.py
First Test and Dependency Injection
To implement the service layer, we know that we'll need a mechanism to inject the in-memory repository to the service.
To do this, we define a function get_repo_test
that returns an InMemoryBookRepository
with a list of
25 new books, that will be used to return paginated results.
The test, overrides the app dependencies with this repository, and do a request to a new endpoint, asking for a page of results.
tests/test_04_api_with_services_and_repository.py
@@ -0,0 +1,32 @@
+import pytest
+
+from tests.repositories import InMemoryBookRepository
+from tests.schemas import Book
+
+
+def get_repo_test() -> InMemoryBookRepository:
+ repo_test = InMemoryBookRepository(initial_books=dict())
+ for book_id in range(0, 25):
+ repo_test.add(Book(title=f"title {book_id}", author=f"author {book_id}"))
+ return repo_test
+
+
+@pytest.mark.parametrize(
+ "page_num, results_count, expected_status", (
+ (1, 10, 200),
+ (3, 5, 200),
+ (5, 0, 200),
+ (1000, 0, 200),
+ ),
+)
+def test_pagination_service_successfully(app, client, page_num, results_count, expected_status) -> None:
+ app.dependency_overrides[InMemoryBookRepository] = get_repo_test
+
+ response = client.get(f"/books?page_num={page_num}")
+ assert response.status_code == expected_status
+
+ content = response.json()
+ assert "page" in content
+ assert content["page"] == page_num
+ assert "content" in content
+ assert len(content["content"]) == results_count
Of course, this test fails, because the endpoint is not yet defined.
Create Service and Endpoint
At this point, we can imagine that the new service will be used from other tests, and perhaps in the future, from a real application. for this reason, we create it in a new file:
{git_diff_by_commit a5f556cd..8f447f5 - tests / services.py}
Since we have a conftest.py
with an app fixture, we'll continue to extend it with the new endpoint, which uses the
previous service.
tests/conftest.py
@@ -1,19 +1,20 @@
from typing import List
from uuid import UUID
import pytest
from fastapi import FastAPI, Depends
from starlette.testclient import TestClient
from tests.repositories import InMemoryBookRepository
from tests.schemas import Book
+from tests.services import BookPaginationService
@pytest.fixture
def app():
app = FastAPI()
@app.get("/", name="books:get-all-books")
def get_all_books(
books_repo: InMemoryBookRepository = Depends(InMemoryBookRepository)
) -> List[Book]:
@@ -29,20 +30,28 @@
return book
@app.post("/", name="books:create-book", status_code=201)
def create_new_book(
book: Book,
books_repo: InMemoryBookRepository = Depends(InMemoryBookRepository)
) -> Book:
book_db = books_repo.add(book)
return book_db
+ @app.get("/books")
+ def get_paginated_books(
+ page_num: int = 1,
+ book_pagination: BookPaginationService = Depends(BookPaginationService),
+ ):
+ paginated_books = book_pagination.get_page(num=page_num)
+ return paginated_books
+
return app
@pytest.fixture
def client(app):
return TestClient(app)
@pytest.fixture()
def a_book() -> Book:
Update Repository
We need that our repository returns only the requested items. To do this, we modify the list
method to accept pagination params:
tests/repositories.py
@@ -30,12 +30,12 @@
def add(self, new_book: Book) -> BookInDB:
if not isinstance(new_book, Book):
raise ValueError("This repository only accepts Book objects.")
book = BookInDB(**new_book.dict())
self.books[book.id] = book
return book
def get(self, book_id: UUID) -> Optional[BookInDB]:
return self.books.get(book_id, None)
- def list(self) -> List[BookInDB]:
- return [book for book_id, book in self.books.items()]
+ def list(self, skip=0, offset=5) -> List[BookInDB]:
+ return list(self.books.values())[skip * offset:(skip + 1) * offset]
Add Test for Invalid Values
We need to be sure that the system is as robust as possible, and that handle correctly invalid values for pagination requests. Let's add a test to check invalid values, using the parametrized decorator:
tests/test_04_api_with_services_and_repository.py
@@ -23,10 +23,25 @@
app.dependency_overrides[InMemoryBookRepository] = get_repo_test
response = client.get(f"/books?page_num={page_num}")
assert response.status_code == expected_status
content = response.json()
assert "page" in content
assert content["page"] == page_num
assert "content" in content
assert len(content["content"]) == results_count
+
+
+@pytest.mark.parametrize(
+ "page_num, expected_status", (
+ (-1, 422),
+ (0, 422),
+ ('0', 422),
+ ('a', 422),
+ ),
+)
+def test_pagination_service_fails(app, client, page_num, expected_status) -> None:
+ app.dependency_overrides[InMemoryBookRepository] = get_repo_test
+
+ response = client.get(f"/books?page_num={page_num}")
+ assert response.status_code == expected_status
Fix Code
With the previous test, we realize that some invalid input values are accepted. Let's fix it adding input data validation to the endpoint:
tests/conftest.py
@@ -1,15 +1,16 @@
from typing import List
from uuid import UUID
import pytest
from fastapi import FastAPI, Depends
+from pydantic.types import PositiveInt
from starlette.testclient import TestClient
from tests.repositories import InMemoryBookRepository
from tests.schemas import Book
from tests.services import BookPaginationService
@pytest.fixture
def app():
app = FastAPI()
@@ -32,21 +33,21 @@
@app.post("/", name="books:create-book", status_code=201)
def create_new_book(
book: Book,
books_repo: InMemoryBookRepository = Depends(InMemoryBookRepository)
) -> Book:
book_db = books_repo.add(book)
return book_db
@app.get("/books")
def get_paginated_books(
- page_num: int = 1,
+ page_num: PositiveInt = 1,
book_pagination: BookPaginationService = Depends(BookPaginationService),
):
paginated_books = book_pagination.get_page(num=page_num)
return paginated_books
return app
@pytest.fixture
def client(app):
All green!