Telerik blogs
ASP.NET Core

Hangfire is an excellent tool for executing background tasks accurately and reliably. Check out how to create a routine system in ASP.NET Core using the three types of jobs available in Hangfire.

REST APIs are powerful web applications that perform specific functions efficiently. However, they rely on requests to perform the tasks they were designed to do. Sometimes, making continuous requests, such as notifications every minute, is not feasible or efficient. For these scenarios, the ideal is to use a routine service, where you can schedule tasks to be executed automatically within a time interval.

This is where Hangfire stands out. Hangfire is an open-source library for ASP.NET Core that allows you to schedule and execute background tasks simply and efficiently.

In this blog post, we will learn what Hangfire is, how to implement a notification system for subscriptions that are about to expire, and what the possibilities of using Hangfire are.

What Is Hangfire?

Hangfire is an open-source tool for running background processing developed for ASP.NET Core. Hangfire stands out for its simplicity. It is easy to configure and use and does not depend on any external service, such as a Windows Service.

Another advantage of Hangfire is the implementation options, such as fire-and-forget jobs, recurring jobs and delayed jobs, allowing the creation of a specific job for each need.

Some scenarios where Hangfire fits perfectly are issuing recurring reports, automated notifications, cleaning temporary data (such as history tables), sending emails and others.

How Hangfire Works

In Hangfire, three main points stand out: the client, the storage of job data in specific tables and the server processing the job.

Hangfire Client

The client is created directly in the ASP.NET Core application by registering the Hangfire service in the Startup or Program.cs class.

The client queues or schedules jobs. During execution, it stores the job information in the database, requiring only the database configuration to be used.

Example of client creation:

services.AddHangfire(config =>
config.UseSqlServerStorage("ConnectionString"));

This configures Hangfire to use SQL Server as the job data storage.

Saving Job Data to the Database

When a job is enqueued (BackgroundJob.Enqueue() or RecurringJob.AddOrUpdate()), Hangfire saves the job state to tables in the SQL Server database.

These tables store information about the job, such as the job type, arguments, execution status and logs.

Example of a “fire-and-forget” job:

BackgroundJob.Enqueue(() => Console.WriteLine("Send notification"));

Starting the Hangfire Server

The server is responsible for processing jobs. It is started inside the application, consuming tasks from the database and executing them according to the scheduling rules.

The Server is configured using the Hangfire middleware in Startup or Program.cs:

app.UseHangfireServer();
app.UseHangfireDashboard(); //Optional

The server checks the database for queued jobs, executes them and updates the job status as it progresses (in progress, completed, failed, etc.).

The image below summarizes how a job runs with Hangfire.

Hangfire operation flowchart: 1. Hangfire client creates a job instance, which goes to job storage. 2. Job storage returns a caller to client. 3. Hangfire server fetches next from storage. 4. job storage runs in background to server.

Hands-on with Hangfire

This blog post will use Hangfire’s three types of Jobs: recurring jobs, delayed jobs and fire-and-forget jobs.

We will assume that you need to develop a service subscription notification system, where the customer must be notified sometime before the subscription expires, when the subscription is about to expire and when the subscription has been renewed. Thus, we will create three jobs, each responsible for a task.

You can access the complete source code in this GitHub repository: Notify Subs Source code.

Prerequisites

You need to have a version of .NET that supports minimal APIs. The example in the post was created in version 8.

You also need to have a local connection to SQL Server (you can use another database or even local data).

1. Creating the Recurring Job

Before creating the recurring job, let’s first create the project solution and a layer called “Subscription.Contract” that will be used to share entities and classes for the three jobs.

To create the solution project, run the command below:

dotnet new sln -n NotifySubs

To create the “Subscription.Contract” project and add it to the solution, use the commands below:

dotnet new classlib -n Subscription.Contract
dotnet sln add Subscription.Contract/Subscription.Contract.csproj

Then, open the solution with your favorite IDE, this post uses Visual Studio.

Inside the “Subscription.Contract” project, create a new folder called “Models” and inside it, create the following entity records:

  • Subscriber
