Unit of Work

The frequent.unit_of_work module provides base classes for an implementation of the unit of work pattern. This pattern is (sometimes) used when working with object persistence/storage (e.g. ORMs). The primary advantage of this pattern is the transactional nature of units of work. Regardless of the storage back-end implemented with the UnitOfWork subclass (provided it can support staging changes to be made, persisting those changes and deleting those changes) all work performed inside a UnitOfWork context block is a transaction.

Note

This pattern pairs well with the Repository pattern and a command-based Messaging Framework.

Usage

The abstract classes UnitOfWork and UnitOfWorkManager can be extended to for specific use-cases.

Creating a UnitOfWork Subclass

The UnitOfWork class has two abstract methods which need to be implemented in subclasses:

commit()

Persists the changes made using the unit of work to the storage back-end.

rollback()

Undo any changes made while using the unit of work and do not persist anything to the storage back-end.

A simple implementation might look like this:

from frequent.unit_of_work import UnitOfWork

class MyUnitOfWork(UnitOfWork):

    def commit(self):
        # Code to persist changes
        return

    def rollback(self):
        # Code to dispose of changes
        return

If there’s anything that needs to be done upon entering/exiting the UnitOfWork context, you can customize two additional methods:

class MyUnitOfWork(UnitOfWork):
    ...

    def __enter__(self):
        # Context entry setup code here
        return super().__enter__()

    def __exit__(self, exc_type, exc_value, traceback):
        # Pre commit/rollback teardown code here
        super().__exit__(exc_type, exc_value, traceback)
        # Post commit/rollback teardown code here
        return

Warning

The order of the super() calls in the __enter__ and __exit__ methods matters!

In the __enter__ call the superclass’s method returns self (you could just return self if you wanted, though this approach ensures that any general entry-code that may exist in future versions of the base class will be executed).

In the __exit__ call the superclass’s method will call either commit() or rollback() depending on the exit conditions. So the location of that call matters and could vary depending on your particular use case.

Creating a UnitOfWorkManager Subclass

The UnitOfWorkManager class has a single abstract method to implement in subclasses:

start()

This returns a new UnitOfWork instance, ready to use.

Continuing from the example above the associated UnitOfWorkManager class would look something like:

from frequent.unit_of_work import UnitOfWorkManager

class MyUnitOfWorkManager(UnitOfWorkManager):

    def start(self):
        return MyUnitOfWork()

The UnitOfWorkManager class may appear to be a useless abstraction from the above example (and in this case it kind of is), but its usefulness can be seen in the (more realistic) extended example given below.

Using the UnitOfWork and Manager

Now to use our new subclasses, remember the commit method will be called upon exiting the context block (or the rollback call if something went wrong). You’re free to call commit at any point within the block to persist any changes up to that point (if it makes sense for your use-case).

>>> uowm = MyUnitOfWorkManager()
>>> with uowm.start() as uow:
...     # Code for doing work in this block
...     uow.commit()  # Persist changes up to this point (if you want/need to)
...     # More work code

Important

You do not have to call the commit method at the end of the with statement block, it will automatically be called upon a successful exit of the context.

Extended Example

Let’s suppose we’re using SQLAlchemy’s ORM for our storage back-end. Then our unit of work class would look something like this:

class MyUnitOfWork(UnitOfWork):

    def __init__(self, sessionmaker) -> None:
        self._sessionmaker = sessionmaker
        self.session = None
        return

    def __enter__(self):
        # Create a new session
        self.session = self._sessionmaker()
        return super().__enter__()

    def __exit__(self, exc_type, exc_value, traceback):
        super().__exit__(exc_type, exc_value, traceback)
        # Be sure to close the session when done, regardless
        self.session.close()
        self.session = None
        return

    def commit(self):
        self.session.commit()
        return

    def rollback(self):
        self.session.rollback()
        return

Now we’ll instantiate the UnitOfWorkManager class to spin-up new MyUnitOfWork instances to use:

class MyUnitOfWorkManager(UnitOfWorkManager):

    def __init__(self, sessionmaker):
        self._sessionmaker = sessionmaker
        return

    def start(self):
        return MyUnitOfWork(self._sessionmaker)

Lastly, let’s wrap all our user management code inside another class whose sole purpose is working with User (and other associated) objects. We’ll want the ability to create users from this user manager class, but let’s also suppose that we also have a (separate) UserProfile object which stores some basic information and settings about our users. This object is always created when we create our User objects. This is where the advantage of the unit of work pattern can really be seen:

class UserManager(object):

    def __init__(self, uow_manager):
        self.uowm = uow_manager
        return

    def create_user(self, name, email, location=None, receive_emails=True):
        with self.uowm.start() as uow:
            # Create and add the user
            user = User(name)
            uow.session.add(new_user)
            uow.session.flush()
            # Create and add the profile
            profile = UserProfile(user.id, email, location, receive_emails)
            uow.session.add(new_profile)
        return new_user

The above code demonstrates the advantages (discussed earlier) of this design pattern. In the above example if an error occurred at any point in creating any new user (for example if we do some validation on the email or the user already exists), the uow would have been rolled-back automatically and the exception raised. If everything works as expected then the uow will call the commit() method and both the new user and profile objects will be persisted to our storage back-end upon exiting the with context block. Thus we have only two possible outcomes when adding User objects:

  • Both the user and their profile are added to our system.

  • Neither the user nor their profile are added to our storage system.

The point is, we won’t wind up in some in-between state where the user is added but the associated profile is not (or vice-versa).