Acortador de links en ASP.NET Core (arquitectura onion: Domain / Application / Infrastructure / Web).
- 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 clics —
GET /{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.
| 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.
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.mmdbde MaxMind (geolocalización por IP; sin ella,Countryquedanull— 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.WebLa 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 testLinkShortener.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 linkshortenerEl 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.
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.WebEn un despliegue real (Azure App Service, etc.) los secretos van como Application Settings/variables de entorno del servicio, nunca committeados en appsettings.json.
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:
- Crear una cuenta gratuita en maxmind.com/en/geolite2/signup.
- Generar una license key y descargar
GeoLite2-Country.mmdb(formato "GeoIP2 Binary (.mmdb)"). - Colocar el archivo en
LinkShortener.Web/App_Data/GeoLite2-Country.mmdb(o ajustarGeoIp: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.
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.