namespace Subscription.Contract.Models;
public record Subscriber(Guid Id, string Name, string Email, DateTime ExpirationDate, bool SubscriptionRenewed);
  • SubscriptionNotification
namespace Subscription.Contract.Models;
public record SubscriptionNotification(Guid Id, string SubscriberName, string SubscriberEmail, string EmailBody, DateTime ExpirationDate);

The Subscriber entity represents customers with a subscription plan and will be used by jobs to insert notification information, represented by the SubscriptionNotification entity.

Now, let’s create the first job, the recurring notification job. So, in the project root execute the following commands to create the project and add it to the solution:

dotnet new web -n Recurring.Notification.Job
dotnet sln add Recurring.Notification.Job/Recurring.Notification.Job.csproj

Then, execute the following commands to add the contract project reference to it.

cd Recurring.Notification.Job
dotnet add reference ../Subscription.Contract/Subscription.Contract.csproj

Now, double-click on the Recurring.Notification.Job project and add in the ItemGroup tag the Hangfire, EF Core and SQL Server dependencies:

    <PackageReference Include="Hangfire" Version="1.8.14" />
    <PackageReference Include="Hangfire.AspNetCore" Version="1.8.14" />
    <PackageReference Include="Hangfire.SqlServer" Version="1.8.14" />
    <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.8" />
    <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.8">
      <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
      <PrivateAssets>all</PrivateAssets>
    </PackageReference>
    <PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="8.0.8" />
    <PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.8" />

Then, create a new folder called “Services” and, inside it, create the class below:

using Microsoft.EntityFrameworkCore;
using Subscription.Contract.Data;
using Subscription.Contract.Models;

namespace Recurring.Notification.Job.Services;
public class NotificationService
{
    private readonly NotificationContext _context;

    public NotificationService(NotificationContext context)
    {
        _context = context;
    }

    public async Task SendExpirationNotificationsAsync()
    {
        try
        {
            var subscribersToNotify = await _context.Subscribers
                .Where(s => s.ExpirationDate <= DateTime.Now.AddDays(7))
                .ToListAsync();

            if (!subscribersToNotify.Any())
            {
                Console.WriteLine("Nothing to process");
                return;
            }

            var subscriptionNotifications = new List<SubscriptionNotification>();

            foreach (var subscriber in subscribersToNotify)
            {
                var emailBody = $@"<html>
                                    <body>
                                        <h1>Subscription Expiration Notice</h1>
                                        <p>Dear {subscriber.Name},</p>
                                        <p>This is a reminder that your subscription will expire on <strong>{subscriber.ExpirationDate:MMMM dd, yyyy}</strong>.</p>
                                        <p>If you wish to continue enjoying our services, please renew your subscription before the expiration date.</p>
                                        <p>Thank you for your continued support!</p>
                                        <p>Sincerely, NotifyMe</p>
                                    </body>
                                    </html>";

                subscriptionNotifications.Add(new SubscriptionNotification(Guid.NewGuid(), subscriber.Name, subscriber.Email, emailBody, subscriber.ExpirationDate));
            }

            await _context.SubscriptionNotifications.AddRangeAsync(subscriptionNotifications);

            await _context.SaveChangesAsync();
        }
        catch (Exception ex)
        {
            Console.WriteLine(ex.Message);
        }
    }
}

Here is a service class with a SendExpirationNotificationsAsync() method that searches for subscribers with seven days left to expire, based on the subscription expiration date. If any are found, the list is traversed and an email is created to be written to the SubscriptionNotifications table, with the HTML text to be sent in the notification email. This table could be read by an email service (which is not covered in this post).

Now, let’s configure the Program class so that the project becomes a recurring job. Replace the existing code in the Program.cs class with the code below:

using Hangfire;
using Microsoft.EntityFrameworkCore;
using Recurring.Notification.Job.Services;
using Subscription.Contract.Data;
using Subscription.Contract.Models;

var builder = WebApplication.CreateBuilder(args);

var connectionString = builder.Configuration.GetConnectionString("DefaultConnection");
builder.Services.AddDbContext<NotificationContext>(x => x.UseSqlServer(connectionString));

