The idea for ShortenUrl came from working on payment reminder emails for 5in5. Those emails included payment links, and some of the URLs were very long.
I wanted a shorter link to include in a reminder, while keeping the original payment URL as the destination. That led me to build a small .NET API: send it a long URL, get a short link back, and redirect the visitor when they open it.
The basic idea was simple. The interesting part was working through the details: generating codes, avoiding duplicates, identifying API clients, and counting clicks.
The project is available on GitHub. This post follows the current code, including a few things I would improve. The examples use fictional addresses, never real payment links or customer data.
Two flows, with different responsibilities
There are two sides to the API:
- Creating a link: an application sends a URL and authenticates with an API key.
- Opening a link: a visitor opens the short address and is redirected to the stored destination.
The visitor does not need the application's API key. That key belongs on the server making the creation request, not inside the email or in a public browser application.
The solution targets .NET 8 and is split into four projects: Domain, Application, Infrastructure and API. The Application layer contains the requests and handlers; Infrastructure provides the SQL Server storage and URL service; the API exposes the endpoints.
I use Mediator to pass requests to their handlers and EF Core to read and write the stored links.
Creating a short link
The creation endpoint is an authorized controller action. This is a formatted extract from it:
[Route("api/[controller]")]
[ApiController]
[Authorize]
public class AddressController : ApiController
{
[HttpPost]
[Route("shorten")]
public async Task<IActionResult> ShortenUrlAsync(
[FromBody] CreateShortenAddressCommand request)
{
var model = await Mediator.Send(request).ConfigureAwait(false);
return Ok(model);
}
}
The endpoint is POST /api/address/shorten. The request body is deliberately small:
{
"address": "https://payments.example.com/pay?reference=demo-invoice&token=example-only"
}
The calling application also sends its key in the x-api-key header. I have left out the header value here because there is no reason for an article to contain a working credential.
The handler reads the authenticated API client ID, then passes the request to the URL service. That means the stored link can be associated with the application that created it.
What gets stored
The main entity keeps both sides of the mapping: the short code and the original destination.
public class ShortAddress : BaseEntity
{
public string Domain { get; set; }
public string UniqueId { get; set; }
public string ShortUrl { get; set; }
public string RedirectUrl { get; set; }
public DateTimeOffset? ExpiresAt { get; set; }
public int Hits { get; set; }
public Guid ApiClientId { get; set; }
public ApiClient ApiClient { get; set; }
}
UniqueId is the code used to look up the link. ShortUrl combines a configured domain and that code. RedirectUrl is the destination, and Hits starts at zero.
There is an ExpiresAt field, but the current redirect flow does not enforce it. Having a property in the model is not the same as having a working expiration feature.
The configured domain list can contain more than one domain. The service selects one when it creates a link. The code remains globally unique in the database, so the lookup does not depend on which configured domain was chosen.
Generating a code is only half the job
The first part of CreateAsync generates a code and checks whether it already exists:
var uniqueId = Randomizer.GenerateUniqueUrl();
var exists = await repository
.ListQueryable<ShortAddress>(x => x.UniqueId.Equals(uniqueId))
.AsNoTracking()
.FirstOrDefaultAsync();
if (exists != null)
{
return await CreateAsync(request, apiClientId);
}
If the code is already taken, the method tries again. Once it has a candidate, it creates the mapping:
var domainName = domain.RandomDomain;
var entity = new ShortAddress
{
Domain = domainName,
UniqueId = uniqueId,
ShortUrl = $"{domainName}/{uniqueId}",
RedirectUrl = request.Address,
Hits = 0,
ApiClientId = apiClientId
};
await repository.AddAsync(entity);
await repository.SaveChangesAsync();
return mapper.Map<ShortAddressModel>(entity);
For the fictional URL above, the useful fields in a response might look like this. This is an illustrative, reduced response, not the complete model:
{
"uniqueId": "AbKd",
"shortUrl": "https://short.example.com/AbKd",
"redirectUrl": "https://payments.example.com/pay?reference=demo-invoice&token=example-only",
"hits": 0
}
The database also has unique indexes:
builder.HasIndex(x => x.ShortUrl).IsUnique();
builder.HasIndex(x => x.UniqueId).IsUnique();
Those indexes matter more than the earlier existence check. Two requests can generate the same code, both find that it is available, and then try to save it. The database prevents both rows from being stored.
The current method does not catch that insert conflict and generate another code. That is one improvement I would make: a limited retry loop around the insert, handling the specific unique-key conflict. I would also replace the recursive retry with a bounded loop so the retry policy is explicit.
Opening the link
The public route is short enough to show in full:
app.MapGet("/{id}", async (string id, IMediator mediator) =>
{
var url = await mediator.Send(
new GetRedirectAddressQuery { UniqueId = id })
.ConfigureAwait(false);
return Results.Redirect(url);
});
The query handler asks the URL service for the destination. The service reads only the fields it needs:
var entity = await repository
.ListQueryable<ShortAddress>(x => x.UniqueId == uniqueId)
.AsNoTracking()
.Select(x => new
{
Id = x.Id,
RedirectUrl = x.RedirectUrl
})
.FirstOrDefaultAsync();
if (entity == null)
{
return $"{domain.RandomDomain}/not-found/{uniqueId}";
}
await mediator.Publish(
new AddressClicked { AddressId = entity.Id })
.ConfigureAwait(false);
return entity.RedirectUrl;
AsNoTracking avoids asking EF Core to track an entity that this lookup will not edit. The projection also avoids loading the whole record just to get its ID and destination.
If the code is unknown, the service returns the address of a link-not-found page. At the moment, that HTML page returns 200. I would change the missing-link behavior to return a real 404, while keeping a useful explanation for the visitor.
Counting clicks without reading and rewriting the total
The AddressClicked notification has a handler that increments the counter in SQL:
await repository.ExecuteNonQueryAsync(
"UPDATE Addresses SET Hits += 1 WHERE Id = @Id",
new Models.QueryParameter
{
ParameterName = "Id",
ParameterValue = notification.AddressId
});
The repository turns the supplied value into a SQL parameter. It is not inserted into the SQL text.
Updating Hits directly avoids a read-modify-write sequence in C#, where two requests could read the same total and overwrite each other's increment.
There is an important limit here: the notification is awaited before returning the destination. Mediator does not turn this into a background job. If the database update is slow, the redirect waits; if it fails, it can prevent the redirect from completing.
For a payment link, I would think carefully about whether a click-counter failure should block navigation. A background queue is one possible future design, but it would need its own handling for retries and duplicate events. The current project does not implement that queue.
I would also describe Hits as a basic request counter, not full analytics. It does not identify unique people, filter automated traffic, or tell me that a payment was completed.
Where the cache is actually used
The project includes a SQL-backed distributed cache, but the redirect lookup above still reads the link from the database.
The cache is used by API-key authentication. This extract shows the lookup:
if (cacheProvider.TryRetrieve<ApiClient>(
$"CLIENT_{apikey}", out ApiClient cached))
{
return cached;
}
var apiClient = await repository
.ListGenericQueryable<ApiClient>(x => x.ApiKey.Equals(apikey))
.AsNoTracking()
.FirstOrDefaultAsync();
if (apiClient == null)
{
return null;
}
await cacheProvider.StoreAsync<ApiClient>(
$"CLIENT_{apikey}", apiClient);
return apiClient;
This is current code, not a pattern I would copy unchanged into a new service. The API key is part of the cache key, and the cached value is the client entity. I would prefer a smaller authentication record and a cache key derived from a hash of the credential.
The default cache lifetime is one day. A key that has been changed in the database could still be accepted from cache during that period unless its entry is invalidated. Key rotation and revocation need to be part of that design.
The authentication handler also currently logs the supplied key when authentication fails. I would remove the key value from that log and keep only a safe failure message.
What I would improve before taking it further
The repository implements link creation, redirects, API-client authentication and basic click counting. It is not a complete public link-management platform.
The next changes I would make are fairly practical:
- Validate destinations as absolute HTTP or HTTPS URLs, with a clear length limit and useful validation errors.
- Handle insert-time code collisions with a bounded retry.
- Enforce expiration in the redirect path, rather than just storing
ExpiresAt. - Add request limits and controls against misuse. An API key alone does not make a destination trustworthy.
- Stop logging credential values and make cached authentication changes take effect predictably.
- Decide how click tracking should behave when its storage is unavailable.
- Return proper missing-link status codes and add tests for the main failure paths.
There are also a couple of startup details I would revisit. The application runs database migrations and seeds a client at startup, inside a catch block that ignores failures. I would want deployment failures to be visible, and schema changes to be a deliberate operational step.
The short link itself is not a new permission check. In the payment-reminder use case, the destination still has to enforce its own payment-link rules. Shortening a URL does not remove the sensitivity of any token it contains.
A small service with useful lessons
This project started with a very specific annoyance: long payment URLs in reminder emails.
That gave me a focused piece of work, with a clear input and output. It also brought up questions that appear in much larger applications: who can create records, what happens when requests race, which work should delay a response, and when a cache can become out of date.
That is why I like small projects like this. There is enough code to follow the whole request, and enough detail to learn something from the parts that need another pass.
Explore the code
Explore the ShortenUrl project on GitHub.
For the implementation discussed here, see UrlService, the API-key handler, and the database mapping.