Skip to content

This is a modular monolith Fast API project that uses the latest and greatest tooling (uv, ruff, pyright, pydantic, pytest, fastapi, sqlmodel, etc) attempting to implement a modular monolith architecture. The repository include pre-commit hooks for ruff, pyright, and uv.

Notifications You must be signed in to change notification settings

YoraiLevi/modular-monolith-fastapi

Repository files navigation

Modular Monolith Fast API

This is a modular monolith Fast API project that uses the latest and greatest tooling (uv, ruff, pyright, pydantic, pytest, fastapi, sqlmodel, etc) attempting to implement a modular monolith architecture. The repository include pre-commit hooks for ruff, pyright, and uv.

For quick setup instructions, jump to the Setup and development section. This project serves as a proof of concept and starting point for building modular monolith applications with FastAPI. For technical details, see Project Implementation

Architecture and Design

What's a modular monolith anyway?

I don't understand the point of modular monolithic, me neither.. a monolith? modules? big ball of mud? actually I never heard of the term "modular monolith" until I started working on this project so i searched around. There is certinly some hype around the idea of modular monoliths, there are framewworks like Lato a Python microframework designed for building modular monoliths and loosely coupled applications. or Actio ... enables you to effortlessly switch between monolithic and microservices architectures. but that doesn't tell me what a modular monolith is!

I collected a few articles trying to explain what a modular monolith, some in support of the idea and some say it doesn't exist whatsoever. Baffling.

What is a modular monolith? | Actually Talking about Modular Monoliths | The Mythical Modular Monolith | Long live the Monolith! Monolithic Architecture != Big Ball of Mud | Modular Monoliths • Simon Brown • GOTO 2018 | Microservices vs Monolithic Architecture | Cross module communication in modular monolith | What Is a Modular Monolith? | Structuring Modular Monoliths | Modular Monolith: A Primer | Modular Monolith: Is This the Trend in Software Architecture? | Microservices Killer: Modular Monolithic Architecture | Modular monolith in Python | Modular Monoliths: Revolutionizing Software Architecture for Efficient Payment Systems in Fintech | How modular can your monolith go? Part 7 - no such thing as a modular monolith?

I think these two fellows in the comments summarize the concensous best:

Don't start with microservices – monoliths are your friend

no one ever talks about architectures in the middle between those two - modular monoliths (KronisLV)
Because there is no newly invented architecture called "modular monolith" - monolith was always supposed to be MODULAR from the start. (ozim)

So... if you read between the lines modular monoliths are just a buzzword for "good software engineering practices"?

What does the modular monolith solve?

If we think about Microservices Communications, the main difference between microservices and modular monoliths is that "One runs on a single process and the other runs on multiple processes". They both apply "Separation of Concerns" and "Single Responsibility" principles, just at different abstraction levels.

The true argument for and against mono-centric vs distributed concern management is Conway's Law, which states that "organizations design systems that mirror their communication structure". So the choice between monolith and microservices often comes down to team structure - small teams benefit from monoliths, while multiple teams might prefer microservices.

Communication between Software Components

The 2x2 grid above, commonly found in articles about microservices versus modular monoliths, splits software architecture into two axes:

  • Logical: How the software is organized and separated
  • Physical: Where the software runs

Most GitHub projects claiming to implement modular monoliths actually implement a normal monolith with good separation of concerns. All parts of the software must communicate, typically in one of two ways:

  1. Function Calls (Local Procedure Calls): Direct function calls between different parts of the software
  2. RPC (Remote Procedure Calls): Function calls that can work across different machines

If we assume our software is "well-designed" and "modular", we're really only working with the physical axis - determining where the software runs, not how it communicates internally.

The 2x2 grid could be simplified to a single line representing "openness to communication":

alt text

A practical example of this is creating a "self-RPC" calling monolith - a REST API application with multiple responsibilities that communicate only through their public API endpoints.

Project Implementation

After researching modular monoliths, I decided to implement a practical example that demonstrates these concepts. This project implements a pet adoption system that showcases how to build a well-structured modular application. Here are the requirements that guided the implementation:

Core Requirements

  • FastAPI and Uvicorn: The application must be built using FastAPI framework and served with Uvicorn
  • REST API: Expose a clean, RESTful API interface
  • Multiple Services: Support multiple responsibilities through different endpoints
  • Internal Communication: Services must communicate through their public REST API endpoints
  • Logging: Implement a robust logging system for debugging and monitoring
  • Testing: Comprehensive test coverage for routes, services, and custom functionality

Additional Features

  • Service Separation: Although monolithic, the application should be designed to be easily split into microservices
  • API Documentation: Well-defined and typed API with automatic documentation
  • Code Generation: Auto-generated API clients from service specifications using Pydantic models
  • Modern Async: Leverage FastAPI and AsyncIO for efficient asynchronous operations
  • Isolated Logging: Each service maintains its own logging configuration

The following sections detail how these requirements were implemented, starting with the technical choices and architecture decisions.

