Consumir API REST en .NET MAUI 10: HttpClient, Refit y MVVM

Tres formas de consumir APIs REST en .NET MAUI 10: desde HttpClient básico hasta Refit declarativo, con MVVM, autenticación Bearer, verificación de conectividad y resiliencia con Polly.

Si desarrollas apps móviles con .NET MAUI, tarde o temprano vas a necesitar conectarte a un backend. Es prácticamente inevitable. Ya sea para traer un listado de productos, autenticar usuarios o sincronizar datos, consumir APIs REST es una de esas habilidades que necesitas dominar sí o sí.

En esta guía te voy a mostrar tres formas de hacerlo, de menor a mayor sofisticación: desde un HttpClient básico, pasando por IHttpClientFactory, hasta llegar a Refit (que, honestamente, es mi favorito). Todo esto integrado con MVVM usando CommunityToolkit, inyección de dependencias y políticas de resiliencia con Polly.

Vamos a ello.

Requisitos previos

Antes de arrancar, asegúrate de tener:

  • .NET 10 SDK instalado
  • Visual Studio 2026 o VS Code con la extensión C# Dev Kit
  • Un proyecto .NET MAUI creado con la plantilla predeterminada
  • Conocimientos básicos de C# y XAML

Como backend de ejemplo usaremos JSONPlaceholder (https://jsonplaceholder.typicode.com), una API pública y gratuita que viene genial para practicar sin complicaciones.

Crear el modelo de datos

Lo primero es definir el modelo que representará los datos que nos devuelve la API. Nada del otro mundo: una clase Post que mapea la respuesta JSON.

namespace MiApp.Models;

public class Post
{
    public int Id { get; set; }
    public int UserId { get; set; }
    public string Title { get; set; } = string.Empty;
    public string Body { get; set; } = string.Empty;
}

Enfoque 1: HttpClient básico con inyección de dependencias

El enfoque más directo. Registras un HttpClient como singleton en el contenedor de DI de MAUI y listo. Es sencillo, funciona bien para apps pequeñas y te permite empezar rápido.

Eso sí, hay que tener cuidado con un par de cosas (ya te cuento más adelante).

Crear el servicio de API

Definimos una interfaz y su implementación para mantener el código desacoplado y testeable:

namespace MiApp.Services;

public interface IPostService
{
    Task<List<Post>> ObtenerPostsAsync();
    Task<Post?> ObtenerPostPorIdAsync(int id);
    Task<Post> CrearPostAsync(Post post);
    Task<bool> ActualizarPostAsync(Post post);
    Task<bool> EliminarPostAsync(int id);
}
using System.Net.Http.Json;
using MiApp.Models;

namespace MiApp.Services;

public class PostService : IPostService
{
    private readonly HttpClient _httpClient;

    public PostService(HttpClient httpClient)
    {
        _httpClient = httpClient;
    }

    public async Task<List<Post>> ObtenerPostsAsync()
    {
        var posts = await _httpClient.GetFromJsonAsync<List<Post>>("posts");
        return posts ?? [];
    }

    public async Task<Post?> ObtenerPostPorIdAsync(int id)
    {
        return await _httpClient.GetFromJsonAsync<Post>($"posts/{id}");
    }

    public async Task<Post> CrearPostAsync(Post post)
    {
        var response = await _httpClient.PostAsJsonAsync("posts", post);
        response.EnsureSuccessStatusCode();
        var creado = await response.Content.ReadFromJsonAsync<Post>();
        return creado!;
    }

    public async Task<bool> ActualizarPostAsync(Post post)
    {
        var response = await _httpClient.PutAsJsonAsync($"posts/{post.Id}", post);
        return response.IsSuccessStatusCode;
    }

    public async Task<bool> EliminarPostAsync(int id)
    {
        var response = await _httpClient.DeleteAsync($"posts/{id}");
        return response.IsSuccessStatusCode;
    }
}

Fíjate que usamos los métodos de extensión GetFromJsonAsync, PostAsJsonAsync y PutAsJsonAsync del namespace System.Net.Http.Json. Estos te ahorran toda la serialización y deserialización manual.

Registrar el servicio en MauiProgram.cs

Ahora toca registrar el HttpClient y el servicio en el contenedor de DI:

using MiApp.Services;

namespace MiApp;

public static class MauiProgram
{
    public static MauiApp CreateMauiApp()
    {
        var builder = MauiApp.CreateBuilder();
        builder
            .UseMauiApp<App>()
            .ConfigureFonts(fonts =>
            {
                fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
            });

        // Registrar HttpClient como singleton
        builder.Services.AddSingleton(sp => new HttpClient
        {
            BaseAddress = new Uri("https://jsonplaceholder.typicode.com/")
        });

        builder.Services.AddSingleton<IPostService, PostService>();
        builder.Services.AddSingleton<MainViewModel>();
        builder.Services.AddSingleton<MainPage>();

        return builder.Build();
    }
}

Un detalle importante: HttpClient está diseñado para instanciarse una sola vez y reutilizarse durante toda la vida de la app. Si creas una instancia nueva por cada petición, puedes acabar agotando los sockets del sistema operativo. En móviles, donde los recursos son más limitados, esto se convierte en un problema real bastante rápido.

Enfoque 2: IHttpClientFactory para gestión avanzada

Aquí es donde las cosas se ponen interesantes. IHttpClientFactory es el enfoque que Microsoft recomienda oficialmente, y con razón: gestiona automáticamente el ciclo de vida de los handlers HTTP, rota las conexiones para evitar problemas de DNS obsoletos y te da un punto centralizado de configuración.

Instalar el paquete necesario

dotnet add package Microsoft.Extensions.Http

Registrar un cliente tipado

El patrón de cliente tipado es el más recomendado porque asocia la configuración HTTP directamente con tu servicio:

// En MauiProgram.cs
builder.Services.AddHttpClient<IPostService, PostService>(client =>
{
    client.BaseAddress = new Uri("https://jsonplaceholder.typicode.com/");
    client.DefaultRequestHeaders.Add("Accept", "application/json");
    client.Timeout = TimeSpan.FromSeconds(30);
});

Con este registro, el contenedor de DI inyectará automáticamente un HttpClient configurado en el constructor de PostService. La implementación del servicio no cambia nada, que es lo bonito del asunto.

¿Por qué IHttpClientFactory y no un HttpClient singleton?

  • Rotación automática de handlers: los HttpMessageHandler internos se reciclan periódicamente (cada 2 minutos por defecto), así que si el DNS del servidor cambia, tu app se entera
  • Pool de conexiones: reutiliza conexiones existentes en lugar de abrir nuevas para cada petición
  • Middleware extensible: puedes encadenar DelegatingHandler personalizados para logging, autenticación o reintentos
  • Configuración centralizada: toda la configuración del cliente HTTP queda en un solo lugar, que es más fácil de mantener

Enfoque 3: Refit para APIs declarativas y limpias

Y llegamos a mi enfoque favorito. Refit convierte interfaces C# en clientes HTTP de forma automática. En vez de escribir manualmente URLs, verbos HTTP y serialización JSON, defines una interfaz decorada con atributos y Refit genera toda la implementación por ti.

El resultado es un código muchísimo más limpio.

Instalar los paquetes de Refit

dotnet add package Refit
dotnet add package Refit.HttpClientFactory

Definir la interfaz de la API

using Refit;
using MiApp.Models;

namespace MiApp.Services;

public interface IPostApi
{
    [Get("/posts")]
    Task<List<Post>> ObtenerPostsAsync();

    [Get("/posts/{id}")]
    Task<Post> ObtenerPostPorIdAsync(int id);

    [Post("/posts")]
    Task<Post> CrearPostAsync([Body] Post post);

    [Put("/posts/{id}")]
    Task<Post> ActualizarPostAsync(int id, [Body] Post post);

    [Delete("/posts/{id}")]
    Task EliminarPostAsync(int id);
}

¿Ves la diferencia? Solo una interfaz con atributos. Sin implementación manual. Sin GetFromJsonAsync, sin PostAsJsonAsync, sin nada de eso.

Registrar Refit con IHttpClientFactory

// En MauiProgram.cs
builder.Services
    .AddRefitClient<IPostApi>()
    .ConfigureHttpClient(client =>
    {
        client.BaseAddress = new Uri("https://jsonplaceholder.typicode.com");
    });

Refit genera automáticamente una implementación de IPostApi que usa HttpClient internamente. Puedes inyectar IPostApi directamente en tus ViewModels sin necesidad de crear una clase de servicio adicional.

Integración con MVVM y CommunityToolkit

Bueno, ya tenemos los servicios de API listos. Ahora toca conectar todo con la capa de presentación usando el patrón MVVM y CommunityToolkit.Mvvm, que simplifica bastante el boilerplate.

Instalar CommunityToolkit.Mvvm

dotnet add package CommunityToolkit.Mvvm

Crear el ViewModel

using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using MiApp.Models;
using MiApp.Services;
using System.Collections.ObjectModel;

namespace MiApp.ViewModels;

public partial class MainViewModel : ObservableObject
{
    private readonly IPostApi _postApi;

    public MainViewModel(IPostApi postApi)
    {
        _postApi = postApi;
    }

    [ObservableProperty]
    private ObservableCollection<Post> _posts = [];

    [ObservableProperty]
    private bool _estaCargando;

    [ObservableProperty]
    private string _mensajeError = string.Empty;

    [RelayCommand]
    private async Task CargarPostsAsync()
    {
        if (EstaCargando) return;

        try
        {
            EstaCargando = true;
            MensajeError = string.Empty;

            var resultado = await _postApi.ObtenerPostsAsync();
            Posts = new ObservableCollection<Post>(resultado);
        }
        catch (HttpRequestException ex)
        {
            MensajeError = $"Error de conexión: {ex.Message}";
        }
        catch (Exception ex)
        {
            MensajeError = $"Error inesperado: {ex.Message}";
        }
        finally
        {
            EstaCargando = false;
        }
    }

    [RelayCommand]
    private async Task CrearPostAsync()
    {
        try
        {
            var nuevoPost = new Post
            {
                UserId = 1,
                Title = "Nuevo post desde MAUI",
                Body = "Este post fue creado desde una app .NET MAUI 10"
            };

            var creado = await _postApi.CrearPostAsync(nuevoPost);
            Posts.Insert(0, creado);
        }
        catch (Exception ex)
        {
            MensajeError = $"Error al crear: {ex.Message}";
        }
    }
}

Gracias a los source generators de CommunityToolkit, los atributos [ObservableProperty] y [RelayCommand] generan automáticamente las propiedades notificables y los comandos. Esto te ahorra escribir un montón de código repetitivo.

Crear la vista XAML

<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             xmlns:vm="clr-namespace:MiApp.ViewModels"
             x:Class="MiApp.MainPage"
             x:DataType="vm:MainViewModel"
             Title="Posts">

    <Grid RowDefinitions="Auto,Auto,*" Padding="16">

        <HorizontalStackLayout Spacing="8">
            <Button Text="Cargar Posts"
                    Command="{Binding CargarPostsCommand}" />
            <Button Text="Crear Post"
                    Command="{Binding CrearPostCommand}" />
        </HorizontalStackLayout>

        <Label Grid.Row="1"
               Text="{Binding MensajeError}"
               TextColor="Red"
               IsVisible="{Binding MensajeError.Length}"
               Margin="0,8" />

        <ActivityIndicator Grid.Row="2"
                           IsRunning="{Binding EstaCargando}"
                           IsVisible="{Binding EstaCargando}"
                           VerticalOptions="Center" />

        <CollectionView Grid.Row="2"
                        ItemsSource="{Binding Posts}"
                        IsVisible="{Binding EstaCargando, Converter={StaticResource InvertedBoolConverter}}">
            <CollectionView.ItemTemplate>
                <DataTemplate x:DataType="models:Post">
                    <Frame Margin="0,4" Padding="12">
                        <VerticalStackLayout Spacing="4">
                            <Label Text="{Binding Title}"
                                   FontAttributes="Bold"
                                   FontSize="16" />
                            <Label Text="{Binding Body}"
                                   MaxLines="2"
                                   TextColor="Gray" />
                        </VerticalStackLayout>
                    </Frame>
                </DataTemplate>
            </CollectionView.ItemTemplate>
        </CollectionView>

    </Grid>
</ContentPage>

Agregar autenticación con tokens Bearer

En la vida real, la mayoría de APIs están protegidas con autenticación. La forma más limpia de adjuntar un token Bearer a cada petición es crear un DelegatingHandler personalizado, que funciona como un middleware en el pipeline HTTP.

Crear el handler de autenticación

namespace MiApp.Services;

public class AuthHandler : DelegatingHandler
{
    private readonly ISecureStorageService _secureStorage;

    public AuthHandler(ISecureStorageService secureStorage)
    {
        _secureStorage = secureStorage;
    }

    protected override async Task<HttpResponseMessage> SendAsync(
        HttpRequestMessage request,
        CancellationToken cancellationToken)
    {
        var token = await _secureStorage.ObtenerTokenAsync();

        if (!string.IsNullOrEmpty(token))
        {
            request.Headers.Authorization =
                new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
        }

        return await base.SendAsync(request, cancellationToken);
    }
}

Registrar el handler en el pipeline

// En MauiProgram.cs
builder.Services.AddTransient<AuthHandler>();

builder.Services
    .AddRefitClient<IPostApi>()
    .ConfigureHttpClient(client =>
    {
        client.BaseAddress = new Uri("https://mi-api-protegida.com");
    })
    .AddHttpMessageHandler<AuthHandler>();

Así, cada petición incluye automáticamente el token sin que tengas que añadirlo a mano en cada llamada. Es mucho más elegante que decorar cada método de la interfaz Refit con un [Header].

Verificar conectividad antes de las peticiones

Algo que muchos desarrolladores olvidan (yo incluido, al principio): verificar si hay conexión a internet antes de lanzar peticiones HTTP. .NET MAUI incluye la clase Connectivity para exactamente esto:

using Microsoft.Maui.Networking;

public partial class MainViewModel : ObservableObject
{
    [RelayCommand]
    private async Task CargarPostsConVerificacionAsync()
    {
        if (Connectivity.Current.NetworkAccess != NetworkAccess.Internet)
        {
            MensajeError = "Sin conexión a internet. Verifica tu red e intenta de nuevo.";
            return;
        }

        await CargarPostsAsync();
    }
}

También puedes suscribirte al evento ConnectivityChanged para reaccionar automáticamente cuando cambie el estado de la red:

Connectivity.Current.ConnectivityChanged += (sender, args) =>
{
    if (args.NetworkAccess == NetworkAccess.Internet)
    {
        // Reintentar cargar datos automáticamente
    }
};

Un consejo: no dependas únicamente de esta comprobación. Puede haber conexión Wi-Fi pero sin acceso real a internet (como en redes cautivas de hoteles). Siempre maneja las excepciones de red igualmente.

Resiliencia con Polly: reintentos y circuit breaker

Las redes móviles son inestables por naturaleza. Una conexión 4G que va y viene, un túnel, un ascensor... los fallos transitorios son el pan de cada día. Polly te permite definir políticas de resiliencia para manejar estos escenarios de forma elegante.

Instalar el paquete

dotnet add package Microsoft.Extensions.Http.Resilience

Configurar resiliencia en el pipeline HTTP

using Microsoft.Extensions.Http.Resilience;

// En MauiProgram.cs
builder.Services
    .AddRefitClient<IPostApi>()
    .ConfigureHttpClient(client =>
    {
        client.BaseAddress = new Uri("https://jsonplaceholder.typicode.com");
    })
    .AddStandardResilienceHandler(options =>
    {
        options.Retry.MaxRetryAttempts = 3;
        options.Retry.Delay = TimeSpan.FromSeconds(1);
        options.Retry.BackoffType = DelayBackoffType.Exponential;
        options.CircuitBreaker.SamplingDuration = TimeSpan.FromSeconds(30);
        options.AttemptTimeout.Timeout = TimeSpan.FromSeconds(10);
    });

¿Qué hace todo esto? Cuando una petición falla por un error transitorio (timeout, error 5xx), Polly reintenta automáticamente con delays crecientes: 1 segundo, 2 segundos, 4 segundos. Si el servidor sigue sin responder, el circuit breaker se abre temporalmente y deja de enviar peticiones durante un rato, evitando sobrecargar un servidor que ya está en problemas.

Manejo centralizado de errores HTTP

Manejar excepciones en cada ViewModel por separado se vuelve repetitivo rápido. Una alternativa más limpia es crear un wrapper que centralice toda la gestión de errores:

namespace MiApp.Services;

public class ApiResult<T>
{
    public bool EsExitoso { get; init; }
    public T? Datos { get; init; }
    public string MensajeError { get; init; } = string.Empty;

    public static ApiResult<T> Exito(T datos) =>
        new() { EsExitoso = true, Datos = datos };

    public static ApiResult<T> Error(string mensaje) =>
        new() { EsExitoso = false, MensajeError = mensaje };
}

public class ApiWrapper
{
    public async Task<ApiResult<T>> EjecutarAsync<T>(Func<Task<T>> accion)
    {
        try
        {
            var resultado = await accion();
            return ApiResult<T>.Exito(resultado);
        }
        catch (HttpRequestException ex) when (ex.StatusCode == System.Net.HttpStatusCode.Unauthorized)
        {
            return ApiResult<T>.Error("Sesión expirada. Inicia sesión nuevamente.");
        }
        catch (HttpRequestException ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound)
        {
            return ApiResult<T>.Error("El recurso solicitado no existe.");
        }
        catch (HttpRequestException ex)
        {
            return ApiResult<T>.Error($"Error del servidor: {ex.StatusCode}");
        }
        catch (TaskCanceledException)
        {
            return ApiResult<T>.Error("La petición tardó demasiado. Intenta de nuevo.");
        }
        catch (Exception ex)
        {
            return ApiResult<T>.Error($"Error inesperado: {ex.Message}");
        }
    }
}

Y en el ViewModel queda mucho más limpio:

[RelayCommand]
private async Task CargarPostsAsync()
{
    EstaCargando = true;
    var resultado = await _apiWrapper.EjecutarAsync(() => _postApi.ObtenerPostsAsync());

    if (resultado.EsExitoso)
    {
        Posts = new ObservableCollection<Post>(resultado.Datos!);
    }
    else
    {
        MensajeError = resultado.MensajeError;
    }

    EstaCargando = false;
}

Me gusta mucho este patrón porque los ViewModels se quedan con la lógica de presentación y nada más.

Configurar handlers nativos de plataforma

En dispositivos móviles, conviene usar los handlers HTTP nativos del sistema operativo. Obtienes mejor rendimiento y compatibilidad con los certificados SSL del dispositivo. .NET MAUI ya lo hace por defecto, pero puedes configurarlo de forma explícita si necesitas más control:

builder.Services
    .AddRefitClient<IPostApi>()
    .ConfigureHttpClient(client =>
    {
        client.BaseAddress = new Uri("https://jsonplaceholder.typicode.com");
    })
    .ConfigurePrimaryHttpMessageHandler(() =>
    {
#if ANDROID
        return new Xamarin.Android.Net.AndroidMessageHandler
        {
            AutomaticDecompression = System.Net.DecompressionMethods.All
        };
#elif IOS
        return new NSUrlSessionHandler
        {
            AllowAutoRedirect = true
        };
#else
        return new HttpClientHandler
        {
            AutomaticDecompression = System.Net.DecompressionMethods.All
        };
#endif
    });

Los handlers nativos aprovechan las pilas de red del SO, lo que significa mejor rendimiento TLS, soporte nativo para HTTP/2 y gestión más eficiente de certificados.

¿Qué enfoque elegir?

Depende de tu proyecto, pero aquí va un resumen rápido:

  • HttpClient directo: perfecto para prototipos rápidos o apps pequeñas con una sola API. Mínima configuración, máxima simplicidad.
  • IHttpClientFactory con clientes tipados: el camino a seguir en apps de producción. Gestión de conexiones, soporte para múltiples APIs y middleware personalizado.
  • Refit + IHttpClientFactory: la mejor opción para proyectos medianos y grandes. Código limpio, declarativo y fácil de mantener. Combinado con IHttpClientFactory tienes lo mejor de ambos mundos.

Personalmente, en mis proyectos siempre acabo usando Refit con IHttpClientFactory y Polly. La productividad que ganas es notable, y el código resultante es mucho más fácil de leer y mantener a largo plazo.

Preguntas frecuentes

¿Puedo usar IHttpClientFactory en .NET MAUI o solo en ASP.NET Core?

Sí, funciona perfectamente en .NET MAUI. Solo necesitas instalar el paquete Microsoft.Extensions.Http y registrar tus clientes en MauiProgram.cs. El contenedor de DI de MAUI es compatible con todas las extensiones de Microsoft.Extensions.

¿Cuál es la diferencia entre HttpClient singleton y IHttpClientFactory?

Un HttpClient singleton reutiliza la misma conexión, evitando el agotamiento de sockets pero ignorando posibles cambios de DNS. IHttpClientFactory resuelve ambos problemas reciclando automáticamente los handlers HTTP, rotando conexiones cada cierto tiempo mientras mantiene un pool eficiente.

¿Cómo pruebo unitariamente un ViewModel que consume una API con Refit?

Como usas interfaces (IPostApi), crear mocks es muy fácil con bibliotecas como Moq o NSubstitute. En tu test inyectas un mock que devuelve datos predefinidos en lugar de hacer llamadas HTTP reales. Los tests quedan rápidos, deterministas e independientes de la red.

¿Es necesario usar Polly si ya manejo excepciones en el ViewModel?

Son complementarios. Las excepciones te permiten mostrar mensajes al usuario cuando algo falla de verdad. Polly actúa antes, reintentando automáticamente ante fallos transitorios que suelen resolverse solos (timeouts breves, errores 503). Sin Polly, muchos de esos errores temporales llegarían innecesariamente al usuario.

¿Cómo consumo una API local durante el desarrollo con un emulador?

Los emuladores de Android no pueden acceder a localhost del equipo host directamente. Usa la dirección 10.0.2.2 en Android para apuntar al host. En iOS, puedes usar la IP de tu máquina en la red local. Si tu API usa HTTP en lugar de HTTPS, necesitarás habilitar el tráfico sin cifrar configurando la seguridad de transporte en cada plataforma.

Sobre el Autor Editorial Team

Our team of expert writers and editors.