builder.Services.AddHangfire(config =>
    config.UseSqlServerStorage(connectionString));

builder.Services.AddHangfireServer();

builder.Services.AddScoped<NotificationService>();

var app = builder.Build();

app.UseHangfireDashboard();
app.UseHangfireServer();

var recurringJobManager = app.Services.GetRequiredService<IRecurringJobManager>();

recurringJobManager.AddOrUpdate<NotificationService>("SendExpirationNotifications", service =>
    service.SendExpirationNotificationsAsync(), Cron.Minutely);

app.Run();

Note that the Hangfire configuration is simple. The code builder.Services.AddHangfire(config => config.UseSqlServerStorage(connectionString)); is creating a connection with the SQL Server that Hangfire uses to create its internal tables, where we pass as a parameter the connection string with the database (which will be created later).

There is also the code app.UseHangfireDashboard(); app.UseHangfireServer(); that configures the Hangfire dashboard, which is a very useful resource for viewing job processes. In addition, we also inform the compiler to create a Hangfire server.

Finally, the code:

var recurringJobManager = app.Services.GetRequiredService<IRecurringJobManager>();
recurringJobManager.AddOrUpdate<NotificationService>("SendExpirationNotifications", service =>
service.SendExpirationNotificationsAsync(), Cron.Minutely);

gets an instance of the IRecurringJobManager service from the ASP.NET Core dependency injection container. Note that the AddOrUpdate() method is used, where a new recurring job is added or updated. It is named SendExpirationNotifications and is configured to call the SendExpirationNotificationsAsync method of the NotificationService class. This method will be executed according to the cron expression Cron.Minutely, which indicates that the task will be executed every minute, but it can be configured as needed with several other expressions such as daily, hourly, etc.

Now, open the appsettings.json file and add the following code to it:

 "ConnectionStrings": {
   "DefaultConnection": "Server=(localdb)\\mssqllocaldb;Database=expiration_notifications;Trusted_Connection=True;MultipleActiveResultSets=true"
 }

Here we are defining the connection string, where the default local SQL Server connection is used, if you have a different configuration, remember to change it here.

Creating the Database and Tables

Before testing the job, you need to create the database and tables used by the job and add a sample record. You can use the SQL script below:

Create database

CREATE DATABASE [expiration_notifications]Create tables

USE [expiration_notifications]
GO

SET ANSI_NULLS ON
GO

SET QUOTED_IDENTIFIER ON
GO

CREATE TABLE [dbo].[Subscribers](
	[Id] [uniqueidentifier] NOT NULL,
	[Name] [nvarchar](max) NOT NULL,
	[Email] [nvarchar](max) NOT NULL,
	[ExpirationDate] [datetime2](7) NOT NULL,
	[SubscriptionRenewed] [bit] NOT NULL,
 CONSTRAINT [PK_Subscribers] PRIMARY KEY CLUSTERED 
(
	[Id] ASC
)WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON) ON [PRIMARY]
) ON [PRIMARY] TEXTIMAGE_ON [PRIMARY]
GO

USE [expiration_notifications]
GO

SET ANSI_NULLS ON
GO

SET QUOTED_IDENTIFIER ON
GO

CREATE TABLE [dbo].[SubscriptionNotifications](
	[Id] [uniqueidentifier] NOT NULL,
	[SubscriberName] [nvarchar](max) NOT NULL,
	[SubscriberEmail] [nvarchar](max) NOT NULL,
	[EmailBody] [nvarchar](max) NOT NULL,
	[ExpirationDate] [datetime2](7) NOT NULL,
 CONSTRAINT [PK_SubscriptionNotifications] PRIMARY KEY CLUSTERED 
(
	[Id] ASC
)WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON) ON [PRIMARY]
) ON [PRIMARY] TEXTIMAGE_ON [PRIMARY]
GO

–Insert record

