We write an application on the C# stack

We write an application on the C# stack

Hello everyone! My name is Dmytro Bakhtenkov, and I am a .NET developer. Today we will conduct an experiment — we will write a full-fledged web application using solutions written in C# and the .NET platform. You can read more of my articles in media LOG IN.

What do I mean?

As we know, in general, a web application consists of a backend, a frontend, a database, and sometimes a cache. Everything is clear with the backend and frontend: we have a great ASP.NET Core framework for the server and blazor or razor pages for the client. However, the infrastructural parts of the application — the database, caches — are often written in other, lower-level languages, such as C and C++.

Fortunately, Microsoft recently released a Redis-like caching solution called Garnet. As the main database, you can use the RavenDB document database, which is written in C#.

We are writing an application on the C# stack

What do we code?

So, the stack has been dealt with. We will have:

  • ASP.NET Core for the backend;

  • Blazor for the frontend;

  • RavenDB as the main DBMS;

  • Garnet for cache.

Hello World in the world of web applications is considered to be various to do sheets. We will write a similar application.

I created an empty solution in the IDE and then added a Web API project on ASP.NET Core:

Backend: repository and endpoints

RavenDB is a document-oriented NoSQL database designed to simplify work with data and ensure high performance. It supports ACID transactions, time-series, full-text search and much more. More details can be found on the project website.

The RavenDB.Client library is used to communicate with RavenDB. It can be added in the interface or using the command:

dotnet add package RavenDB.Client

Next, add the DataAccess folder and the ToDoItem class – this will be our model:

public class ToDoItem
{
    public string Id { get; set; }
    public string Title { get; set; }
    public string Description { get; set; }
    public DateTime Deadline { get; set; }
}

There we will add the ToDoRepository class, which will communicate with the database using a special IDocumentStore abstraction. The repository is responsible for CRUD operations: create, read, update and delete. The IDocumentStore abstraction allows you to create session objects that can be used to interact with data.

In the Program.cs file, we will register our repository, and also initialize IDocumentStore with a connection to our database. In general, interacting with RavenDB is very similar to working with DBContext in Entity Framework.

The Create method:

    public async Task Create(ToDoItem item)
    {
        using var session = store.OpenAsyncSession();
        await session.StoreAsync(item);
        await session.SaveChangesAsync();
    }

GetById method:

    public async Task GetById(string id)
    {
        using var session = store.OpenAsyncSession();
        return await session.LoadAsync(id);
    }

You can update the entity in two ways:

  • Get the entity by ID, update the set of fields in the entity, and call SaveChanges.

  • Call the Patch method, in which you need to specify the entity and references to the fields in it.

    public async Task Update(ToDoItem item)
    {
        using var session = store.OpenAsyncSession();
        session.Advanced.Patch(item, x => x.Deadline, item.Deadline);
        session.Advanced.Patch(item, x => x.Title, item.Title);
        session.Advanced.Patch(item, x => x.Description, item.Description);
        await session.SaveChangesAsync();
    }

It is also necessary to register our repository in the DI container, in the Program.cs file:

var store = new DocumentStore
{
    Urls = new[] { "http://localhost:8080" },
    Database = "Todos"
};
store.Initialize();
builder.Services.AddSingleton(store);

Backend: cache

We use Garnet as a cache. This is a remote cache-store from Microsoft Research. At its core, this solution is written in C#. This cache supports the RESP protocol, so as a client we will be able to use the StackExchange.Redis library.

Let’s install the library:

dotnet add package StackExchange.Redis

Let’s add the CacheService class and implement the first GetOrAdd method:

public async Task GetOrAdd(string key, Func> itemFactory, int expirationInSecond)
    {
        // если такой элемент уже есть, возвращаем его
        var existingItem = await _database.StringGetAsync(key);
        if (existingItem.HasValue)
        {
            return JsonSerializer.Deserialize(existingItem);
        }
 
        // забираем новый элемент
        var newItem = await itemFactory();
 
        // добавляем элемент в кеш

        await _database.StringSetAsync(key, JsonSerializer.Serialize(newItem), TimeSpan.FromSeconds(expirationInSecond));
        return newItem;
    

The Invalidate method to clear the cache:

    public async Task Invalidate(string key)
    {
        await _database.KeyDeleteAsync(key);
    }

Backend: connecting everything together

Now let’s add a new ToDoService class, which will combine the logic of the repository and the cache. When we receive data, we will add it to the cache, and when we update it, we will disable it.

public class ToDoService(ToDoRepository repository, CacheService cacheService)
{
    public async Task> GetAllAsync()
    {
        return await cacheService.GetOrAdd($"ToDoItem:all", 
            async () => await repository.GetAll(), 30);
    }
 
    public async Task GetByIdAsync(string id)
    {
        return await cacheService.GetOrAdd($"ToDoItem:{id}", 
            async () => await repository.GetById(id), 30);
    }
 
    public async Task CreateAsync(ToDoItem item)
    {
        await repository.Create(item);
        await cacheService.Invalidate($"ToDoItem:all");
    }
 
    public async Task UpdateAsync(ToDoItem item)
    {
        await repository.Update(item);
        await cacheService.Invalidate($"ToDoItem:{item.Id}");
        await cacheService.Invalidate($"ToDoItem:all");
    }
 
    public async Task DeleteAsync(string id)
    {
        await repository.Delete(id);
        await cacheService.Invalidate($"ToDoItem:{id}");
        await cacheService.Invalidate($"ToDoItem:all");
    }
}

Let’s register everything we need in Program.cs:

var store = new DocumentStore
{
    Urls = new[] { "http://localhost:8080" },
    Database = "Todos"
};
store.Initialize();
builder.Services.AddSingleton(store);
builder.Services.AddScoped();
builder.Services.AddScoped();
builder.Services.AddScoped();
builder.Services.AddSingleton(ConnectionMultiplexer.Connect("localhost"));

And let’s add endpoints using the Minimal API approach in the same Program.cs:

app.MapGet("api/todo", async ([FromServices] ToDoService toDoService) 
    => await toDoService.GetAllAsync());
    
app.MapPost("api/todo", async ([FromBody] ToDoItem item, [FromServices] ToDoService toDoService) 
    => await toDoService.CreateAsync(item));
 
app.MapPut("api/todo", async ([FromBody] ToDoItem item, [FromServices] ToDoService toDoService) 
    => await toDoService.UpdateAsync(item));
 
app.MapGet("api/todo/{id}", async (string id, [FromServices] ToDoService toDoService) 
    => await toDoService.GetByIdAsync(id));
 
app.MapDelete("api/todo/{id}", async (string id, [FromServices] ToDoService toDoService)
    => await toDoService.DeleteAsync(id));

Infrastructure

To test our application, you need to bring up RavenDB and Garnet. This can be done using Docker Compose.

Let’s add the Launcher folder and the docker-compose.yml file to the project:

version: '3.8'
 
services:
  ravendb:
    image: ravendb/ravendb:latest
    environment:
      RAVEN_DB_URL: "http://0.0.0.0:8080"
      RAVEN_DB_PUBLIC_URL: "http://ravendb:8080"
      RAVEN_DB_TCP_URL: "tcp://0.0.0.0:38888"
    ports:
      - "8080:8080"
 
  garnet:
    image: 'ghcr.io/microsoft/garnet'
    ulimits:
      memlock: -1
    ports:
      - "6379:6379"
    volumes:
      - garnetdata:/data
 
volumes:
  ravendb_data:
  garnetdata:

Let’s execute the command docker compose up -d. RavenDB is now available at localhost:8080 and Garnet is available at localhost:6379.

You need to go to the RavenDB address and perform the initial configuration, then go to the Databases section and create the Todos database:

Now we can run our API and test its functionality. Let’s run the program, open Swagger and execute a POST request to create a task:

The request was completed successfully. We can go to the database and see the tasks:

The easiest way to check the cache is in the debugger: the first time we execute a GET request, we should go to the database, and the second time we should go to the cache:

Frontend

Now let’s write the UI for our tracker. We will use the Blazor framework so that the program is written entirely in .NET 🙂

Let’s add the Blazor project to our solution:

By analogy with the backend, we will add the classes ToDoItem to describe the task object and ToDoService to interact with the Backend.
ToDoItem:

public class ToDoItem
{
    public string Id { get; set; }
    [Required]
    public string Title { get; set; }
    [Required]
    public string Description { get; set; }
    public DateTime Deadline { get; set; }
}

ToDoService:

public class ToDoService(HttpClient httpClient)
{
    public async Task> GetToDoItemsAsync() 
        => await httpClient.GetFromJsonAsync>("todo");
    
    public async Task GetToDoItemByIdAsync(string id)
        => await httpClient.GetFromJsonAsync($"todo/{id}");
 
    public async Task CreateToDoItemAsync(ToDoItem item)
        => await httpClient.PostAsJsonAsync("todo", item);
 
    public async Task UpdateToDoItemAsync(ToDoItem item)
        => await httpClient.PutAsJsonAsync($"todo/{item.Id}", item);
 
    public async Task DeleteToDoItemAsync(string id)
        =>  await httpClient.DeleteAsync($"todo/{id}");
}

In the Program.cs file, register the service and HttpCilent:

builder.Services.AddScoped(_ => 
new HttpClient { BaseAddress = new Uri("http://localhost:5042/api/") });

builder.Services.AddScoped();

Now the visual. All the logic will be in the files CreateItem.razor, EditItem.razor and ToDoList.razor. The complete code can be viewed on GitHub, here we will focus on the key points.

To wake up various services on the page, you can use the @inject helper:

@inject ToDoService ToDoService
@inject NavigationManager NavigationManager

The EditForm tag is used for forms:


    
    
    

                   

   

                   

   

                   

     

If you are using .NET8, you must explicitly specify the rendermode parameter in the page files for the methods to work correctly:

@rendermode InteractiveServer

We launch the programs: first docker-compose for the infrastructure, then our API and frontend:

Conclusion

In this article, we tried to use everything written in the .NET platform, from the front-end framework to the database, to create a web application. Of course, these are not the only programs written in C#. There is also YARP, which is very convenient to use as a proxy for microservices, or LiteDB – an in-memory database that is convenient for testing.

Full code on GitHub


Other articles on the topic

Business modeling: how and why to use it in development
We analyze how process diagrams help business, and when processes really need to be optimized.

IT company metrics: what numbers you need to know to succeed in business
Twelve metrics that can be used to evaluate business performance

How I built corporate knowledge management from an IT product
We share the experience of organizing and building a corporate knowledge base for product IT development

Related posts