Generating OpenAPI API bindings for Python's AsyncIO

The FastAPI docs demonstrates how to generate OpenAPI API bindings for TypeScript and suggests using OpenAPI Generator to generate bindings for other languages. the OpenAPI generator is an nodejs tool and not a python tool so using it breaks our pure python dependency we have had so far. There are multiple other tools that can generate python bindings for OpenAPI specifications but they are all either abandoned, unmaintained or not working. they may work for you, I couldn't get any of them to work for this project.
Openapi Python Generator | openapi-python-client | fastapi-code-generator | pythogen

OpenAPI Generator

OpenAPI Generator offers a variety of customization options, the customization documentation is really long and doesn't mention python at all so I skipped it, here is the command to generate the API:

npx -g openapi-generator-cli generate -g python --library asyncio \
 -i generated/openapi.json -o generated/api

see the generate command for the cli options and the python generator for python specific options. you can also use --additional-properties for more python specific options.
NOTE: the --library asyncio is a required option in order to be able to self-call the API from within the application. by default the generator uses urllib3 which is blocking and halts the program making it never respond to the request it has generated to itself. (ask me how I know)

how do we generate the OpenAPI specification? FastAPI offers a Swagger UI and ReDoc which themselfs are generated from the OpenAPI specification. FastAPI exposes an get_openapi function call that returns the Application OpenAPI specification as a python dictionary. so it's easy to manipulate and save to a file. in this project it is done using a script src/app/subapp.openapi.py called by run.sh before the application starts. the script scans for all the routes exposed by the application including mounts and subapps (which aren't "visible" to the parent application object) to merges them into a single OpenAPI specification.

Mounts🐎 vs Routers⤵️

When diving into the FastAPI documentation (Bigger Applications - Multiple Files) you're introduced to the concept of routers as a way to organize your application routes as inticing as it is to implement into modular monolith architecture we will not be using routers for the services. FastAPI routers are very assessible are intended to to allow easier mangament of the application routes throughout the source code. Instead we will be using Sub Applications - Mounts. the Purpose of sub applications aka Mounts is to create a complete new FastAPI application under the existing one that is invisible to its parent almost completely. it's as if we are executing two separate applications.

Alternatives: we don't have to use mounts, we could just run multiple application instances under the same python process. uvicorn exposes serve which can be used to orchestrate multiple applications under the same python process, this however forces us to use different ports for each application! this may be desirable and puts us even closer to the microservice architecture. Bonus point to the alternative: by manutally creating the AsyncIO event loop we can mirror javascript promises and make asyncio act like promises when used with asyncio.create_task How can I start a Python async coroutine eagerly?

we could go even further and implement Domain-driven design with Python and FastAPI which goes as far as to force "restriction of import between domains" is a little bit of a stretch, so it was only partially implemented

Context aware logger

This project has multiple services and all of them are in the same codebase, they could reference and reuse the same routers extending their own functionality with generic functionality or even call common functions. This complexity grows fast and keeping track of which and what executes where can become a nightmare fast. only if there was a way to know which service is executing at any given time!

What is a middleware? from FastAPI docs - Middleware:

A "middleware" is a function that works with every request before it is processed by any specific path operation. And also with every response before returning it.

a middleware is a wrapper around the request and response and it can do whatever it wants with them before they are processed by the path operation. We can take advantage of this and the fact we use subapps to create a context aware logger! all that's needed is to create a middleware that will set a context variable with the name of the current service. see LoggerContextMiddleware and getContextualLogger by applying the middleware to the application with a unique name for each service and getting a contextual logger every time we need to log something we are done! no more confusion about which service is logging what!

What's asynchronous logging anyway?

This great article explains how to go about logging from asyncio without blocking sepecifically Practice #04. Log From Asyncio Without Blocking it is suggests to use the logging.handlers.QueueHandler and logging.handlers.QueueListener to achieve this. which essentially queue away the logging messages and process them in a separate thread. this method is easy to implement using only a configuration file and no new code.
That's all well and good but it doesn't have the word async in it WHERE IS THE FUN IN THAT? That's a significant inspiration for implementing the AsyncEmitLogHandler which is a logging handler that emits log records as async tasks instead of "running a thread" (it's essentialy the same thing under the GIL model) both methodologies operate in a concurrent manner.

following mCoding's video and partially using his configuration file the log_config.json is a configuration file for the logging system that uses the AsyncEmitLogHandler to emit log records as async tasks in both simple plain text and json formats while also keeping the colored uvicorn text output in the console, (see also: uvicorn logger definition)
NOTE: for formatting options and properties of loggers see LogRecord attributes logging formatters LogRecord attributes

Model💃, Relationships💏 and CRUD Operations

This project implements a pet adoption system with two main services:

  1. User Service src/services/user_service
  2. Pet Service src/services/pet_service

These services demonstrate a many-to-many relationship where users can adopt multiple pets, and pets can be adopted by multiple users. The services are configured through config.yaml and combined into a single application via src/app/main.py.

