The Problem with 'Dependency Injection'
Dependency injection. This is usually the first pattern you learn in software development and it utterly confuses everyone. Here's the thing, I hate the name "dependency injection," and this is because it sounds more technical or extremely harder than it really is.
Therefore, in this video, we're going to break it up into three categories: one, what is dependency injection; two, why it even exists; and then three, we're going to jump into some code and see some practical examples of dependency injection inside FastAPI.
If you're new to the channel, I'm Eric Roby, a software engineer with over a decade of experience and have helped thousands of developers learn and grow within their craft. So go ahead and grab your favorite coffee mug before we get started. I am grabbing the dad coffee mug today. Oh, come on, there it is. So now that you have your coffee, I need to be caffeinated myself, but now let's go ahead and let's learn about dependency injection.
What is Dependency Injection?
So let's start with what the heck is dependency injection? Dependency injection is a design pattern where an object or function receives its dependencies from external sources rather than just creating them itself. And when I say dependencies, I'm talking about like the objects needed for the specific function or object to work. So if a function needs a database connection which comes from a database service, we don't need to create that service inside the function. We want the service to already be created so we can just focus on the business logic inside that function and do whatever we need to do with the database. So the database service is the dependency and we are injecting the database service into the function that needs it. And usually when we say injecting, this really means pass it in as a parameter. And I think that's where a lot of confusion starts because we learn how to like create objects and services and then when it's time to create software we're like, whoa, hold it right there, and we will create these objects by dependencies instead of creating them ourselves, and that can get really confusing for people.
So at its core, dependency injection is a design pattern used to achieve another fancy tech term called inversion of control. This just means instead of a class being responsible for creating their dependencies, they are provided automatically for us, and we will walk through this step-by-step in code so we can break this down even more.
But inversion of control and dependency injection brings us to why does DI or dependency injection even exist? There's something in the software world called tightly coupled and loosely coupled applications. Tightly coupled means it's hard to change things because you are creating objects and services and all that stuff inside the function or classes itself, while loosely coupled kind of means the opposite. It means it's easier to change things when needed because the dependencies are created outside the function or class itself. And that's really where dependency injection came from. It's because dependency injection promotes loose coupling between components and makes the system more modular, testable, and maintainable. And you'll see dependency injection in just about every enterprise application at some point.
So I think that's where like a lot of beginners get stuck because they hear dependency injection, they hear inversion of control, and they're like, what the heck does that even mean? I just learned how to start programming or I just learned this new framework or library or whatever it is, and tech loves to have scary fancy terms that are very simple to do.
A Cautionary Tale of a Tightly-Coupled Monolith
So an example of this would be a few years back, I was working on a project where we had a monolithic application, which is okay, I'm a pretty big proponent of monolithic apps. The system had grown over the years and every class seemed to depend on a dozen other classes. And I'm not even kidding, we would create a new class and have to start each function by creating a user service object, and then an auth service object, and then a post service object, and then we'd have to create all these different entities because those services required other objects and it started getting out of control because we were just creating DTOs everywhere and trying to stick them in as constructors and holy moly, did it become a lot of work.
We were stuck in a cycle of just like bug fixes that just ended up creating more bugs because everything was tightly coupled. The challenge we ran into consistently is that every time we needed to change something, we had to trace back through all of the dependencies because it needed like one service needed another item that needed another item inside a constructor. So we had to just go through every file and in every spot where the new item was needed, we needed to create a new object, pass it into a constructor, and it just started to become a nightmare. And it was especially prevalent in testing because we had to mock all the objects. So the solution to this is exactly what you're thinking and what this video is about was dependency injection. This just helped make the product more modular.
Setting Up the FastAPI Project
So now that we kind of got the backstory of dependency injection, let's jump into code so we can see it in action.
So I have this dependency injection file, or folder. I'm going to go ahead and just create a quick main.py file. And then I am going to create a quick virtual environment. Ah, I messed up. Alright. So now that we're creating this, we don't really need anything, I guess. For this example, I'm going to use FastAPI just so we can see dependency injection through the library itself. So I can say pip install fastapi and uvicorn. All right.
A Basic DI Example: The Logger Service
So let's go ahead and just say from fastapi import FastAPI and Depends and then we're just going to leave it there. And then we can say app = FastAPI(). And now let's say we wanted to create a class of Logger. So we're going to create like a simple logging system. We can say class Logger: def log(self, message: str): and then we can just print logging message of that message.
All right. So now let's say we had a function or an API endpoint that is log/message. If we wanted to not use dependency injection, we would have to say app.get("/logmessage") def log_message(message: str): and then we need to directly create the logger instance inside the router. So then we could say logger = Logger() and then logger.log(message) and then return that message. So what's happening here is if we ran this application and we passed in a message, we are instantiating this logger object right here. And let's say we had this multiple different times. Like this might be a log message, but let's say we had a bunch of different endpoints or a bunch of different functions that all needed to use Logger, we would need to instantiate the object every single time when in reality we could just make this even easier for reusability. So if we had this, we'd have to create it every single time, right?
While here we can create a new dependency called get_logger which just returns a new object of Logger. So then what we could do here now is right here instead of saying, hey, we need to create this new logger object, what we could do is just say logger is going to be of type Logger and we're going to depend on that get_logger. And this Depends is... means it's a dependency. So it's part of our dependency injection. For us to run this application or run this function, we need this object logger which returns an object of logger because we call logger inside the function itself. We have a dependency on this logger object and we are injecting it into this function so we have a logger that we can use.
Cleaner Dependencies with Annotated Type Hints
Real quick, if you have enjoyed the video, please subscribe to be notified of upcoming videos and check out my bestselling FastAPI course linked below.
Now, if we wanted to go even further, we can just... we can just pass this in every function that needs a logger, or can use type annotation which just makes it a little bit cleaner. So we could say logger_dependency = Annotated[Logger, Depends(get_logger)], and this comes from the Python package of typing. So we can just import that there. And now instead of saying this long thing every single time, we can just go ahead and add this logger_dependency right here. So we're saying every single time this application is run, we're going to be using our logger dependency which uses a type hint of Annotated, and then we have our logger, which means we're just returning this type of class from our depends. So it adds a little bit more code up front, but now we only have to say logger_dependency, so it's very clear what this is and we don't have to redo this or instantiate an object every single time. It'll handle the dependency for us.
DI Pattern in Practice: Email Service
All right, now let's just do it again. So we have this. Let's create a new one. So we're going to say this is going to be an email service. So we can say class EmailService: def send_email(self, recipient, who we're going to be sending it to, and the message). Then we just print 'sending email' to both of the parameters that we're sending in.
All right, so we have this email service. Again, we would have to recreate this email service in every function that we want to use the email service in, when instead we could just say def get_email_service() which returns the EmailService. And then we can create the dependency, so email_service_dependency = Annotated[EmailService, Depends(get_email_service)].
And now if we had like a function where we needed to use it, we could just say def send_email(self, ...) where we pass in the information that's needed plus our email_service, which is of type email_service_dependency. And then boom, every time we have this function, we can just inject the email service dependency and we have this email_service.send_email(recipient, message) which then sends that message. We are using dependency injection to pass that in.
Securing an Endpoint with an Auth Service Dependency
And now one more just for like a little bit better use case that you'll see often is an auth service. So let's go ahead and say class AuthService: def authenticate(self, token: str):
if token == "valid_token": so we are not using like the normal JWT stuff for this example, but then we'll return True, else raise an HTTPException of a status code 401, detail "Unauthorized". And here we have some dependencies that we need to add, so we can just say quick fix, add from fastapi import HTTPException. Well now we need to create our dependency, which is def get_auth_service() which returns the AuthService. And then we want to add our dependency on top of this. So we can say auth_service_dependency = our Annotated[AuthService, Depends(get_auth_service)].
And now, hypothetically, we could like have an application, so we could say like app.get("/secure-data") that's going to be an endpoint that needs secure data. And then def get_secure_data() where we pass in a token of string, auth_service of our auth_service_dependency. if auth_service.authenticate(token): then we want to return the data that this is secure data.
Conclusion: DI is Just Passing Parameters
All right, so I hope you have a better understanding of how dependency injection works and that it's not as scary of a term as it might sound, and that we're just injecting dependencies that's needed for this application. So right here we are requiring an off service, so we're just going to inject it right here as a parameter.
All right, well I hope this video was helpful and I will see you in the next.