A Minimalist Guide to Dependency Injection in Flask-based Python Web Services

In the world of web development, managing dependencies efficiently is crucial for building scalable and maintainable applications. One powerful design pattern that helps achieve this is Dependency Injection (DI). DI simplifies the management of components and services in your application, making it easier to replace or extend functionality without modifying existing code. In Python web services, particularly those built with Flask, Dependency Injection can often seem like an unnecessary complexity. However, when used correctly, DI helps keep your codebase clean, modular, and testable. This is particularly important as your application grows, requiring more services and components to interact with each other. In this guide, we’ll explore how to implement a minimalist version of Dependency Injection in Flask-based Python web services. We’ll walk through the process of injecting classes and variables into your Flask application in a simple yet effective way, avoiding the overhead of heavyweight DI frameworks. By the end of this post, you’ll have the tools to build more maintainable and flexible Flask web services using this essential pattern.

Close-up of Ferrari Engine Parts in Detail From Pexels

Use Case

In traditional Flask-based Python web services, managing configuration variables and classes like a logger can quickly become repetitive and cumbersome. Without Dependency Injection (DI), we would need to initialize these components manually in each module where they are used.


Visit Deep Learning Enabled Art Exhibition: Digital Van Gogh




Let’s look at a simple example:

  • In the absence of DI, we would have to call os.getenv() for every variable we need in every module.
  • Likewise, every module would manually initialize a Logger class, ensuring consistency and configuration in each instance.

Instead, by using DI, we can simplify this process. We initialize the environment variables and the logger class once—typically in a container—and then inject them into the relevant services or modules. This eliminates the need to manually call os.getenv() or create new logger instances throughout the codebase.  This reduces redundancy and improves maintainability by centralizing the configuration and logger initialization in one place, making our code cleaner and more modular.

Dependent Class

First, create a logger.py file under the src/commons directory. During initialization, it will accept a log level as an integer and store it. Then, in each logging method, it will check whether the action meets the required level based on the log level provided during initialization.

import logging
from datetime import datetime

# pylint: disable=broad-except
class Logger:
    def __init__(self, log_level: int):
        self.log_level = log_level

        if self.log_level == logging.DEBUG:
            logging.basicConfig(level=logging.DEBUG)

    def info(self, message):
        if self.log_level <= logging.INFO:
            self.dump_log(f"{message}")

    def debug(self, message):
        if self.log_level <= logging.DEBUG:
            self.dump_log(f"{message}")

    def warn(self, message):
        if self.log_level <= logging.WARNING:
            self.dump_log(f"{message}")

    def error(self, message):
        if self.log_level <= logging.ERROR:
            self.dump_log(f"{message}")

    def critical(self, message):
        if self.log_level <= logging.CRITICAL:
            self.dump_log(f"{message}")

    def dump_log(self, message):
        print(f"{str(datetime.now())[2:-7]} - {message}")

Next, create a variables.py file under the src/dependencies directory. This file will be responsible for loading the necessary variables from the environment.

# built-in dependencies
import os

# 3rd party dependencies
from dotenv import load_dotenv
load_dotenv() # load env vars from .env file

class Variables:
    def __init__(self):
       self.log_level = int(os.getenv("LOG_LEVEL", "20"))

Third, create a container.py file under the src/dependencies directory. This file will be responsible for loading the necessary classes and modules, which will be injected into your services later.

# project dependencies
from modules.core.service import CoreService
from dependencies.variables import Variables
from commons.logger import Logger

class Container:
    def __init__(self, variables: Variables):
       self.variables = variables
       
       # initialize logger
       logger = Logger(log_level = variables.log_level)

       # initialize core service
       self.core_service = CoreService(logger = logger)

As you can see, the Logger class is initialized in the container.py file, while the log level information is passed from the variables. This log level is retrieved from the environment variables, ensuring that the logger is configured based on the environment-specific settings. We also stored variables in our container as well.

Next, create the service functionalities in the src/modules/core/service.py file. This service will retrieve the container during its initialization, and its welcome method will log a “Homepage called” message using the logger.

# project dependencies
from commons.logger import Logger

class CoreService:
def __init__(self, logger: Logger):
   self.logger = logger

   def welcome(self):
      self.logger.info("homepage called")
      return "Welcome Home"

Now, let’s create the service endpoints in the src/modules/core/routes.py file.





# 3rd party dependencies
from flask import Blueprint, request

# project dependencies
from dependencies.container import Container

# initializations
blueprint = Blueprint("routes", __name__)

