Skip to content

Repository files navigation

LinkShortener

Acortador de links en ASP.NET Core (arquitectura onion: Domain / Application / Infrastructure / Web).

Funcionalidades

  • Crear un link corto — funciona anónimo o logueado; si hay un usuario logueado, el link queda asociado a su cuenta (ShortLinksController.Create).
  • Redirección con conteo de clicsGET /{code} resuelve el código por Redis primero y SQL Server como fallback, y registra de forma asíncrona dispositivo/navegador (UAParser), país (GeoIP2) y referrer (RedirectController.Go).
  • Detalle de un link — si el link tiene dueño, solo ese usuario puede verlo (ShortLinksController.Details).
  • Dashboard de analíticas por link — clics en el tiempo y breakdown por dispositivo/navegador/país, con el mismo control de acceso que el detalle (GET /Analytics/{code}, AnalyticsController.Dashboard).
  • "Mis links" — listado de los links del usuario logueado (ShortLinksController.Mine, requiere estar autenticado).
  • Borrar un link — solo el dueño puede hacerlo (ShortLinksController.Delete).
  • Cuentas de usuario — registro/login/logout vía ASP.NET Core Identity, con mensajes de error en español, bloqueo de cuenta tras intentos fallidos y rate limiting (5 req/min) en Login/Register.

Los usuarios anónimos pueden crear y ver links sin dueño y usar la redirección, pero no acceden a "Mis links" ni al dashboard de links ajenos.

Arquitectura del proyecto

Proyecto Responsabilidad
LinkShortener.Domain Entidades (ShortLink, ClickEvent) y servicios de dominio puros (Base62Encoder, IpAnonymizer); sin dependencias externas.
LinkShortener.Application Casos de uso: interfaces (repositorios, cache, cola, geo, UA-parser), DTOs, ShortLinkService/AnalyticsService. Depende solo de Domain.
LinkShortener.Infrastructure Implementaciones: EF Core + migraciones (SQL Server), cache Redis, GeoIP2, UAParser, cola de clics en memoria + BackgroundService, ASP.NET Identity.
LinkShortener.Web Presentación MVC: Controllers/Views/ViewModels, Program.cs, Serilog, rate limiting.
LinkShortener.UnitTests xUnit + Moq sobre Domain y Application.
LinkShortener.IntegrationTests xUnit contra infraestructura real (ej. ShortLinkRepositoryConcurrencyTests).

Stack técnico: EF Core 9 + SQL Server, ASP.NET Core Identity, Redis (StackExchange.Redis, cache-aside), MaxMind GeoIP2, UAParser, Serilog (consola + archivo), rate limiting nativo de ASP.NET Core, xUnit + Moq.

Cómo levantar el proyecto

Prerrequisitos:

  • .NET 9 SDK
  • SQL Server LocalDB (viene con Visual Studio) o cualquier instancia de SQL Server accesible
  • Opcional: Redis (cache-aside de la Etapa 6; sin él, la app funciona igual pero sin caché — ver "Cache-aside con Redis" más abajo)
  • Opcional: base GeoLite2-Country.mmdb de MaxMind (geolocalización por IP; sin ella, Country queda null — ver "Geolocalización con degradación elegante" más abajo)

Pasos:

dotnet restore
dotnet ef database update --project LinkShortener.Infrastructure --startup-project LinkShortener.Web
dotnet run --project LinkShortener.Web

La primera vez que corre en Development, si la tabla ShortLinks está vacía, se siembran automáticamente unos enlaces de ejemplo con historial de clics (SeedData.EnsureSeededAsync, ver Program.cs) para que el dashboard de Analíticas no arranque vacío.

Tests:

dotnet test

LinkShortener.UnitTests no necesita nada externo. LinkShortener.IntegrationTests sí requiere una instancia de LocalDB accesible (usa una base separada, LinkShortenerTestDb, que crea y limpia sola).

Con Docker:

docker build -t linkshortener .
docker run -p 8080:8080 linkshortener

El contenedor solo empaqueta la app — SQL Server, Redis y el .mmdb de GeoLite2 siguen siendo dependencias externas que hay que apuntar por configuración/variables de entorno.

Secretos

Nada de lo que hay hoy en appsettings.Development.json es un secreto real (LocalDB con Trusted_Connection, Redis en localhost). Para desarrollo local con credenciales reales (por ejemplo, un SQL Server o Redis remoto), usar dotnet user-secrets en vez de tocar appsettings.json:

dotnet user-secrets set "ConnectionStrings:DefaultConnection" "..." --project LinkShortener.Web

En un despliegue real (Azure App Service, etc.) los secretos van como Application Settings/variables de entorno del servicio, nunca committeados en appsettings.json.

Decisiones de diseño

Redirección con 302, no 301. El objetivo del proyecto es medir clics, no servir la redirección más rápido posible. Con RedirectPermanent (301) el navegador cachea la redirección desde el primer clic y en visitas siguientes ni siquiera vuelve a golpear el servidor, perdiendo el registro de esos clics. Con 302 cada clic pasa por RedirectController y queda contado.

Registro del clic vía cola en memoria + BackgroundService (reemplaza el Task.Run de la Etapa 3). RedirectController ya no dispara trabajo asíncrono en el request: IClickEventQueue.Enqueue es una escritura en memoria sobre un System.Threading.Channels.Channel (sin I/O), así que la redirección responde sin esperar nada de base de datos. ClickEventBatchProcessor (BackgroundService, Infrastructure) drena la cola en lotes — hasta 100 clics o 2 segundos, lo que ocurra primero — y los persiste con un solo SaveChanges para todos los ClickEvent del lote, más un ExecuteUpdateAsync atómico por cada ShortLinkId distinto (reutilizando el mismo patrón de incremento atómico que se corrigió en la Etapa 3 para no reintroducir el lost update). Al apagar la app, StopAsync drena lo que haya quedado en la cola antes de terminar (best-effort, no bloqueante) para no perder clics en un shutdown normal.

