DEV Community

Cover image for Understanding Singleton Design Pattern with a Logger Example
Bhavuk kalra
Bhavuk kalra

Posted on

Understanding Singleton Design Pattern with a Logger Example

Written by Bhavuk Kalra✏️

In this post, we will explore how the Singleton design pattern is implemented in code and demonstrate one of its most common use cases: a Logger.

Why is a Logger a Singleton?

A logger should have a single instance that is shared across all files in an application. This ensures that all logs are stored in one place, making debugging and tracking events easier.

Project Overview

Let's consider a real-world scenario where we have two different files performing separate tasks, but both need to log information. To achieve this, we will create a Logger class that follows the Singleton pattern, ensuring that all logs are written to the same instance.

Project Structure

Our project will consist of three main files:

  1. logger.py – This file will contain the Singleton Logger class.

  2. user1.py and user2.py – These files represent different parts of the application that will use the logger.

  3. main.py – This file acts as the central server, calling user1.py and user2.py to execute their respective processes.

After setting up, the directory structure will look like this:

└── root/
    ├── user1.py
    ├── user2.py
    ├── logger.py
    ├── main.py
Enter fullscreen mode Exit fullscreen mode

In the next steps, we will implement the Singleton pattern for our logger and see it in action.

Boilerplate Code

Now, let's set up the basic functionality of our project. We'll define the Logger class and ensure that all logs are recorded using a single instance.

Setting Up the Logger

We’ll start by defining the Logger class in logger.py. This class will ensure that only one instance exists throughout the application.

📄 logger.py

class Logger:
    def __init__(self):
        print("Logger Instantiated")

    def log(self, message: str):
        print(message)
Enter fullscreen mode Exit fullscreen mode

Using the Logger in Different Files

Both user1.py and user2.py will import and use the Logger class to log messages corresponding to two different processes.

📄 user1.py

from logger import Logger

def doProcessingUser1():
    loggerUser1 = Logger()
    loggerUser1.log("Log from the first user")
Enter fullscreen mode Exit fullscreen mode

📄 user2.py

from logger import Logger

def doProcessingUser2():
    loggerUser1 = Logger()
    loggerUser1.log("Log from the second user")
Enter fullscreen mode Exit fullscreen mode

Entry Point:

📄 main.py

from user1 import doProcessingUser1
from user2 import doProcessingUser2


if __name__ == "__main__":
    doProcessingUser1()
    doProcessingUser2()
Enter fullscreen mode Exit fullscreen mode

Lets run the main.py file to check if our base setup is working.

Output

Logger Instantiated
Log from the first user
Logger Instantiated
Log from the second user
Enter fullscreen mode Exit fullscreen mode

Logger Functionality 📝

For our Logger, we need to ensure:

🔹 Single Instance: Only one instance of the Logger class exists throughout the project.
🔹 Restricted Instantiation: Other processes must not create additional instances of the Logger.

With this setup, all logs will be managed by a single, central Logger instance, ensuring consistency and efficiency! 🚀

Tracking Logger Instances for Debugging 🧐

To ensure that our Singleton implementation is working correctly, we will track how many instances of the Logger class have been created. This will help us debug and verify that only one instance is used throughout the application.

How Will We Track Instances? 📊

🔹 Use a Static Variable – This will keep track of the number of instances created.
🔹 Why a Static Variable? – Unlike instance variables (which belong to a specific object), static variables belong to the class itself and are shared across all instances.

Implementing Instance Tracking 🔍

1️⃣ Add a static variable to count the number of instances created.
2️⃣Update the constructor to increment this counter whenever a new instance is attempted.
3️⃣ Log the instance count for verification.

With these changes, we’ll be able to confirm whether our Singleton pattern is correctly restricting multiple instances! 🚀

Since our Logger class should ideally have only one instance, we will implement functionality to print how many instances have been created. This will help us verify that the Singleton pattern is correctly enforced.

Updating the Codebase 🛠️

We implement a static counter belonging to a specific class and shared across all instances.

📄 logger.py

class Logger:

    # private Static variable to track num of instances created
    __numInstances = 0

    # private:


    def __init__(self):
        Logger.__numInstances = Logger.__numInstances + 1
        print("Logger Instantiated, Total number of instances - ", Logger.__numInstances)

    def log(self, message: str):
        print(message)

Enter fullscreen mode Exit fullscreen mode

We have now introduced a static variable numInstances to keep track of how many instances of the Logger class have been created across the codebase.

Lets run the main.py to see if what we get

📄 output

Logger Instantiated, Current number of instances -  1
Log from the first user
Logger Instantiated, Current number of instances -  2
Log from the second user
Enter fullscreen mode Exit fullscreen mode

which is an expected output as we are here creating two instances of Logger class in two different files namely user1.py and user2.py.

Converting to a Singleton Design Pattern 🏗️

To implement the Singleton design pattern for our Logger, we need to ensure that only one instance of the class is created and shared across the entire application.