@blueprint.route("/")
def home():
   # container was initialized in app initialization once, inject it here
   container: Container = blueprint.container
   return container.core_service.welcome()

Here, we retrieved the container from blueprint but we haven’t stored the container into blueprint yet. We will do this while creating the app.

Let’s create the application in the src/app.py file. As you can see, we first create the variables object, which loads the necessary variables from the environment. Then, we initialize the container while passing the variables to it. Finally, we store the container into the blueprint, completing the dependency injection process.

# 3rd party dependencies
from flask import Flask
from flask_cors import CORS

# project dependencies
from modules.core.routes import blueprint as core_blueprint
from dependencies.variables import Variables
from dependencies.container import Container

def create_app():
    app = Flask(__name__)
    CORS(app)

    core_blueprint.container = Container(variables=Variables())

    app.register_blueprint(core_blueprint)
    return app

To run our service, we will run the following shell script. This will get the application up at localhost’s 5000 port.

cd src
gunicorn --workers=8 --timeout=3600 --bind=0.0.0.0:5000 "app:create_app()"

When the localhost:5000/ endpoint is called, it will log the message using the logger we injected.

More Dependencies

As the project grows and we need to log messages in different modules, we won’t need to initialize the logger again. Instead, we will simply use it from the injected container, ensuring consistent logging across the entire application. Let’s expand the project to understand better.

Create a healthcheck service as service.py under src/modules/health. In this service, we will log a “Healthcheck service called” message using the logger available in the container. Note that the logger was initialized in the application and injected into the container, making it accessible here.

# project dependencies
from commons.logger import Logger

class HealthService:
    def __init__(self, logger: Logger):
        self.logger = logger

   def is_healthy(self):
      self.logger.info("healthcheck service called")
      return "Health check: OK", 200

Similar to injection of logger in core service, we will initialize health service once in our container and inject logger to it here.

# project dependencies
from modules.core.service import CoreService
from modules.health.service import HealthService
from dependencies.variables import Variables
from commons.logger import Logger

class Container:
    def __init__(self, variables: Variables):
       self.variables = variables
       
       # initialize logger
       logger = Logger(log_level = variables.log_level)

       # initialize core service
       self.core_service = CoreService(logger = logger)

       # initialize health service
       self.health_service = HealthService(logger = logger)

Now, create its endpoint under src/modules/health/routes.py. Health service was already initialized in our container and logger was passed to it already. So, we will use health service from container in routes instead of initializing it from scratch.

# 3rd party dependencies
from flask import Blueprint, request

# project dependencies
from dependencies.container import Container

# initializations
blueprint = Blueprint("routes", __name__)

@blueprint.route("/health")
def is_healthy():
   # container was initialized in app initialization once, inject it here
   container: Container = blueprint.container
   return container.health_service.is_healthy()

Finally, we will add health check endpoint’s blueprint into our existing application at src/app.py.

# 3rd party dependencies
from flask import Flask
from flask_cors import CORS

# project dependencies
from modules.core.routes import blueprint as core_blueprint
from modules.health.routes import blueprint as health_blueprint
from dependencies.variables import Variables
from dependencies.container import Container

def create_app():
    app = Flask(__name__)
    CORS(app)

    core_blueprint.container = Container(variables=Variables())
    health_blueprint.container = Container(variables=Variables())

    app.register_blueprint(core_blueprint)
    app.register_blueprint(health_blueprint)

    return app

As you can see, the Logger class is initialized once in container.py, but it is used in both src/modules/core/service.py and src/modules/health/service.py. This is possible because we injected the initialized container into our application, making the logger accessible throughout the application. Similarly, we can load the necessary variables from the environment variables once in this manner, making them accessible throughout the application without needing to load them repeatedly in each module.





Although we’ve demonstrated a basic implementation of dependency injection, it can be easily expanded to suit more complex needs. You can use the framework outlined in this post as a foundation for your own project, adapting it as necessary.

Conclusion

In conclusion, by implementing Dependency Injection (DI) in our Flask-based Python web service, we’ve simplified the process of managing environment variables and logging. Through DI, we centralized the initialization of crucial components like the logger and environment variables, making them easily accessible throughout the application. As the project expands, this approach ensures maintainability and scalability, as we no longer need to repeatedly initialize or configure these components in every module. DI helps streamline the architecture, reduce redundancy, and foster cleaner, more modular code—making it an essential practice for larger applications.


Support this blog if you do like!

Buy me a coffee      Buy me a coffee


Leave a Reply