USE [expiration_notifications]
GO
INSERT INTO [dbo].[Subscribers]
           ([Id]
           ,[Name]
           ,[Email]
           ,[ExpirationDate]
           ,[SubscriptionRenewed])
     VALUES
           ('E5C668BC-EBF4-432D-AF59-13BD88F4B87A'
           ,'John Smith'
           ,'js@example.com'
           ,GETDATE()
           ,0)
GO

Running the Recurring Job

Now, just run the project and access the dashboard at the address http://localhost:PORT/hangfire. In the succeeded tab, you can see the job that just ran. By clicking on the link “NotificationService.SendExpirationNotificationsAsync” you can see some details such as the current culture, the RecurringJobId, the elapsed time and the status:

Recurring job dashboard succeeded

Recurring job dashboard details

You can also explore other details, such as the server name, when it was last run and more. And if you check the database, you can see the Hangfire tables:

Hangfire tables

And the records that were inserted by the job execution:

Recurring job record

2. Creating the Delayed Job

Delayed jobs run only once after a certain time interval. In the example in the post, we will create a job to run every two minutes, which will execute a method to search for subscribers who have their subscription within a day of expiring. It will create a notification email to be saved in the SubscriptionNotifications table if it finds any.

To create the application, use the commands below in the project root.

Creating the project:

dotnet new web -n Delayed.Notification.Job
dotnet sln add Delayed.Notification.Job/Delayed.Notification.Job.csproj

Adding the project reference to the solution:

cd Delayed.Notification.Job
dotnet add reference ../Subscription.Contract/Subscription.Contract.csproj

Then, add references to the Delayed.Notification.Job project:

   <PackageReference Include="Hangfire" Version="1.8.14" />
   <PackageReference Include="Hangfire.AspNetCore" Version="1.8.14" />
   <PackageReference Include="Hangfire.SqlServer" Version="1.8.14" />
   <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.8" />
   <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.8">
     <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
     <PrivateAssets>all</PrivateAssets>
   </PackageReference>
   <PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="8.0.8" />
   <PackageReference Include="Microsoft. EntityFrameworkCore" Version="8.0.8" />

Now, let’s create a service class in the project. So, create a folder called “Services” and, inside it, create the class below:

  • DelayedService
using Microsoft.EntityFrameworkCore;
using Subscription.Contract.Data;
using Subscription.Contract.Models;

namespace Delayed.Notification.Job.Services;
public class DelayedService
{
    private readonly NotificationContext _context;

    public DelayedService(NotificationContext context)
    {
        _context = context;
    }

    public async Task SendFinalExpirationReminderAsync()
    {
        var subscribersToNotify = await _context.Subscribers
            .Where(s => s.ExpirationDate <= DateTime.Now.AddDays(1))
            .ToListAsync();

        if (!subscribersToNotify.Any())
        {
            Console.WriteLine("Nothing to process");
            return;
        }

        foreach (var subscriber in subscribersToNotify)
        {
            try
            {
                var emailBody = $@"<html>
                                   <body>
                                       <h1>Final Reminder: Subscription Expiration</h1>
                                       <p>Dear {subscriber.Name},</p>
                                       <p>Your subscription is set to expire tomorrow on <strong>{subscriber.ExpirationDate:MMMM dd, yyyy}</strong>.</p>
                                       <p>Please renew your subscription to avoid interruption of service.</p>
                                       <p>Thank you for your continued support!</p>
                                       <p>Sincerely, NotifyMe</p>
                                   </body>
                                   </html>";

                var notification = new SubscriptionNotification(Guid.NewGuid(), subscriber.Name, subscriber.Email, emailBody, subscriber.ExpirationDate);

                await _context.SubscriptionNotifications.AddAsync(notification);
                await _context.SaveChangesAsync();
            }
            catch (Exception ex)
            {
                Console.WriteLine(ex.Message);
            }
        }
    }
}

Note that the SendFinalExpirationReminderAsync() method searches for subscriber records that are one day away from having their subscription expire. Then, if it finds any, it creates an email body and saves the record in the SubscriptionNotifications table.

Add the connection string in the “appsettings.json,” as in the recurring job:

 "ConnectionStrings": {
    "DefaultConnection": "Server=(localdb)\\mssqllocaldb;Database=expiration_notifications;Trusted_Connection=True;MultipleActiveResultSets=true"
  },