Steps to Implement Singleton ✅

1️⃣ Make the Constructor Private

  • Prevents the creation of new instances directly.

2️⃣ Create a Static Instance

  • Stores the single instance of the class.

  • Ensures all users access the same instance.

3️⃣ Provide a Static Method for Access

  • This method will instantiate the static instance if it hasn’t been created yet.

  • All users will access the same instance through this method.

Implementation 🛠️

Making the constructor private 🔐

Since the constructor is private, no one can create multiple instances of the class. However, we still need at least one instance that other users can access, right?

What we can do is create a static function that returns the instance of the Logger class.

(Read that again if you have to so that you make sense of it)

Since Python is a very flexible language, we can't directly make a class constructor private like in C++ or Java.

Instead, we will use Name Mangling to achieve this.

Using __new__ with Name Mangling

The __new__() method is called before __init__() and controls the instance creation.

By altering __new__ method we are going to restrict the access to the __init__ method. Raising an error every time a method tries to directly instantiate it.


class Logger:

    # private Static variable to track num of instances created
    __numInstances = 0

    # private static variable to denote if instance was created
    __loggerInstance = None

    def __new__(cls):
        # Private constructor using __new__ to prevent direct instantiation
        raise Exception("Use getLlogger() to create an instance.")


    def __init__(self):
        Logger.__numInstances = Logger.__numInstances + 1
        print("Logger Instantiated, Total number of instances - ", Logger.__numInstances)

    def log(self, message: str):
        print(message)

    @classmethod
    def getLogger(cls):
        # Returns the singleton instance, creates one if it doesn't exist
        if cls.__loggerInstance is None:
            # Bypass __new__ and directly instantiate the class
            cls.__loggerInstance = super(Logger, cls).__new__(cls)

             # Trigger __init__ manually on first creation
            cls.__loggerInstance.__init__()

        return cls.__loggerInstance



Enter fullscreen mode Exit fullscreen mode

We use the @classmethod decorator to pass the caller class (cls) into the function. This makes our code safer in cases of inheritance and polymorphism.

Understanding super(Logger, cls) 🔄

super(Logger, cls) returns a proxy object that allows access to the parent class methods.

🔹 Logger – The current class where we start the lookup.
🔹 cls – The class whose instance we want to create.
🔹 This ensures that if we use inheritance, Python creates an instance of the correct class.

How super(Logger, cls).new(cls) Works ⚙️

🔹 __new__(cls) is a special method that creates a new instance of a class before initializing it.
🔹 Python looks for the parent class of Logger (which is object).
🔹 It then calls object.__new__(cls), ensuring that an instance of cls (either Logger or a subclass) is created properly.

This approach keeps our Singleton pattern safe and flexible when used with inheritance!

📄 user1.py

from logger import Logger

def doProcessingUser1():
    loggerUser1 = Logger.getLogger()
    loggerUser1.log("Log from the first user")
Enter fullscreen mode Exit fullscreen mode

📄 user2.py

from logger import Logger

def doProcessingUser2():
    loggerUser1 = Logger.getLogger()
    loggerUser1.log("Log from the second user")

Enter fullscreen mode Exit fullscreen mode

Output

Logger Instantiated, Total number of instances -  1
Log from the first user
Log from the second user
Enter fullscreen mode Exit fullscreen mode

🔍 Observe the Singleton in Action

Even with two getLogger() calls, we still have only one instance of the Logger class being used. ✅

🚫 What Happens if Someone Tries to Instantiate the Logger?

Now that we have restricted direct instantiation, let’s imagine a scenario where a user tries to create a new instance manually.

To test this, we will modify our code and see what happens when someone attempts to instantiate the Logger class directly. 🛠️

📄 user1.py

from logger import Logger

def doProcessingUser1():
    loggerUser1 = Logger()
    loggerUser1.log("Log from the first user")
Enter fullscreen mode Exit fullscreen mode

We get this error, which is our custom exception.

raise Exception("Use getLlogger() to create an instance.")
Enter fullscreen mode Exit fullscreen mode

✅ First Step of the Singleton Pattern Completed!

But are we completely done? No! 🚫

⚠️ What’s the Next Challenge? (Hint: Multi-threading) 🧵

Our current implementation is not thread-safe.

🧐 What’s the Problem?

Imagine a scenario where two threads call getLogger() at the same time:

🔹 Both threads check if __loggerInstance is None.
🔹 Since both see it as None, they both try to create an instance.
🔹 As a result, two instances of Logger are created— **breaking **our Singleton pattern! ❌

🔒 Next Step: Making the Code Thread-Safe

In the next part, we will:

✔️ Explore different approaches to handling multi-threading.
✔️ Identify corner cases to watch out for.
✔️ Learn how to use mutex locks efficiently to prevent race conditions.

Stay tuned as we enhance our Singleton implementation! 🚀

All this good stuff in the part 2 of this tiny two part series on Singleton Design Pattern

About me ✌️

Socials 🆔

Top comments (0)