This project is an implementation of an imaginary Game LeaderBoard application, based on Microservices Architecture, Event Driven Architecture, Vertical Slice Architecture, Event Sourcing with EventStoreDB, Redis SortedSet, Redis Pub/Sub, SignalR and .Net 8.
This application capable of handling online calculation of player ranks with using Redis SortedSet
so it is very fast and capable for handling 1 million request per second.
- โ
Using
Vertical Slice Architecture
as a high level architecture - โ
Using
Event Driven Architecture
and asynchronous communications on top of RabbitMQ Message Broker and MassTransit - โ
Using
Outbox Pattern
for all microservices for Guaranteed Delivery or At-least-once Delivery And Using Inbox Pattern for handling Idempotency in receiver side and Exactly-once Delivery - โ
Using
CQRS Pattern
on top ofMediatR
library - โ
Using
Minimal APIs
for handling requests - โ Using Redis SortedSet for calculating player ranks
- โ Using Redis Pub/Sub for some of asynchronous communications
- โ Using Event Sourcing and EventStoreDB as our primary database
- โ Using Postgres and Redis as secondary database on top of EventStore Projections
- โ
Supporting different type of caching strategy like
Read-Through
,Write-Through
,Write-Behind
,Read and Write Cache Aside
on top ofredis
for handling millions of request per second
- โ๏ธ
.NET 8
- .NET Framework and .NET Core, including ASP.NET and ASP.NET Core - โ๏ธ
StackExchange.Redis
- General purpose redis client - โ๏ธ
MassTransit
- Distributed Application Framework for .NET - โ๏ธ
EventStore-Client-Dotnet
- Dotnet Client SDK for the Event Store gRPC Client API written in C# - โ๏ธ
Npgsql Entity Framework Core Provider
- Npgsql has an Entity Framework (EF) Core provider. It behaves like other EF Core providers (e.g. SQL Server), so the general EF Core docs apply here as well - โ๏ธ
FluentValidation
- Popular .NET validation library for building strongly-typed validation rules - โ๏ธ
Swagger & Swagger UI
- Swagger tools for documenting API's built on ASP.NET Core - โ๏ธ
Serilog
- Simple .NET logging with fully-structured events - โ๏ธ
Polly
- Polly is a .NET resilience and transient-fault-handling library that allows developers to express policies such as Retry, Circuit Breaker, Timeout, Bulkhead Isolation, and Fallback in a fluent and thread-safe manner - โ๏ธ
Scrutor
- Assembly scanning and decoration extensions for Microsoft.Extensions.DependencyInjection - โ๏ธ
Newtonsoft.Json
- Json.NET is a popular high-performance JSON framework for .NET - โ๏ธ
AspNetCore.Diagnostics.HealthChecks
- Enterprise HealthChecks for ASP.NET Core Diagnostics Package NET Compiler Platform - โ๏ธ
AutoMapper
- Convention-based object-object mapper in .NET.
For implementing this application we can use different type of caching strategy and we can config our caching strategy in appsettings.json file of our GameEventsProcessor service and run our caching strategy workers separately (like WriteThrough, WriteBehind and ReadThrough), if we don't want to use our built-in Write-aside caching
and Read-aside caching
caching strategy.
For decreasing calculation and response time for real-time rank calculation with millions of request and changes per second we need to use a high performant approach to handling this issue, redis has very handy feature of SortedSet and when store a member with specific score, based on score sorted re-arrange affected members with new rank for each member in the SortedSet. With sorted sets it is trivial to return a list of player sorted by their scores because actually they are already sorted and ranked.
Every time we add an element Redis performs an maximum O(log(N))
operations, where n is the number of members, to re-sort and re-rank affected elements based on new element score. after that when we ask for sorted elements Redis does not have to do any work at all, it's already all sorted
and drastically decrease our reading times and reading hits (It performs a binary search-like operation to locate the element efficiently, resulting in a time complexity of O(log N)).
- Getting the score of an element: O(1)
- Retrieving an element by its rank: O(log N)
Also for ensuring about losing our data and events in our redis cache because it is on the ram, we need to have a primary database and because we want to keep track of all of our events over time we use EventStoreDB as our primary storage and based on caching-strategies on the write
and read
level we update our primary database and secondary redis database and postgres database (using EventStore projections for updating secondary databases).
Here we used Cache-Aside
strategy for both read and write.
The flow of our application for showing leader board to users is according these steps:
- Suppose we have a online game and our users can play the game through mobile or web browser. After getting some points in the game our
mobile app
orweb app
will send aAddOrUpdate
command to its corresponding endpoint inGameEventSource
service through ourtraefik ingress
, load balancer and reverse proxy. - Our traefik will route
AddOrUpdate
request toGameEventSource
service endpoint. - AddOrUpdate endpoint
GameEventSource
service publishesGameEventChanged
to the broker. GameEventChangedConsumer
which is subscribed onGameEventChanged
event inGameEventProcessor
service, will getGameEventChanged
event from the broker.- our
GameEventChangedConsumer
will callAddOrUpdatePlayerScore
command and innerAddOrUpdatePlayerScoreHandler
handler we store events on the EventStoreDB for keep track of all events over the time. - After storing events on EventStoreDB our
Postgres Projection (EFCorePlayerScoreReadModelProjection)
andRedis Projection (RedisPlayerScoreReadModelProjection)
will be triggered.Then these projections will materialize the input data into their respective read data models and store them on Redis and Postgres. - Our
RedisPlayerScoreReadModelProjection
projection will publish aRedisScoreChangedMessage
message through RedisPub/Sub
- Our
GameEventProcessor
service, which is subscribed onRedisScoreChangedMessage
Redis message, will get message by its predefinedRedis subscriber
onRedisScoreChangedMessage
message. - Our
Redis Subscriber
onRedisScoreChangedMessage
message will publishPlayersRankAffected
message to the broker. - Our SignalR service which is subscribed on
PlayersRankAffected
message throughPlayersRankAffectedConsumer
consumer, will get the message and callsUpdatePlayersScoreForClient
on ourIHubService
. - Our
UpdatePlayersScoreForClient
onIHubService
of SignalR service, will get all affected players based on ourScoreChanged
event through a REST call toGameEventProcessor
service. - Our
GameEventProcessor
service andGetPlayerGroupGlobalScoresAndRanks
endpoint will get all related players score withGetGlobalScoreAndRank
query. This query at-first tries to get rank and score form redis sorted set and if not exists it will usesRead-Aside Caching
and will read data from primary database and will update our redis database. - If the data not existed on the redis we check our primary database which is postgres in this example.
- After getting data from postgres we update our Redis SortedSet and HashSet data.
- We send fetched score via
HubService
of our SignalR service in a real time to connected affected players.
TODO
TODO
In this project I used vertical slice architecture or Restructuring to a Vertical Slice Architecture also I used feature folder structure in this project.
- We treat each request as a distinct use case or slice, encapsulating and grouping all concerns from front-end to back.
- When We adding or changing a feature in an application in n-tire architecture, we are typically touching many different "layers" in an application. we are changing the user interface, adding fields to models, modifying validation, and so on. Instead of coupling across a layer, we couple vertically along a slice and each change affects only one slice.
- We
Minimize coupling
between slices
, andmaximize coupling
in a slice
. - With this approach, each of our vertical slices can decide for itself how to best fulfill the request. New features only add code, we're not changing shared code and worrying about side effects. For implementing vertical slice architecture using cqrs pattern is a good match.
Also here I used CQRS for decompose my features to very small parts that makes our application:
- maximize performance, scalability and simplicity.
- adding new feature to this mechanism is very easy without any breaking change in other part of our codes. New features only add code, we're not changing shared code and worrying about side effects.
- easy to maintain and any changes only affect on one command or query (or a slice) and avoid any breaking changes on other parts
- it gives us better separation of concerns and cross cutting concern (with help of MediatR behavior pipelines) in our code instead of a big service class for doing a lot of things.
With using CQRS, our code will be more aligned with SOLID principles, especially with:
- Single Responsibility rule - because logic responsible for a given operation is enclosed in its own type.
- Open-Closed rule - because to add new operation you donโt need to edit any of the existing types, instead you need to add a new file with a new type representing that operation.
Here instead of some Technical Splitting for example a folder or layer for our services
, controllers
and data models
which increase dependencies between our technical splitting and also jump between layers or folders, We cut each business functionality into some vertical slices, and inner each of these slices we have Technical Folders Structure specific to that feature (command, handlers, infrastructure, repository, controllers, data models, ...).
- Install git - https://git-scm.com/downloads.
- Install .NET Core 8.0 - https://dotnet.microsoft.com/en-us/download/dotnet/8.0.
- Install Visual Studio, Rider or VSCode.
- Install docker - https://docs.docker.com/docker-for-windows/install/.
- Make sure that you have ~10GB disk space.
- Clone Project https://github.com/mehdihadeli/leaderboard, make sure that's compiling
- Run the docker-compose.infrastructure.yaml file, for running prerequisites infrastructures with
docker-compose -f ./docker-compose.infrastructure.yaml up -d
command. - Open leaderboard.sln solution.
For implementing our frontend we used Angular and for real-time communication with SignalR Hub we used @microsoft/signalr library.
For running our front-end App:
- go to
cd src/Client
folder and open it in VSCode:
cd src/Client
code .
- Install node modules:
npm install
- Run front end:
npm start
For running our backend we can use different caching strategies:
First of all we should turn-on both write and read cache aside strategies in GameEventProcessor
service and appsettings.json file with setting UseReadCacheAside
and UseWriteCacheAside
to true
:
"LeaderBoardOptions": {
"UseReadCacheAside": true,
"UseWriteCacheAside": true,
"UseReadThrough": false,
"UseWriteBehind": false,
"UseWriteThrough": false,
"CleanupRedisOnStart": true,
"UseCacheWarmUp": true,
"SeedInitialData": true
},
Now we should run our needed services:
dotnet run --project src/Server/Services/LeaderBoard.GameEventsSource
dotnet run --project src/Server/Services/LeaderBoard.GameEventsProcessor
dotnet run --project src/Server/Services/LeaderBoard.SignalR
Now our GameEventSource
service is available on http://localhost:3500
, and GameEventsProcessor
service is available on http://localhost:5000
and our SignalR is available on http://localhost:7200
.
First of all we should turn-on both write-behind and read-through strategies in GameEventProcessor
service and appsettings.json file with setting UseWriteBehind
and UseReadThrough
to true
:
"LeaderBoardOptions": {
"UseReadCacheAside": false,
"UseWriteCacheAside": false,
"UseReadThrough": true,
"UseWriteBehind": true,
"UseWriteThrough": false,
"CleanupRedisOnStart": true,
"UseCacheWarmUp": true,
"SeedInitialData": true
},
Now we should run our needed services:
dotnet run --project src/Server/CacheStrategies/LeaderBoard.ReadThrough
dotnet run --project src/Server/CacheStrategies/LeaderBoard.WriteBehind
dotnet run --project src/Server/Services/LeaderBoard.GameEventsSource
dotnet run --project src/Server/Services/LeaderBoard.GameEventsProcessor
dotnet run --project src/Server/Services/LeaderBoard.SignalR
Now our GameEventSource
service is available on http://localhost:3500
, and GameEventsProcessor
service is available on http://localhost:5000
and our SignalR is available on http://localhost:7200
.
The application is in development status. You are feel free to submit pull request or create the issue.
The project is under MIT license.