The last step is to configure the Program.cs file, so replace the existing code in it, with the code below:

using Delayed.Notification.Job.Services;
using Hangfire;
using Microsoft.EntityFrameworkCore;
using Subscription.Contract.Data;

var builder = WebApplication.CreateBuilder(args);

var connectionString = builder.Configuration.GetConnectionString("DefaultConnection");

builder.Services.AddDbContext<NotificationContext>(x => x.UseSqlServer(connectionString));

builder.Services.AddHangfire(config => config.UseSqlServerStorage(connectionString));

builder.Services.AddHangfireServer();

builder.Services.AddTransient<DelayedService>();

var app = builder.Build();

app.UseHangfireDashboard();

var backgroundJobClient = app.Services.GetRequiredService<IBackgroundJobClient>();

backgroundJobClient.Schedule((DelayedService service) => service.SendFinalExpirationReminderAsync(), TimeSpan.FromMinutes(2));

app.Run();

Note that the delayed job configuration is added through the code:

var backgroundJobClient = app.Services.GetRequiredService<IBackgroundJobClient>();

backgroundJobClient.Schedule((DelayedService service) => service.SendFinalExpirationReminderAsync(), TimeSpan.FromMinutes(2));

Here, an instance of the “IBackgroundJobClient” interface is created that executes the “SendFinalExpirationReminderAsync()” method once every two minutes.

Now, if you run the job and access the Hangfire dashboard, you can see that the delayed job was executed successfully:

Delayed job dashboard succeeded

3. Creating the Fire-and-Forget Job

Now let’s create the fire-and-forget job that runs only once, immediately after creation.

So, to create the application, use the commands below:

dotnet new web -n FireAndForget.Notification.Job
dotnet sln add FireAndForget.Notification.Job/FireAndForget.Notification.Job.csproj

And then:

cd FireAndForget.Notification.Job
dotnet add reference ../Subscription.Contract/Subscription.Contract.csproj

Now, double-click on the FireAndForget.Notification.Job project and edit it to include the Hangfire, EF Core and SQL Server dependencies.

    <PackageReference Include="Hangfire" Version="1.8.14" />
    <PackageReference Include="Hangfire.AspNetCore" Version="1.8.14" />
    <PackageReference Include="Hangfire.SqlServer" Version="1.8.14" />
    <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.8" />
    <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.8">
      <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
      <PrivateAssets>all</PrivateAssets>
    </PackageReference>
    <PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="8.0.8" />
    <PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.8" />

Then, create a new folder called “Services” and, inside it, create the class below:

  • FireAndForgetService
using Microsoft.EntityFrameworkCore;
using Subscription.Contract.Data;
using Subscription.Contract.Models;

namespace FireAndForget.Notification.Job.Services;
public class FireAndForgetService
{
    private readonly NotificationContext _context;

    public FireAndForgetService(NotificationContext context)
    {
        _context = context;
    }

    public async Task ConfirmSubscriptionRenewalAsync()
    {
        try
        {
            var subscribersToNotify = await _context.Subscribers
                .Where(s => s.SubscriptionRenewed)
                .ToListAsync();

            if (!subscribersToNotify.Any())
            {
                Console.WriteLine("Nothing to process");
                return;
            }

            foreach (var subscriber in subscribersToNotify)
            {
                var newExpirationDate = subscriber.ExpirationDate.AddMonths(1);

                var emailBody = $@"<html>
                                <body>
                                    <h1>Subscription Renewal Confirmation</h1>
                                    <p>Dear {subscriber.Name},</p>
                                    <p>Thank you for renewing your subscription. Your new expiration date is <strong>{newExpirationDate:MMMM dd, yyyy}</strong>.</p>
                                    <p>We appreciate your continued support!</p>
                                    <p>Sincerely, NotifyMe</p>
                                </body>
                                </html>";

                var notification = new SubscriptionNotification(Guid.NewGuid(), subscriber.Name, subscriber.Email, emailBody, newExpirationDate);

                await _context.SubscriptionNotifications.AddAsync(notification);
                await _context.SaveChangesAsync();
            }
        }
        catch (Exception ex)
        {
            Console.WriteLine(ex.Message);
        }
    }
}

