Building a Beer Rating App

by Aviv Rahmany

Introduction

In distributed applications, a common requirement is to perform high-frequency counting operations, such as tracking user actions, logging events, or maintaining counters. These operations need to be handled efficiently across multiple nodes to ensure consistency and accuracy. One typical example is rating systems, where users continuously rate items, and the system needs to maintain and update these ratings accurately in real-time.

In this tutorial, we will build a beer rating app to demonstrate how to handle high-frequency counting in a distributed environment using RavenDB. We’ll start by implementing a simple approach that stores ratings inside the beer document and then discuss the issues that can arise. Next, we will enhance our solution by utilizing the Patch API, and finally, we’ll show how to use RavenDB’s distributed counters to overcome these challenges.

What You Will Learn

  • Setting up RavenDB as a database for an ASP.NET Core web application.
  • Implementing a simple rating system by storing ratings inside documents.
  • Understanding the issues with concurrent document updates.
  • Improving the rating system using the Patch API.
  • Learning about distributed counters and their benefits.
  • Implementing distributed counters in a beer rating app.

Building the Beer Rating App

 

Step 1: Setting Up the Project

To get started, create a new ASP.NET Core web application using the “React and ASP.NET Core” template in Visual Studio. This template sets up a basic project structure with React for the frontend and ASP.NET Core for the backend.

Install RavenDB and set up your RavenDB server. Create a database named BeerRatingApp

Next, configure RavenDB in your application startup. In the Program.cs file, add the following code to create and initialize a document store and register it as a singleton service in the application:

// Configure RavenDB in application startup
  var builder = WebApplication.CreateBuilder(args);
  var store = new DocumentStore
  {
         Urls = new[] { "http://localhost:8080" }, // url of your RavenDB server
         Database = "BeerRatingApp"
  };
  store.Initialize();
  builder.Services.AddSingleton<IDocumentStore>(store);

Step 2: Defining the Beer Model

First, define a simple Beer model with properties for ID, Name, Style, and ImageUrl.

This should be enough to get us starting:

public class Beer
  {
      public string Id { get; set; }
      public string Name { get; set; }
      public string ImageUrl { get; set; }
      public string Style { get; set; }
      public double TotalRating { get; set; }
      public int RatingCount { get; set; }
  }

Step 3: Implementing the Initial Rating System

In this approach, we will store the total rating and rating count directly inside the beer document.

Later we will go over the drawbacks of this approach, before moving to use Counters.

In order to start, we’ll create our API controller named BeerController, with methods for

fetching all beers, rating a beer and getting the average rating for a beer:

[ApiController]
  [Route("api/[controller]")]
  public class BeersController : ControllerBase
  {
         private readonly IDocumentStore _documentStore;
  
public BeersController(IDocumentStore documentStore) { _documentStore = documentStore; }
[HttpGet] public async Task<IEnumerable<Beer>> Get() { // fetch all beer documents from the database using var session = _documentStore.OpenAsyncSession(); var beers = await session.Query<Beer>().ToListAsync();
return beers; } [HttpPost("{id}/rate")] public async Task<IActionResult> Rate(string id, [FromBody] int rating) { // open a session and load the beer document using var session = _documentStore.OpenAsyncSession(); var beer = await session.LoadAsync<Beer>(id); if (beer == null) return NotFound(); // no beer document for this id // increment the TotalRating and RatingCount fields by 1 beer.TotalRating += rating; beer.RatingCount += 1; await session.SaveChangesAsync(); return Ok(); } [HttpGet("{id}/rating")] public async Task<IActionResult> GetRating(string id) { // open a session and load the beer document using var session = _documentStore.OpenAsyncSession(); var beer = await session.LoadAsync<Beer>(id); if (beer == null) return NotFound(); // no beer document with this id // divide the total rating by the total number of ratings var averageRating = beer.RatingCount > 0 ? beer.TotalRating / beer.RatingCount : 0; return Ok(averageRating); } }

Problems with Initial Implementation:

This approach works, but it has some drawbacks.

In a distributed environment, multiple users might rate the same beer simultaneously, leading to concurrent document updates. This can cause document conflicts, which require manual or automatic resolution, adding complexity to your application.

Additionally, we should take into consideration that this operation is not optimized, since updating the rating for a beer requires loading and updating the entire beer document, which might be large.

Intermediate Approach: Using the Patch API

To address some of these issues, we can use the Patch API for partial document updates. In a single-node environment, the Patch API helps avoid document conflicts since loading the document and updating it are both processed in a single write transaction. Additionally, this approach improves performance since it avoids loading the entire document for each update.

Let’s update the Rate method in our controller to use Patch API:

[HttpPost("{id}/rate")]
  public async Task<IActionResult> Rate(string id, [FromBody] int rating)
  {
         // open a session and load the beer document 
         using var session = _documentStore.OpenAsyncSession();
         // increment the TotalRating and RatingCount fields by 1
         // by using the Patch API
         session.Advanced.Increment<Beer, int>(id, beer => beer.TotalRating, rating);
         session.Advanced.Increment<Beer, int>(id, beer => beer.RatingCount, 1);
  // The two Patch operations are sent via 'SaveChanges()' which completes     transactionally, as this call generates a single HTTP request to the database. // Either both will succeed or both will be rolled back since they are applied within the same transaction.
         await session.SaveChangesAsync();
         return Ok();
  }

Advantages and Drawbacks:

Using the Patch API can boost performance and help us to solve concurrency issues in a single node environment. However, in a distributed setup with multiple nodes, concurrent updates can still cause document conflicts, leading to potential data inconsistencies and possible loss of votes.

Distributed Counters to the Rescue

To fully address the concurrency issues and improve performance in a distributed environment, we can use RavenDB’s distributed counters. Counters are designed to handle high-frequency counting in distributed environments without causing conflicts. They offer a lightweight and efficient way to perform counting operations, simplifying the code and eliminating the need for synchronization or locking mechanisms. As such, they are a perfect fit for our beer rating app.

Step 4: Updating the Beer Model

We will modify the Beer model to remove the TotalRating and RatingCount fields. Instead, we will use counters to track the number of ratings for each score (1 to 5).

public class Beer
  {
      public string Id { get; set; }
      public string Name { get; set; }
      public string ImageUrl { get; set; }
      public string Style { get; set; }
  }

Step 5: Implementing the Rating System with Counters

Each beer document will have up to five counters, named 1, 2, 3, 4, and 5. Each counter tracks the number of users that rated this beer with the corresponding score.

Let’s update the Rate and GetRating methods in our controller to use distributed counters:

[HttpPost("{id}/rate")]
  public async Task<IActionResult> Rate(string id, [FromBody] int rating)
  {
         // open a session and load the beer document
         using var session = _documentStore.OpenAsyncSession();
         var beer = await session.LoadAsync<Beer>(id);
         if (beer == null)
             return NotFound(); // no beer document with this id
         // increment the counter corresponding to the rating
         session.CountersFor(id).Increment(rating.ToString());
         await session.SaveChangesAsync();
         return Ok();
  }
  [HttpGet("{id}/rating")]
  public async Task<IActionResult> GetRating(string id)
  {
         // open a session and load the beer document
         using var session = _documentStore.OpenAsyncSession();
         var beer = await session.LoadAsync<Beer>(id);
         if (beer == null)
             return NotFound(); // no beer document with this id
         // get all counters for this beer document
         var ratings = await session.CountersFor(id).GetAllAsync();
         // sum the product of each score and its corresponding count,
         // then divide by the total number of ratings
         double totalRating = 0;
         int ratingCount = 0;
  
foreach (var rating in ratings) { if (int.TryParse(rating.Key, out int score)) { totalRating += score * rating.Value; ratingCount += rating.Value; } } var averageRating = ratingCount > 0 ? totalRating / ratingCount : 0; return Ok(averageRating); }

Conclusion

In this tutorial, we explored different approaches to implementing a high-frequency counting system in a distributed environment using RavenDB. We started with a simple but flawed approach of storing ratings inside documents, which led to issues with concurrent updates and potential data inconsistencies. Then we improved our solution and addressed some of these issues by using the Patch API, and finally implemented a robust solution using distributed counters.

By leveraging RavenDB’s distributed counters, we achieved a conflict-free, efficient, and scalable rating system for our beer rating app, maintaining accurate and consistent counts across multiple nodes.

project source code can be found here

Woah, already finished? 🤯

If you found the article interesting, don’t miss a chance to try our database solution – totally for free!

Try now try now arrow icon