Service Architecture

  • Pet Service: Manages pet-related operations (create, read, update, delete)

    • Pet attributes: name, species, age, mood, feeding time, interaction time
    • Standalone service that doesn't know about users
    • Provides endpoints for basic pet management and interactions (like feeding)
  • User Service: Handles user operations and pet adoption

    • User attributes: name, ID
    • Maintains relationships between users and their adopted pets
    • Communicates with the Pet Service through its public API
    • Does not duplicate pet data, only stores relationships

Both services use SQLModel for database operations, combining SQLAlchemy's power with Pydantic's data validation. Data is stored in a SQLite database that persists between application restarts.

Example Operations

  1. Adopting a Pet
# User 1 adopts Pet 1
curl -X 'POST' \
  'http://127.0.0.1:8000/user/1/pets/1' \
  -H 'accept: application/json' \
  -d ''

Response shows the user with their newly adopted pet:

{
  "name": "John Doe",
  "id": 1,
  "pets": [
    {
      "name": "Fluffy",
      "species": "cat",
      "age": 2,
      "mood": "sleepy",
      "id": 1,
      "last_fed": "2024-12-18T21:03:51.790256",
      "last_interaction": "2024-12-18T21:03:51.790311"
    }
  ]
}
  1. Interacting with a Pet
# Give a treat to Pet 1
curl -X 'POST' \
  'http://127.0.0.1:8000/pet/1/treat' \
  -H 'accept: application/json' \
  -d ''

Response shows the pet's updated state:

{
  "name": "Fluffy",
  "species": "cat",
  "age": 2,
  "mood": "excited",
  "id": 1,
  "last_fed": "2024-12-19T04:16:29.585504",
  "last_interaction": "2024-12-19T04:16:29.585531"
}

Available API Endpoints

Each service provides its own Swagger documentation:

For a complete list of available endpoints and their specifications, see the OpenAPI specification.

Setup and development

Prerequisites

Before starting, ensure you have the following installed:

  • Python 3.13 or higher
  • Node.js and npm (for OpenAPI generation)
  • UV package manager

Check your installations:

python --version
npm --version  # Should be 10.8.2 or higher
uv --version   # Should be 0.5.8 or higher

Installation

  1. Clone the repository:
git clone https://github.com/YoraiLevi/modular-monolith-fastapi
cd modular-monolith-fastapi
  1. Set up the Python environment:
uv sync
source ./.venv/bin/activate
  1. Start the application:
./run.sh

More ways to run the application:

python -m app # run the aggregate application
uvicorn services.user_service:app --reload # run the user service with default service configuration
uvicorn services.pet_service:app --reload # run the pet service with default service configuration

Accessing the Application

Once running, you can access:

Logs are written to both the console and the logs directory.

Debugging and Testing

VS Code Debugging

This repository includes a preconfigured VS Code debugging setup in launch.json. To use it:

  1. Open the project in VS Code
  2. Navigate to the Debug panel (Ctrl+Shift+D)
  3. Select "FastAPI" from the debug configuration dropdown
  4. Start debugging (F5)

Running Tests

The project uses pytest for testing. To run tests:

# Run all tests
pytest

# Run tests with coverage report
pytest --cov=src

# Run specific test file
pytest tests/test_specific_file.py

# Run tests in verbose mode
pytest -v

Common Issues

  • If you encounter database errors, try deleting the SQLite database file and restarting the application
  • For OpenAPI generation issues, ensure npm is properly installed and the openapi-generator-cli is accessible

More references

Projects references

https://github.com/fastapi/full-stack-fastapi-template
https://github.com/zhanymkanov/fastapi-best-practices
https://github.com/arctikant/fastapi-modular-monolith-starter-kit
https://github.com/r2rstep/modular-monolith
https://github.com/BrianThomasMcGrath/modular-monolith
https://github.com/aipress24/aipress24
https://github.com/rifatrakib/fast-subs
https://github.com/vasilmkd/fastapi-eureka-docker-service-discovery
https://github.com/sabatinim/fast_api_hello_world
https://github.com/jod35/fastapi-beyond-CRUD

Future invesigations

more libraries and tools

Known issues

Code related issues

  • Maybe I misunderstood how uvicorns --reload-exclude is supposed to work but it doesn't seem to work for me, this caused the watchfile logger to be created and log messages to be printed to the console. and self-update itself when used with --reload and DEBUG settings. it blocked the entire application.
  • there are multiple main.py each one is intended to be an entrypoint for a service or an aggregate application. as it is now they aren't all operational.
  • figure out how to allow --reload to work with app.main launch script

Conundrums and open questions

About

This is a modular monolith Fast API project that uses the latest and greatest tooling (uv, ruff, pyright, pydantic, pytest, fastapi, sqlmodel, etc) attempting to implement a modular monolith architecture. The repository include pre-commit hooks for ruff, pyright, and uv.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published