Implementing FastAPI Services – Abstraction and Separation of Concerns
FastAPI application and service structure for a more maintainable codebase
This article introduces an approach to structure FastAPI applications with multiple services in mind. The proposed structure decomposes the individual services into packages and modules, following principles of abstraction and separation of concerns. The code discussed below can also be studied in its entirety in a dedicated companion GitHub repository.
FastAPI – Building High-performing Python APIs
FastAPI is a fast, highly intuitive and well-documented API framework based on Starlette. Despite being relatively new, it’s gaining strong adoption by the developer community – it is even already being adopted in production by corporates like Netflix and Microsoft.
Following the UNIX philosophy of “doing one thing, and doing it well”, separating parts of the application according to their task improves code readability and maintainability; ultimately reducing complexity. The main benefits of structuring applications in this way:
Separation of concerns – Decomposing the application into modules performing a single job. This allows accepting requests (top down: controller → service → data access) and returning responses (bottom up: data access → service → controller) with a clear separation of what particular functionality should be implemented in which particular module, reducing cognitive load for development.
Abstraction — Components of the application are designed in a reusable way. For instance, ServiceResult
is implemented as a generic outcome of a service operation (which may be successful and return a response, or unsuccessful and raise an exception) able to be used by all services of the app, keeping code DRY.
The granular nature of namespacing allow to distinguish parts of the application, e.g., routes versus business logic belonging to a particular service – grouping similar tasks together, while keeping distinct parts separated. Four principal packages are needed. For each service, one module is added to these four packages. For instance, a service called “Foo” requires the following modules (discussed in detail below):
The four principal packages are complemented by two generic packages which contain application-specific (and not service-specific) functionality, such as configuration or utility functions.
Controller Layer – Routes
Within main
, the application is instantiated and all routers are included. Additionally, middlewares and/or exception handlers are implemented. The example application discussed below is based on a service called Foo
, which requires a number of routes. To handle custom exceptions occurring at the service layer, as instances of class AppExceptionCase
, a respective exception handler is added to the application.
Routers and their routes are defined in modules within the routers
package. Each route instantiates the respective service and passes on the database session from the request dependency. Handled by handle_result()
, the service result (either the requested data as the result of a successful operation, or an exception) is returned. In case of an exception, instead of (and before) returning any response, the app exception handler in main
picks up handling the exception and returns a response.
Service Result Class – Success & Exceptions
The ServiceResult
class defines a generic outcome of a service operation. In case the operation is successful, the outcome (or “value”) is returned contained within the value attribute of the instance. In case of a custom app exception, the service result instance contains information about the raised exception (e.g., which status code should be returned to the client).
Service Layer – Business Logic
Services are defined in the services
package. Each service is a subclass of AppService
. The database session is passed down from the request dependency via an “interface-like” mixin utility class (other mixin classes may be added via multiple inheritance in order to extend available attributes).
Routes belonging to service Foo
are connected to methods of FooService
, which encapsulates all business logic of the service. The return value are of type ServiceResult
: Containing a value attribute with returnable data or, in case of an exception, an AppException
. In both cases, the respective result is returned back “upwards” to the controller layer.
Data Access Layer – Database Operations
CRUD helper methods perform operations on the database and are subclassing AppCRUD
. The database session is passed down from the AppService
instance. These methods are atomic and only concerned with operating on the database. They do not contain any business logic.
Pydantic “schemas” or models are defined in the schemas
package. They contain mainly two different kinds of data models. First, those expected from clients as request data (route parameters in route method definitions). Second, those expected to be returned to clients as response data (defined in response_model parameter of route definitions).
Additionally, it’s possible to model any kind of data being passed inbetween the layers of the app (controller, service and data-access).
SQLAlchemy models are defined in the models
package. They define how data is stored within the relational database. They are referenced from AppCRUD
. If needed, make sure to differentiate between FooItem
models (SQLAlchemy) and FooItem
schemas (Pydantic) by appropriate import namespacing.
Exception Handling
App exceptions are implemented in the utils
package. First, AppExceptionCase
is subclassed from base Exception
and includes various attributes for defining custom app exception scenarios. The exception handler with the task of handling custom app exceptions (added to main
, see above) is defined with a response containing information about the app exception.
Second, defining and documenting custom exception scenarios happens in the same module and requires subclassing from AppExceptionCase
. Each app exception includes a description within the docstring and defines the status code to be returned to the client.
The class names are reported back to inform clients of the particular exception scenario - see AppExceptionCase
initalization, and JSONResponse
within the app exception handler.
It’s possible to add context to an exception AppException.FooCreateItem({"id": 123})
in order to inform the client of an exception’s context, if needed.
To compile a list of all exceptions (e.g., in order to localize app exception messages displayed to the client), access all attributes within the scope of AppException
:
Conclusion
The proposed approach allows designing FastAPI applications which are highly structured to accomodate multiple services, allowing the implementation of any number of services in a unified pattern.
I hope you enjoyed this article! I’m curious to hear your thoughts regarding this proposed structure and look forward to learn from alternative approaches - feel free to start a discussion by opening an issue on GitHub in the companion repository to this article, or reach out to me directly.