Cache-aside con Redis para /{codigo}. IShortLinkCache guarda Code → (Id, OriginalUrl) — no solo la URL, porque el Id numérico hace falta para encolar el ClickEvent sin volver a golpear SQL Server. Es una interfaz chica y específica para este único hot path, no un decorator genérico sobre IShortLinkRepository: las páginas Details/Mine/Analytics siguen leyendo siempre de SQL directo, porque ahí sí importa que ClickCount esté fresco (con caché quedaría atrasado hasta que expire el TTL). Al borrar un enlace (ShortLinkService.DeleteAsync) se invalida la entrada correspondiente en Redis. Igual que con GeoLite2, la implementación (RedisShortLinkCache, Microsoft.Extensions.Caching.StackExchangeRedis) atrapa cualquier error de conexión: si Redis no responde, cada operación cae a SQL Server como si no hubiera caché — la app funciona igual, solo más lento. En esta máquina no hay Docker/WSL/redis-cli para levantar un Redis real, así que el cache HIT en sí no se pudo verificar de punta a punta; el fallback (Redis caído → SQL Server) sí. Para probarlo con Redis real: docker run -p 6379:6379 redis (o cualquier instancia accesible) y apuntar ConnectionStrings:Redis ahí.

Timeout duro propio sobre las llamadas a Redis (hallazgo real durante la verificación). La primera versión solo confiaba en los timeouts de StackExchange.Redis (ConnectTimeout/SyncTimeout) — con Redis caído, cada redirección tardaba 19 segundos (la librería reintenta antes de tirar la excepción que activa el fallback), justo lo contrario de lo que busca esta etapa. Bajar esos timeouts a 500ms lo mejoró a ~2s, pero seguía sin ser aceptable. La solución fue envolver cada operación de RedisShortLinkCache en Task.WhenAny(operacion, Task.Delay(150ms)): un techo duro controlado por la app, no por la librería — si Redis no contesta en 150ms, se sigue por SQL Server sin esperar más. Con Redis caído, el peor caso de una redirección (cache-miss: GetAsync + SetAsync, dos llamadas) quedó en ~300–700ms — lejos de los milisegundos ideales con Redis sano, pero un piso acotado y predecible en vez de una cola de reintentos sin techo.

Flujo final de /{codigo}:

GET /{codigo}
  → IShortLinkCache.GetAsync(codigo)
      HIT  → usa (Id, OriginalUrl) de Redis
      MISS → IShortLinkService.GetByCodeAsync(codigo) [SQL Server] → puebla Redis
  → UAParser + GeoLite2 (en memoria/archivo local, no bloquean)
  → IClickEventQueue.Enqueue(clickEvent)   [in-memory, no I/O]
  → Redirect(originalUrl)                   [la respuesta ya salió acá]

  (en background) ClickEventBatchProcessor:
    → junta hasta 100 clics o 2s
    → 1 SaveChanges para todo el lote de ClickEvents
    → 1 ExecuteUpdateAsync atómico por ShortLinkId distinto del lote

Geolocalización con degradación elegante. IGeoLocationService (implementado con MaxMind.GeoIP2) lee un archivo GeoLite2-Country.mmdb desde la ruta configurada en GeoIp:DatabasePath (appsettings.json, por defecto App_Data/GeoLite2-Country.mmdb). El archivo no se distribuye con el repo porque MaxMind exige una cuenta gratuita + license key para descargarlo. Si el archivo no está presente, la app arranca igual, loggea un warning una vez, y Country queda null en los clics — nada se rompe. Para habilitarlo:

  1. Crear una cuenta gratuita en maxmind.com/en/geolite2/signup.
  2. Generar una license key y descargar GeoLite2-Country.mmdb (formato "GeoIP2 Binary (.mmdb)").
  3. Colocar el archivo en LinkShortener.Web/App_Data/GeoLite2-Country.mmdb (o ajustar GeoIp:DatabasePath).

Geolocalización sobre la IP cruda, nunca sobre la anonimizada. RedirectController resuelve el país usando HttpContext.Connection.RemoteIpAddress (la IP real) antes de pasarla por IpAnonymizer.Anonymize. Solo el resultado (Country) se persiste — la IP sin anonimizar nunca toca la base.

Benchmark: índice compuesto (ShortLinkId, TimestampUtc) en ClickEvents

La consulta de "clics por día, últimos 30 días" (AnalyticsService.GetDashboardAsync) filtra por ShortLinkId y rango de TimestampUtc. Se sembraron 300.000 filas sintéticas en ClickEvents (100k para el enlace medido + 200k de "ruido" en otros enlaces, para que el filtro por ShortLinkId sea representativo) y se corrió la misma consulta con SET STATISTICS IO, TIME ON, sin y con el índice IX_ClickEvents_ShortLinkId_TimestampUtc:

Sin índice (table scan) Con índice (index seek)
Lecturas lógicas 8.782 374 (~23x menos)
Tiempo transcurrido 34–136 ms 19–21 ms
Operador (plan de ejecución) Clustered Index Scan Index Seek

El plan de ejecución confirma el Index Seek sobre IX_ClickEvents_ShortLinkId_TimestampUtc cuando el índice está presente (SET SHOWPLAN_TEXT ON). Sin el índice, SQL Server recorre toda la tabla para filtrar por ShortLinkId. La diferencia se nota más cuanto más crece ClickEvents — con pocos cientos de filas por enlace el impacto es marginal, pero a esta escala (decenas/cientos de miles de filas totales) ya es una mejora de un orden de magnitud en lecturas lógicas.

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages