Si tengo que reconocerlo, yo era de las personas más escépticas con Aspire cuando Microsoft lo anunció. Una nueva tooling que dentro del ecosistema de Microsoft funciona perfectamente, pero que viene a cubrir un escenario dentro del desarrollo de Kubernetes como es Docker Compose, en el que ya muchos productos lo tienen listo y funcionando.
Otra vez pensando en escenarios donde Microsoft ha optado por reinventar el estándar y se ha quedado en el cajón de cosas sin utilizar: ejemplos hay muchos como Silverlight, WCF, Xamarin.Forms… Pero en esta ocasión, con la evolución de la comunidad, Aspire no se ha quedado en algo que solamente se puede ejecutar en el tradicional Visual Studio y que además añade una capa de dependencia en tu proyecto.
Se ha construido algo que puedes lanzar a puro F5 (como dice mi equipo), sea el IDE que estés utilizando, y depurar tu solución de una forma muy fácil. En este artículo vamos a mostrar un ejemplo que he usado en la web de la NetCoreConf arrancando el proyecto desde cero y ver cómo podemos adoptarlo en proyectos ya existentes.
Antes de meternos en código, creo que es importante aclarar qué es Aspire y qué no es. Aspire no es un framework que te obliga a cambiar tu arquitectura. No es un runtime. No despliega nada en producción. Aspire es tooling de desarrollo: una forma de orquestar todos los servicios que necesita tu aplicación (bases de datos, APIs, frontends, caches…) con un solo comando y sin tocar Docker Compose.
Lo que realmente me convenció fue descubrir que:
No añade dependencias a tu código de producción: el AppHost es un proyecto aparte que solo se usa en desarrollo. El ServiceDefaults sí se despliega, pero es código tuyo que centraliza configuración de observabilidad — no es una dependencia de Aspire en runtime.
Funciona en cualquier IDE: VS Code, Rider, terminal… no necesitas Visual Studio.
El dashboard: trazas distribuidas, logs y métricas sin configurar nada.
Service discovery automático: tus servicios se encuentran entre sí sin hardcodear URLs.
La estructura del proyecto: Web de la NetCoreConf#
Para la web de la NetCoreConf 2026 decidí usar Aspire desde el principio. El proyecto tiene una arquitectura bastante típica: una API en .NET con PostgreSQL y un frontend en React con Vite.
Lo interesante aquí es que solo dos proyectos saben que Aspire existe: el AppHost (que no se despliega) y el ServiceDefaults (que es código de infraestructura que controlas tú). El resto de la solución — API, dominio, infraestructura, frontend — no tiene ninguna referencia a Aspire. Esto es clave: puedes adoptar Aspire sin modificar tu código existente.
Son 45 líneas de YAML, necesitas mantener un Dockerfile por servicio, y no tienes ni trazas, ni métricas, ni logs centralizados. Además, no puedes poner breakpoints en la API porque corre dentro de un contenedor.
Ahora compáralo con el AppHost de Aspire:
var builder = DistributedApplication.CreateBuilder(args);
var postgres = builder.AddPostgres("postgres")
.WithPgAdmin();
var db = postgres.AddDatabase("netcoreconf-db");
var api = builder.AddProject<Projects.NetCoreConf_Api>("api")
.WithReference(db)
.WaitFor(db);
builder.AddNpmApp("web", "../../src/NetCoreConf.Web", "dev")
.WithReference(api)
.WaitFor(api)
.WithHttpEndpoint(env: "VITE_PORT", port: 5173)
.WithExternalHttpEndpoints();
builder.Build().Run();
18 líneas de C# con IntelliSense, tipado fuerte, y depuración nativa de la API. Las connection strings se inyectan automáticamente, el health check de PostgreSQL viene integrado, y el dashboard te da observabilidad completa sin configurar nada.
Esta es la pregunta que me hice yo y que seguramente te estés haciendo tú. Si ya tienes un docker-compose.yml que funciona, ¿qué ganas con Aspire? La respuesta corta: experiencia de desarrollo y observabilidad.
Con Docker Compose tu API corre dentro de un contenedor. Para depurar necesitas configurar remote debugging, exponer puertos de debug, montar volúmenes para hot-reload… y aun así la experiencia es frágil. Con Aspire, tu API es un proyecto .NET normal que se ejecuta en tu máquina. Pones un breakpoint, le das a F5 y funciona. En cualquier IDE. Sin configuración extra.
En la NetCoreConf, cuando un endpoint devolvía datos incorrectos, ponía un breakpoint en el handler, lanzaba el AppHost y en segundos estaba inspeccionando el estado del DbContext con PostgreSQL real corriendo en un contenedor. Lo mejor de los dos mundos.
Docker Compose levanta contenedores. Punto. Si quieres logs centralizados necesitas un stack ELK o Loki. Si quieres trazas distribuidas, Jaeger o Zipkin. Si quieres métricas, Prometheus y Grafana. Cada uno con su configuración, sus volúmenes, sus puertos.
Aspire te da todo eso gratis en el dashboard:
Logs estructurados de todos los servicios en un solo panel, con filtros por nivel y búsqueda de texto.
Trazas distribuidas que siguen una petición HTTP desde el frontend, pasando por la API, hasta la query SQL a PostgreSQL. Puedes ver exactamente qué tramo tarda más y dónde está el cuello de botella.
Métricas en tiempo real: requests por segundo, tiempos de respuesta (p50, p95, p99), uso de memoria, conexiones al pool de base de datos.
Health checks con estado visual de cada recurso: sabes de un vistazo si PostgreSQL está healthy o si la API está respondiendo.
En la NetCoreConf detectamos que ciertas queries a la tabla de sesiones tardaban más de lo esperado simplemente mirando las trazas en el dashboard. Sin Aspire, ese problema habría llegado a producción porque nadie habría configurado un Jaeger en local “solo para desarrollar”.
Cuando cambias código en la API, Aspire aplica hot-reload automáticamente. Cuando cambias código en el frontend, Vite hace lo suyo con HMR. Todo coordinado, sin reiniciar contenedores, sin reconstruir imágenes. El ciclo de feedback es de segundos.
Con Docker Compose, un cambio en la API implica reconstruir la imagen (docker compose build api), reiniciar el servicio (docker compose up -d api) y esperar a que vuelva a arrancar. Eso son minutos que, multiplicados por cada cambio, se convierten en horas perdidas al día.
.WaitFor() parece simple, pero resuelve un dolor de cabeza real. En Docker Compose puedes usar depends_on con condition: service_healthy, pero necesitas definir el healthcheck manualmente para cada servicio. Aspire lo hace automáticamente: sabe cuándo PostgreSQL está aceptando conexiones y cuándo tu API está respondiendo a health checks.
Además, cuando paras el AppHost, todos los contenedores se limpian. No más docker compose down olvidados que dejan contenedores huérfanos consumiendo recursos.
Con Docker Compose tienes que mantener las connection strings sincronizadas entre el docker-compose.yml y el appsettings.json. Cambias el nombre de la base de datos en un sitio y te olvidas del otro — media hora depurando por qué la API no conecta.
Con Aspire, la connection string se genera y se inyecta automáticamente. Si en el AppHost escribes .AddDatabase("netcoreconf-db"), en la API accedes a ella con builder.Configuration.GetConnectionString("netcoreconf-db"). Un solo lugar, cero desincronización.
No todo es blanco o negro. Docker Compose sigue teniendo sentido si:
Necesitas replicar el entorno exacto de producción con imágenes específicas.
Tu equipo ya tiene un docker-compose.yml muy maduro y estable, y nadie tiene problemas de onboarding.
Pero si desarrollas en .NET y tu Docker Compose es básicamente “levantar infraestructura para que la API funcione”, Aspire hace lo mismo con mejor experiencia de desarrollo.
El corazón de Aspire es el proyecto AppHost. Aquí defines qué servicios componen tu aplicación y cómo se relacionan entre sí. Vamos a desglosar el Program.cs de la NetCoreConf:
var builder = DistributedApplication.CreateBuilder(args);
var postgres = builder.AddPostgres("postgres")
.WithPgAdmin();
var db = postgres.AddDatabase("netcoreconf-db");
AddPostgres levanta un contenedor de PostgreSQL automáticamente. .WithPgAdmin() añade otro contenedor con PgAdmin ya conectado a la instancia. AddDatabase crea la base de datos dentro del servidor. No necesitas recordar puertos, usuarios ni passwords — Aspire los genera y los inyecta.
var api = builder.AddProject<Projects.NetCoreConf_Api>("api")
.WithReference(db)
.WaitFor(db);
La API se registra como proyecto .NET. .WithReference(db) inyecta la connection string como ConnectionStrings:netcoreconf-db en la configuración de la API. .WaitFor(db) garantiza que PostgreSQL esté listo antes de arrancar la API.
AddNpmApp ejecuta npm run dev apuntando a la carpeta del proyecto Vite. .WithReference(api) inyecta las URLs de la API como variables de entorno. .WithHttpEndpoint asigna el puerto 5173 y lo expone como variable VITE_PORT.
El .WaitFor() es especialmente útil: garantiza que PostgreSQL esté listo antes de que la API intente conectar, y que la API esté disponible antes de que el frontend arranque. Adiós a los scripts de wait-for-it.sh.
Hay paquetes de hosting para prácticamente todo: Redis, RabbitMQ, MongoDB, Azure Storage, Kafka… Solo necesitas añadir el NuGet correspondiente y registrar el recurso en el AppHost.
Una de las funcionalidades que más me ha sorprendido es el service discovery automático. Cuando haces .WithReference(api) en el frontend, Aspire inyecta variables de entorno con las URLs del servicio referenciado. En el vite.config.ts del frontend lo aprovechamos directamente:
Las variables services__api__https__0 y services__api__http__0 las inyecta Aspire automáticamente. El proxy de Vite redirige todas las peticiones a /api hacia la API de .NET. Si ejecutas el frontend fuera de Aspire, el fallback a localhost:5000 sigue funcionando. Cero acoplamiento.
El proyecto ServiceDefaults es una convención de Aspire que centraliza la configuración de OpenTelemetry, health checks y resiliencia HTTP. Este es el código:
Trazas distribuidas: cada request HTTP se traza de extremo a extremo.
Métricas: de ASP.NET Core, HttpClient y el runtime de .NET.
Health checks: endpoints /health y /alive listos para Kubernetes.
Resiliencia: reintentos y circuit breaker en todos los HttpClient.
Service discovery: resolución de servicios por nombre.
En la API de la NetCoreConf, adoptarlo fue añadir una línea:
var builder = WebApplication.CreateBuilder(args);
builder.AddServiceDefaults(); // ← esto es todo
Y al final del pipeline:
app.MapDefaultEndpoints(); // ← /health y /alive
Es importante aclarar que ServiceDefaultssí se despliega con tu aplicación, pero no es una dependencia de Aspire: es código tuyo que vive en tu solución. Lo que configura (OpenTelemetry, health checks, resiliencia) son paquetes estándar de .NET que usarías igualmente. Aspire simplemente te da el scaffolding.
Cuando lanzas el AppHost con dotnet run, Aspire arranca automáticamente un dashboard en http://localhost:18888. Desde ahí puedes:
Ver todos los recursos: contenedores, proyectos y apps con su estado en tiempo real. De un vistazo sabes si PostgreSQL está healthy, si la API ha arrancado, o si el frontend tiene errores.
Consultar logs: de todos los servicios centralizados en un solo lugar, con búsqueda y filtros. Ya no necesitas tener 4 terminales abiertas.
Inspeccionar trazas distribuidas: seguir una petición desde el frontend, pasando por la API, hasta la query a PostgreSQL. Puedes ver exactamente cuánto tarda cada tramo.
Revisar métricas: tiempos de respuesta, uso de memoria, conexiones activas.
Todo esto sin instalar Jaeger, Grafana, Prometheus ni configurar ningún collector. En desarrollo local, es difícil pedir más.
Si quieres ver el dashboard en acción, arranca cualquier proyecto con Aspire y navega a la URL que aparece en la consola. Las capturas no le hacen justicia — es una de esas cosas que hay que probar.
Aspire también ofrece soporte para tests de integración con Aspire.Hosting.Testing. La idea es levantar el AppHost completo en un test y hacer peticiones HTTP reales contra tu API, con todos los servicios (base de datos incluida) arrancados como contenedores.
[Fact]publicasync Task GetSpeakers_ReturnsOk()
{
var appHost = await DistributedApplicationTestingBuilder
.CreateAsync<Projects.NetCoreConf_AppHost>();
awaitusing var app = await appHost.BuildAsync();
await app.StartAsync();
var client = app.CreateHttpClient("api");
var response = await client.GetAsync("/api/speakers");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
En la web de la NetCoreConf optamos por usar WebApplicationFactory con una base de datos in-memory para los tests de integración, ya que nos daba ciclos de feedback más rápidos. Pero el enfoque de Aspire es ideal cuando necesitas probar la interacción real entre servicios o validar que las connection strings y el service discovery funcionan correctamente en un entorno lo más parecido a producción.
Monorepo vs multirepo: dónde Aspire brilla y dónde flojea#
Aspire está pensado para un escenario claro: tienes una solución .NET con todos los proyectos que necesitas y el AppHost los orquesta. Es el caso de la NetCoreConf — API, frontend, base de datos, todo en el mismo repositorio. Un dotnet run y a trabajar.
Pero no todos los proyectos son así. En muchas empresas el panorama es diferente: tu API depende de un servicio de autenticación que mantiene otro equipo en otro repositorio, de un servicio de notificaciones en otro, y quizás de un BFF que vive en un tercero. ¿Qué pasa con Aspire en ese escenario?
Aspire permite referenciar proyectos que estén fuera de tu solución apuntando a su .csproj por ruta relativa. También puedes levantar contenedores de imágenes existentes con AddContainer() o usar AddDockerfile() para construir desde un Dockerfile externo. Técnicamente, puedes montar un AppHost que orqueste servicios de varios repositorios.
// Proyecto .NET de otro repo (ruta relativa)var auth = builder.AddProject<Projects.Auth_Api>("auth");
// Servicio externo como contenedor con imagen ya publicadavar notifications = builder.AddContainer("notifications", "myregistry.azurecr.io/notifications", "latest")
.WithHttpEndpoint(port: 8080, targetPort: 8080);
var api = builder.AddProject<Projects.MiProyecto_Api>("api")
.WithReference(auth)
.WithReference(notifications)
.WaitFor(auth);
Dicho esto, hay que ser honesto: en cuanto sales del monorepo la experiencia deja de ser fluida. Los proyectos externos por ruta relativa requieren que tengas los repos clonados con una estructura de carpetas específica. El hot-reload solo funciona en proyectos de tu solución. Y si el servicio externo es un contenedor, pierdes la depuración nativa — vuelves al remote debugging o directamente a mirar logs.
Antes de complicar tu AppHost con referencias a cinco repositorios, hazte la pregunta importante: ¿realmente necesitas depurar ese servicio externo?
En la mayoría de los casos, la respuesta es no. Si tu API consume un servicio de autenticación, lo que necesitas es que ese servicio esté corriendo y responda. No necesitas poner breakpoints en su código. Para eso, una imagen consolidada del servicio es más que suficiente:
// No necesitas el código fuente, solo que esté corriendovar auth = builder.AddContainer("auth", "myregistry.azurecr.io/auth-service", "2.1.0")
.WithHttpEndpoint(port: 5100, targetPort: 8080);
Y si descubres que sí necesitas depurar ese servicio constantemente, quizás lo que te está diciendo tu arquitectura es que ese código debería estar en tu repositorio. No como un copy-paste, sino como una dependencia real: un submodule, un package, o directamente mover el proyecto al monorepo.
Monorepo o solución única: Aspire brilla. Es su escenario ideal.
Multirepo con dependencias estables: usa imágenes consolidadas para los servicios externos. Obtienes la orquestación y el dashboard sin complicarte.
Multirepo donde necesitas depurar todo: Aspire puede hacerlo, pero la experiencia no es la misma. Valora si el esfuerzo compensa o si tu estructura de repositorios necesita repensarse.
Al final, Aspire es una herramienta que te obliga a pensar en cómo organizas tu código. Y eso, independientemente de la herramienta, siempre es un ejercicio valioso.
Añade una referencia a tu proyecto API y define los recursos que necesitas. Si tu proyecto usa PostgreSQL, Redis o cualquier otro servicio, simplemente regístralo:
var builder = DistributedApplication.CreateBuilder(args);
var redis = builder.AddRedis("cache");
var api = builder.AddProject<Projects.MiProyecto_Api>("api")
.WithReference(redis)
.WaitFor(redis);
builder.Build().Run();
Es importante tener claras las limitaciones para no crear falsas expectativas:
No despliega a producción: Aspire genera manifiestos, pero el despliegue es cosa tuya (o de azd). En la web de la NetCoreConf desplegamos a Azure Container Apps manualmente.
No reemplaza Kubernetes: Aspire es para desarrollo local. En producción sigues necesitando tu orquestador.
No gestiona secretos en producción: las connection strings que inyecta son para desarrollo. En producción usas Azure Key Vault, variables de entorno del cluster, etc.
Necesita Docker: los recursos como PostgreSQL o Redis se levantan como contenedores. Necesitas Docker Desktop o Podman.
Si me hubieran dicho hace dos años que iba a recomendar Aspire sin reservas, no me lo habría creído. Pero la realidad es que ha resuelto un problema que llevaba años arrastrando: el “funciona en mi máquina” multiplicado por cada persona nueva que entra en el equipo.
En la web de la NetCoreConf, cualquier persona clona el repo, ejecuta dotnet run --project src/NetCoreConf.AppHost y tiene PostgreSQL, PgAdmin, la API y el frontend funcionando. Sin leer documentación, sin instalar nada más allá de .NET y Docker.
Lo que más valoro es que Aspire no te pide nada a cambio: no acopla tu código, no te ata a un vendor, y si mañana decides quitarlo solo borras dos proyectos. Esa es la diferencia con otras apuestas de Microsoft que acabaron en el cajón — esta vez han construido algo que suma sin restar.