The Repository and Unit of Work Design Patterns


Most web applications we build benefit from having a separate “data access layer” (DAL) and separating business logic from database interaction.

The main advantages to this are easier testing and maintainability. Because business logic is separated from the actual implementation, you can write code in terms of higher level abstractions, rather than having to worry about specifics.

For example, a blog website could do something like:

const post = posts.create({ title: 'An example blog post' });
posts.publish(post.id);

The snippet above is describing what’s happening in logical terms:

  1. Create a post
  2. Publish the post

Notice that we are able to use logic without having to worry about how it works. We don’t know the specifics of how a post is created – Is it using SQL? Is it using an API? – but we do know we have a posts object with a create method, that will create a new post for us.

Data Access Layer: The Repository Pattern

The repository design pattern acts as an intermediary between your application’s logic and how it retrieves data. It hides the nitty-gritty details of how data is stored (databases, APIs, etc.) and provides a simplified interface for working with your data. This separation keeps your code cleaner, easier to maintain, and more flexible as you can switch data sources without changing the core logic.

It can be as simple as just creating an object that wraps all your database operations, such as creating a new blog post, publishing it, or finding a user by id.

// A repository for managing users
class UserRepository {
  async findById(id: number): Promise<User> {
    return sql<User>('SELECT * FROM users WHERE id = ?', id);
  }
}

From the outside world, you’d consume the repository like this:

// an example Express route
app.get('/users/:id', (req, res) => {
  const users = new UserRepository();
  const user = users.findById(req.query.id);
  return res.render('users/index.hbs', { user });
})

That’s the repository pattern in a nutshell! This pattern shines in several situations where you want to improve code organization, maintainability, flexibility and testability while dealing with data access:

  • Data Access Abstraction: If your application interacts with various data sources (databases, files, APIs), the repository pattern helps abstract away the specifics of each source. This allows you to focus on the core data access logic (CRUD operations) without worrying about the underlying implementation details.
  • Separation of Concerns: The repository separates the data access layer from the business logic. This improves code modularity and maintainability. Business logic can interact with data through the repository interface without being entangled with the specifics of data retrieval or manipulation.
  • Testability: By mocking repository interfaces, you can easily unit test your business logic without relying on actual data access. This promotes faster development cycles and more reliable code.
  • Flexibility: The repository pattern allows you to switch between different data access technologies (e.g., SQL to NoSQL) by simply implementing a new repository that fulfills the same interface. This promotes flexibility and future-proofs your application.

To get the most out of the repository pattern (and most object-oriented patterns), you’ll want to use it together with dependency inversion.

The Dependency Inversion Principle

The Dependency Inversion Principle (or “DI”, for short), part of the SOLID design principles, states that you should depend on abstractions, not concretions. This means you want to depend on an interface, not on the actual implementation of that interface.

In the previous section we saw an example of a simple repository. Following the DI principle, we would need to define an interface for it:

interface IUserRepository {
  async findById(id: number): Promise<User>;
}

Have the actual class implement that interface:

class UserRepository implements IUserRepository {
  async findById(id: number): Promise<User> {
    return sql<User>('SELECT * FROM users WHERE id = ?', id);
  }
}

And then use the interface rather than using the implementation directly:

app.get('/users/:id', (req, res) => {
  const users = container['IUserRepository'];
  const user = users.findById(req.query.id);
  return res.render('users/index.hbs', { user });
})

The most important things to notice from above are:

  • The users variable is of type IUserRepository, so any object implementing that interface will satisfy our code
  • When using dependency inversion, you need a way to map interfaces to their actual objects. That’s what the container variable is doing

A simplified DI container could look like this:

const container = {
  'IUserRepository': new UserRepository();
}

const users = container['IUserRepository'];

This approach allows you to define all your associations in a single place, and then re-use them across the app. Also, it makes swapping implementations trivial.

Normally, you’d use a dependency inversion container library, such as inversify, to associate actual objects (concretions) to interfaces (abstractions).

Being able to easily swap interface implementations is particularly good for testing, as we could easily create a mock object implementing IUserRepository, and use that in our tests. As long as it satisfies the interface, all our code will continue to work!

class UserRepositoryMock implements UserRepository {
  async findById(id: number): Promise<User> {
    return new User({ id, name: 'Fede' });
  }
}

In this case, the mock object will always return a user with the given id, and the name will always be 'Fede'. It’s enough to make our tests pass without having to actually interact with a database, thus, making your tests much faster and easier to work with.

Because changing the implementation is so easy, if we ever need to swap a data source, for example, from MySQL to MongoDB, all we would need to do is implement the interfaces for the relevant repositories, plug them into the container, and that’s it!

This also helps not to lock yourself into a particular library or ORM. Did you find a bug in Prisma? No worries, you can use Knex only for a single repository, or maybe even a single method.

The Unit of Work Pattern

The unit of work pattern (or “UOW”, for short) is all about ensuring data consistency in your application. It groups multiple database operations into a single logical unit, like a business transaction. This way, all the operations either succeed or fail together.

The typical example is transferring money between accounts, both accounts need to be updated. The unit of work pattern manages this process, making sure either both updates happen or neither does, keeping your data in a clean and consistent state.

An example UoW class could look like this:

class UnitOfWork implements IUnitOfWork {
  private userRepository: UserRepository;

  // The transaction type will depend on the library you use for connecting to
  // the database. For this example, I've simply used `MyTransactionType`.
  private transaction: MyTransactionType; 

  // Notice we are depending on the abstractions here
  constructor(users: IUserRepository, posts: IPostsRepository) {
    this.users = users;
    this.posts = posts;
  }

  async beginTransaction(): Promise<void> {
    // Implement logic to begin transaction on your database connection
    // Again, this will depend on your database library
    this.transaction = myDbConnection.transaction();
  }

  async registerUser(name: string): Promise<void> {
    const user = new User(name);
    await this.users.create(user);
  }

  async updateUser(id: number, name: string): Promise<void> {
    const user = await this.users.get(id);
    user.name = name;
    await this.users.update(user);
  }

  async createPost(authorId: number; title: string, body: string): Promise<void> {
    const user = await this.users.get(authorId);
    if (!user.canCreatePost) throw new Error('Missing permissions');

    await this.posts.create({ title, body, author: authorId });
  }

  async commit(): Promise<void> {
    this.transaction.commit();
  }

  async rollback(): Promise<void> {
    this.transaction.rollback();
  }
}

The class above is a unit of work that allows us to create and update users, as well as creating blog posts. We can use it as such:

// Use a DI container to fetch the implementation
const unitOfWork = container['IUnitOfWork'];

// All operations below will be atomic
await unitOfWork.beginTransaction();
try {
  await unitOfWork.registerUser("John Doe");
  await unitOfWork.createPost(1, 'Some Title', 'Example post body')
  await unitOfWork.updateUser(1, "Jane Doe");
  await unitOfWork.commit();
  console.log("It worked!");
} catch (error) {
  console.error("Error during user operations:", error);
  await unitOfWork.rollback();
}

If any of the steps fail, for example, createPost fails because permissions were invalid, then nothing will be changed in the database.

This is quite useful because you can create multiple unit of work classes, each with its related business logic. A few good use cases for this pattern include:

  • Transactional Applications: If your application performs multiple data operations that must succeed or fail together, the unit of work pattern is ideal. It ensures data integrity by allowing you to commit or rollback the entire transaction as a unit. This is crucial for maintaining data consistency, especially in financial systems, inventory management, or any scenario where a series of data changes need to be atomic.
  • Complex Data Access Logic: Applications with intricate data access logic involving multiple repositories or data sources benefit from the unit of work pattern. It centralizes transaction management and simplifies the process of ensuring data consistency across these operations.
  • Separation of Concerns: The unit of work pattern promotes separation of concerns by isolating transaction management from the business logic. This makes code cleaner, easier to understand, and promotes better maintainability.
  • Testing: By encapsulating transaction logic, the unit of work pattern simplifies unit testing. You can mock the unit of work to isolate and test specific data access operations without affecting the entire application.

Note that unit of work objects generally shouldn’t directly use other unit of work objects. This is because nesting transactions scope can be confusing, as well as potentially introducing circular dependencies and extra code complexity.

Also, if your repositories use different data sources, you’ll need to manage the “transaction” manually, for example, you might need to manually delete records on rollback if your repository uses an API as data source.

Conclusion

The repository and unit of work patterns are powerful tools for building clean, maintainable, and testable applications. By separating data access from business logic, they promote code organization, flexibility, and data consistency.

The repository pattern provides an abstraction layer for interacting with various data sources, while the unit of work pattern ensures the integrity of data by grouping multiple operations into atomic transactions. Used together, these patterns offer a robust approach to data management in web applications.

I find having these patterns in your toolbox can help tremendously when dealing with unknowns in software development, which is pretty frequently!

Not sure about what data source you’ll need to use? Create an interface for the repository and worry about that later. Is the API still not finished? No worries, just create an interface with whatever you need and use a mock object until the API is done, then you can write an actual implementation for that interface.

Being able to delay design decisions allows you to write code while still figuring things out, which is great for real-world software projects.

I find the repository pattern in particular to be quite useful. Unit of work requires a bigger, more complex application for you to benefit from it, but the repository pattern can work well even in smaller projects.

Want updates on our latest blog posts?
Subscribe to our newsletter!

Previous Post
Rendez-Vous 2024 – Claris FileMaker Conférence, Nantes, France
Next Post
Use REST and cURL with FileMaker 2023's Data API