In this service, the ConfirmSubscriptionRenewalAsync() method is used to prepare subscription renewal confirmation notifications for subscribers who have already renewed their subscriptions.

It starts by retrieving from the database all subscribers whose subscriptions have been renewed. After obtaining this list, the method checks to see if there are any subscribers to be notified. If there are none, it simply prints “Nothing to process” to the console and ends the execution.

The method calculates a new subscription expiration date for each renewed subscriber by adding one month to the current one. Then, it constructs a renewal confirmation email with this new date, personalized with the subscriber’s name and the new formatted expiration date. Finally, it creates a SubscriptionNotification object containing the subscriber’s information, the email body and the new expiration date.

This notification object is then added to the database, and the changes are saved asynchronously.

The fire-and-forget job fits perfectly in scenarios like this one where you only need to run it once after its creation.

So, replace the in the Program.cs file with the code below:

using FireAndForget.Notification.Job.Services;
using Hangfire;
using Microsoft.EntityFrameworkCore;
using Subscription.Contract.Data;

var builder = WebApplication.CreateBuilder(args);

var connectionString = builder.Configuration.GetConnectionString("DefaultConnection");

builder.Services.AddDbContext<NotificationContext>(x => x.UseSqlServer(connectionString));

builder.Services.AddHangfire(config => config.UseSqlServerStorage(connectionString));

builder.Services.AddHangfireServer();

builder.Services.AddTransient<FireAndForgetService>();

var app = builder.Build();

app.UseHangfireDashboard();

var backgroundJobClient = app.Services.GetRequiredService<IBackgroundJobClient>();

backgroundJobClient.Enqueue((FireAndForgetService service) => service.ConfirmSubscriptionRenewalAsync());

app.Run();

Note that Hangfire configuration is done through the code:

var backgroundJobClient = app.Services.GetRequiredService<IBackgroundJobClient>();

backgroundJobClient.Enqueue((FireAndForgetService service) => service.ConfirmSubscriptionRenewalAsync());

Which uses the IBackgroundJobClient interface and the Enqueue() method to execute the ConfirmSubscriptionRenewalAsync() method only once.

Finally, you must add the connection string to the appsettings.json file as in the previous jobs. The last step is to run the job and check that it is working correctly:

Fire and Forget job succeeded

Culture Customization

By default, Hangfire uses the local culture where the server was created. So, if the Hangfire instance is created in a location in the United States, for example, the dashboard will be displayed in English:

US culture

However, you can change this default setting using the code below, which sets the culture to Spanish Spain (es-ES):

var cultureInfo = new CultureInfo("es-ES");
CultureInfo.DefaultThreadCurrentCulture = cultureInfo;
CultureInfo.DefaultThreadCurrentUICulture = cultureInfo;

So, you can check out the culture changed to the new language:

ES culture

Conclusion and Final Considerations

Hangfire is an excellent solution for background task processing, supporting different scenarios with three main types of jobs: recurring, delayed and fire-and-forget. Each of these types serves a specific need.

In this post, we saw how to create and configure these three types of jobs. Recurring jobs are ideal for tasks that need to be executed regularly, such as daily reports or reminders. Delayed jobs are used when you need to schedule a task to run at a specific time in the future, such as sending a reminder email after a certain period. Fire-and-forget jobs are ideal for tasks that should be triggered immediately and run in the background, without requiring follow-up, such as a notification.

In addition, we explored Hangfire’s dashboard, which allows you to monitor and manage scheduled and running tasks, and we also saw how to change the local culture to adapt the routines to different regional settings.

So, consider using Hangfire to simplify and optimize background task processing.


assis-zang-bio
About the Author

Assis Zang

Assis Zang is a software developer from Brazil, developing in the .NET platform since 2017. In his free time, he enjoys playing video games and reading good books. You can follow him at: LinkedIn and Github.

Related Posts

Comments

Comments are disabled in preview mode.