, ,

MAUI + Blazor: coreografiando la resiliencia en aplicaciones híbridas

Avatar de Jorge Fernández

La era de lo eléctrico y lo híbrido…..

Hoy en día es normal encontrarnos con coches híbridos en cualquier situación, al igual que eléctricos y de combustión 100%. Pero, obviamente, no voy a hablar de coches; simplemente quiero hacer un símil para darnos cuenta de que nuestra vida está compuesta por múltiples opciones para desplazarnos.

En la época de los dispositivos móviles —los que usamos en la playa, en el campo, desde nuestro sofá e incluso desde el baño— es habitual que queramos que todo funcione correctamente. Buscamos que nuestra conectividad sea del 100 %: ansiamos WiFi 6, 7, iMesh. Peleamos y luchamos para que todo esté conectado y podamos usar nuestras apps en tiempo real.

En muchas ocasiones —ya sea por descuido, mala praxis o por exigencias técnicas— olvidamos que nuestras aplicaciones no siempre operan en condiciones ideales. Como consultores, tenemos la responsabilidad de anticiparnos a estos escenarios y promover buenas prácticas. Sin embargo, es común que pasemos por alto algo fundamental: nuestra app puede enfrentar microcortes de conexión o incluso quedarse completamente offline.

Y cuando eso ocurre… ¿nos quedamos paralizados, como si no lo hubiéramos previsto? ¿Nos vemos reflejados en ese momento de desconcierto, como si la conectividad fuera un derecho divino?.

¿Cierto?

Atados de pies y manos. Así, quizás, sería la mejor forma de describirnos cuando nuestras aplicaciones fallan por falta de conectividad. En esta entrada te voy a contar cómo mitigar el riesgo de pérdida de datos ante microcortes de comunicación, e incluso cómo mantenerlos a salvo cuando la conexión es completamente nula.

Porque sí, en un mundo hiperconectado, donde damos por hecho que todo está siempre “online”, olvidamos que la resiliencia no es un lujo, sino una necesidad. Y hoy te voy a mostrar cómo construirla desde el código.

Sincronización

Al igual que cuando nos reencontramos con un amigo o antiguo compañero y queremos ponernos al día, compartimos cómo nos ha ido durante ese tiempo y escuchamos su historia. Así, ambos nos hacemos una idea clara del estado actual de cada uno.

En la era de las aplicaciones, esto no iba a ser diferente. Cuando trabajamos con productos que requieren comunicación entre dos partes —ya sea cliente y servidor, dispositivo y nube, o cualquier otro binomio digital— es imprescindible establecer protocolos capaces de sincronizar la información de forma desatendida y resiliente. Es decir, que estén preparados para proteger los datos ante fallos de conexión, o incluso cuando no haya posibilidad alguna de enviarlos.

Como estamos hablando de comunicación y de aplicaciones hibridas, vamos a ver un ejemplo completo de como podría usarse con .Net MAUI y Blazor, es decir el prototipo ideal de hybrid application que nos proporciona para dispositivos de movilidad Microsoft.

Blazor + .Net Maui

¿Qué ventajas nos da Blazor frente a una aplicación realizada en .NET MAUI? La curva de aprendizaje es más suave, y no es necesario pelear con código XAML (figura 1).

Xaml en .NET Maui

Pero en nuestro caso, sí debemos tener conocimientos de HTML, CSS y, por supuesto, Blazor. La aplicación híbrida lo que hará es usar un WebView con código incrustado.

Al igual que —como hablábamos al principio— un coche híbrido o eléctrico tiene su mercado, no usaremos un camión híbrido para recorrer 3000 km. Si hacemos el símil, una aplicación híbrida también tendrá el suyo. Ya que si solo usamos .NET MAUI para componer nuestro puzle —que será nuestro producto final—, esta tendrá beneficios respecto a usar una app híbrida, tal y como refleja esta tabla.

Por eso es fundamental analizar bien los requerimientos del cliente).

Comparativa .NET Maui vs Hibrida

Es decir, como consultores, debemos evaluar qué es más conveniente para el cliente y el producto final.

Lo importante es que nuestro ejemplo se adapta de igual manera tanto para una aplicación híbrida como para una solución .NET MAUI pura.

En este ejemplo, supondremos que n clientes están insertando países en una colección (que por ahora no sabemos dónde se alojará), y debemos tener en cuenta varias cosas:

  • Debemos tener resiliencia, es decir, estar preparados para errores, con sus respectivos reintentos.
  • Guardar los datos en el dispositivo local para evitar consultar la red constantemente.
  • Asimismo, debemos guardarlos por si se producen microcortes o incluso en caso de conectividad nula.
  • Debemos proponer soluciones escalables y capaces de responder ante escenarios de alta demanda.

Pues con estos mimbres, nos podremos manos a la obra.

Plantilla Maui + Blazor que nos proporciona Visual Studio

Una vez que hayamos creado nuestra aplicación, la tendremos lista con un ejemplo funcional, quedándonos una estructura tal que así:

Hybrid App

Tendremos, en la carpeta wwwroot (como en cualquier sitio web), la capa con el index.html como punto de entrada, junto con los diferentes estilos que podemos aplicar mediante CSS. La parte que se refiere más al contenido específico para cada sistema operativo host (Android, iOS, Windows, etc.) estará ubicada en la carpeta Platforms.

Y dentro de Components encontraremos los diferentes «trocitos» de código que usamos con Blazor para dar forma a nuestra aplicación.

Nuestra app básica de ejemplo

A partir de este punto, vamos a darle «vidilla» y hacer que nuestra app tome forma.

Como dije, vamos a tener una interfaz básica que nos pedirá un nombre de país. Tendrá dos botones: uno para agregarlo y otro para, una vez agregado, sincronizar, ¿Pero con qué vamos a sincronizar?.

Tal y como comentábamos, deberíamos tener resiliencia y una política de reintentos.
Nuestra app, cuando vayamos a sincronizar, podrá elegir dos vías que llevarán como destino final nuestros datos a la nube, en concreto a una Azure SQL.

Planteamos dos formas de hacerlo:

Con estas dos formas de sincronizar, queremos tener cubierto en nuestro ejemplo que podríamos implementar hasta una tercera política de intentos de conectividad.
Es decir, podríamos protegernos con varias capas de reintentos en caso de fallo.

De forma manual podrías configurar en nuestro MauiProgram (Clase de entrada) las diferentes políticas aplicando por ejemplo Polly.

Pero a su vez tenemos de manera nativa la política de reintentos de la Azure Fx.

Y para rizar más el rizo, si usamos el envío mediante Azure Service Bus, también dispondremos de su propia política de reintentos, y en su defecto su «poison«.

Este sería nuestro MauiProgram, donde aín no esta aplicado Polly, y tenemos confiurada una base de datos SQLite que hará de respaldo de nuestros datos en el dispositivo.
Además, para dividir el código, nos ayudamos de un service custom para realizar todas la peticiones.




using MauiDemo.Services;
using Microsoft.Extensions.Logging;

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

            builder.Services.AddMauiBlazorWebView();

#if DEBUG
            builder.Services.AddBlazorWebViewDeveloperTools();
            builder.Logging.AddDebug();
#endif
            var dbPath = Path.Combine(FileSystem.AppDataDirectory, "countries.db3");

            var countryService = new CountryService(dbPath);
            builder.Services.AddSingleton(countryService);

            return builder.Build();
        }
    }
}

Para apoyarnos en nuestra lógica, usaremos un Servicio (countryService), donde, entre otros métodos, tendremos la posibilidad de:

  • Salvar un registro en nuestra base de datos SQLite local.
  • Marcar localmente nuestro registro como sincronizado (update a la entidad).
  • Comprobar si un registro existe para identificador.
  • Llamar a dos Azure Functions, dependiendo del método de sincronización elegido.
 public async Task SyncToSqlAsync(Country item)
 {
     var json = JsonSerializer.Serialize(item);
     var content = new StringContent(json, Encoding.UTF8, "application/json");

     var functionUrl = "https://mauimiddleware-bfhzhdexg6hmb7c9.westeurope-01.azurewebsites.net/api/countries?code=XXXXXX";

     var response = await _httpClient.PostAsync(functionUrl, content);

     if (response.IsSuccessStatusCode)
     {
         await MarkAsSyncedAsync(item);
     }
     else
     {
         // Maneja el error
     }
 }

Perfecto, tenemos nuestro «backend de la app» casi montado, pero ¿dónde y cómo interactuamos con las listas, botones, eventos etc.?.

En nuestro caso, reutilizaremos el Home.razor que ya viene con el ejemplo básico, y lo modificaremos tal que así.

@page "/"
@inject CountryService CountryService



<div class="controls">
    <input class="input" @bind="newCountryName" placeholder="Nombre del país" />
    <button class="btn" @onclick="AddCountry">💾</button>
    <button class="btn" @onclick="SyncAll">🔄</button>
</div>

<div class="sync-options">
    <label>🌐 Conexión a Internet:</label>
    <input type="checkbox" @bind="isOnline" />
    <span>@(isOnline ? "Conectado" : "Sin conexión")</span>

    <label>🔄 Sincronizar con:</label>
    <select @bind="syncTarget">
        <option value="sql">Azure SQL</option>
        <option value="bus">Azure Service Bus</option>
    </select>
</div>

<ul class="country-list">
    @foreach (var country in countries)
    {
        <li class="item">
            <span>@country.Name</span>
            <span class="status">@((country.IsSynced ? "✔️" : "⏳"))</span>
        </li>
    }
</ul>

Y su code behind sería tal que así

@code {
    private string newCountryName = string.Empty;
    private List<Country> countries = new();
    private bool isOnline = true;
    private string syncTarget = "sql";

    protected override async Task OnInitializedAsync()
    {
        countries = await CountryService.GetAllAsync();
    }

    private async Task AddCountry()
    {
        if (string.IsNullOrWhiteSpace(newCountryName))
            return;

        var country = new Country { Name = newCountryName, Id = Guid.NewGuid() };

        var existe = await CountryService.GetByNameAsync(newCountryName);

        if (existe == null)
        {
            await CountryService.AddAsync(country);

            if (isOnline)
            {
                if (syncTarget == "sql")
                    await CountryService.SyncToSqlAsync(country);
                else
                    await CountryService.SyncToServiceBusAsync(country);
            }
        }
        else
        {
            return;
        }

        countries = await CountryService.GetAllAsync();
        newCountryName = string.Empty;
    }

    private async Task SyncAll()
    {
        var unsyncedCountries = await CountryService.GetUnsyncedAsync();

        if (unsyncedCountries.Any())
        {
            if (isOnline)
            {
                foreach (var country in unsyncedCountries)
                {
                    if (syncTarget == "sql")
                        await CountryService.SyncToSqlAsync(country);
                    else
                        await CountryService.SyncToServiceBusAsync(country);
                }
            }
            else
            {
                return;
            }
        }
        else
        {
            return;
        }

        countries = await CountryService.GetAllAsync();
        newCountryName = string.Empty;
    }
}

Donde reaccionaremos a los diferentes eventos (AddCountry, SyncAll), optando por una u otra vía en función valor que tenga el checkbok «IsOnline«, como se puede apreciar, no hay nada de XAML.

¿Cómo se vería nuestro interface?

Si desmarcamos el selector de «Conectado», todas nuestras entradas se guardarán únicamente en local (SQLite).

Pero si marcamos la conexión, cuando creemos un país se enviará primero a Azure SQL (protocolo Service Bus o directo) y, si todo ha ido correctamente, se guardará también en local.

Para saber si un país necesita ser sincronizado o no, aparte de que el icono en el listado será diferente, se apoya en el siguiente modelo:

using SQLite;

namespace MauiDemo.Data
{
    public class Country
    {
        [PrimaryKey]
        public Guid Id { get; set; }
        public string Name { get; set; }
        public bool IsSynced { get; set; } = false;
    }
}

Donde le indicamos, mediante un flag (booleano), si ha sido sincronizado o no.

La magia de las AzureFxs

En ambos casos, usamos Azure Functions para hacer de «Middleware» entre las peticiones del dispositivo movil y los diferentes subservicios, hasta llegar a almacenar el dato en Azure SQL.

Se ha decidido usar este método porque quizás sea uno de los más protegidos, ya que:

  • Podemos utilizar MSAL, en nuestra aplicación móvil para autenticar al usuario y, mediante un token válido, hacer que solo se pueda usar la función si las credenciales son correctas.
  • Las pegas que tiene este método es que mostramos la «puerta» (Url de la AzureFX) al usuario, aunque por defecto estará cerrada.
  • Podríoamos darle más seguridad, y es que, aparte de estar logueado, se le pedirá al usuario una clave que almacenemos en SecureStorage , y que usaríamos como validación adicional.

No se recomienda hacer llamadas directas a servicios como SQL, Service BUS o incluso Azure Key Vault desde frontend, para eso usaremos nuestras functions como «falso backend«, quedando tal que así:

/// Country to Azure Service Bus
public class CountryToSB
{
    private readonly IConfiguration _config;
    public CountryToSB(IConfiguration config)
    {
        _config = config;
    }


    [Function("SendCountryToBus")]
    public async Task&lt;HttpResponseData> Run([HttpTrigger(AuthorizationLevel.Function, "post", Route = "send-country")] HttpRequestData req,
        FunctionContext context)
    {
        var logger = context.GetLogger("SendCountryToBus");

        var requestBody = await new StreamReader(req.Body).ReadToEndAsync();
        var country = JsonSerializer.Deserialize&lt;Country>(requestBody);

        var namespaceFqdn = _config["ServiceBusNamespace"];
        var queueName = _config["QueueName"];

        var client = new ServiceBusClient(namespaceFqdn, new DefaultAzureCredential());
        var sender = client.CreateSender(queueName);

        try
        {

            var message = new ServiceBusMessage(JsonSerializer.Serialize(country))
            {
                ContentType = "application/json",
                Subject = "NewCountry"
            };

            await sender.SendMessageAsync(message);
            await sender.DisposeAsync();
            await client.DisposeAsync();
        }
        catch (Exception ex)
        {
            var errorResponse = req.CreateResponse(HttpStatusCode.InternalServerError);
            await errorResponse.WriteStringAsync("Error enviando el mensaje a Service Bus.");
            return errorResponse;
        }

        var response = req.CreateResponse(HttpStatusCode.OK);
        await response.WriteStringAsync("Mensaje enviado correctamente a Service Bus.");
        return response;
    }

}
/// Country to Azure SQL
public class CountryToSQL
{
    private readonly SecretClient _secretClient;
    private readonly string _secretName;


    public CountryToSQL(IConfiguration configuration)
    {
        var keyVaultUri = configuration["KeyVaultUri"];
        _secretName = configuration["SqlSecretName"];
        _secretClient = new SecretClient(new Uri(keyVaultUri), new DefaultAzureCredential());

    }

    [Function("AddCountry")]
    public async Task&lt;HttpResponseData> Run([HttpTrigger(AuthorizationLevel.Function, "post", Route = "countries")] HttpRequestData req,
        FunctionContext context)
    {
        var logger = context.GetLogger("AddCountry");
        var requestBody = await new StreamReader(req.Body).ReadToEndAsync();
        var country = JsonSerializer.Deserialize&lt;Country>(requestBody);

        var secret = await _secretClient.GetSecretAsync(_secretName);
        var connectionString = secret.Value.Value;

        using var conn = new SqlConnection(connectionString);
        await conn.OpenAsync();

        var cmd = new SqlCommand("INSERT INTO Country (Id,Name,IsSynced,Origin) VALUES (@Id,@Name,@IsSynced,@Origin)", conn);
        cmd.Parameters.AddWithValue("@Id", country.Id);
        cmd.Parameters.AddWithValue("@Name", country.Name);
        cmd.Parameters.AddWithValue("@IsSynced", true);
        cmd.Parameters.AddWithValue("@Origin", "Azure SQL");
        await cmd.ExecuteNonQueryAsync();

        var response = req.CreateResponse(HttpStatusCode.OK);
        await response.WriteStringAsync("País insertado correctamente.");
        return response;

    }
}
/// Read from Azure Service BUS
public class ReadFromSB
{
    private readonly SecretClient _secretClient;
    private readonly string _secretName;


    public ReadFromSB(IConfiguration configuration)
    {
        var keyVaultUri = configuration["KeyVaultUri"];
        _secretName = configuration["SqlSecretName"];
        _secretClient = new SecretClient(new Uri(keyVaultUri), new DefaultAzureCredential());

    }

    [Function(nameof(ReadFromSB))]
    public async Task Run(
        [ServiceBusTrigger("%QueueName%", Connection = "ServiceBusConnection")]
             string message, FunctionContext context)
    {
        var logger = context.GetLogger("ReadFromQueue");

        try
        {
            var country = JsonSerializer.Deserialize&lt;Country>(message);
            logger.LogInformation($"Mensaje recibido: {country?.Name}");

            var secret = await _secretClient.GetSecretAsync(_secretName);
            var connectionString = secret.Value.Value;

            using var conn = new SqlConnection(connectionString);
            await conn.OpenAsync();

            var cmd = new SqlCommand("INSERT INTO Country (Id,Name,IsSynced,Origin) VALUES (@Id,@Name,@IsSynced,@Origin)", conn);
            cmd.Parameters.AddWithValue("@Id", country.Id);
            cmd.Parameters.AddWithValue("@Name", country.Name);
            cmd.Parameters.AddWithValue("@IsSynced", true);
            cmd.Parameters.AddWithValue("@Origin", "Service Bus");
            await cmd.ExecuteNonQueryAsync();

        }
        catch (Exception ex)
        {
            logger.LogError($"Error al procesar el mensaje: {ex.Message}");
        }
    }
}

Con todo esto, tenemos la posibilidad de enviar nuestros mensajes y ser escuchados a través de Azure Service Bus, una solución que nos ofrece la potencia y escalabilidad necesarias para escenarios de alta demanda.

Este es solo un ejemplo funcional, pero como mejora, podríamos implementar una API protegida mediante un Gateway, desde la cual realizar las llamadas de forma más controlada y segura.

Lo importante aquí es entender que es perfectamente viable simular un “backend” utilizando Azure Functions, incluso bajo un modelo serverless, lo que nos permite construir soluciones resilientes, escalables y con un coste optimizado, sin necesidad de mantener infraestructura dedicada.

Hasta aquí, ya tenemos nuestro dispositivo preparado para sincronizar los elementos locales con los de la nube, utilizando distintos protocolos según el contexto.

Con esto, cumplimos el objetivo principal de esta entrada: construir una aplicación resiliente, capaz de operar en modo offline y de sincronizar datos locales con cualquier nube de forma segura, eficiente y escalable.

Y sobre todo, con:

  • 🔄 Sincronización desatendida: sin necesidad de intervención manual.
  • 🛡️ Protección ante fallos de conectividad: tolerancia a microcortes o desconexión total.
  • ☁️ Flexibilidad en la nube: compatible con Azure, AWS, GCP o cualquier backend moderno.
  • ⚙️ Protocolos adaptativos: desde colas de mensajería hasta APIs protegidas por Gateway.
  • 🚀 Escalabilidad serverless: gracias a Azure Functions y arquitecturas desacopladas.

Os dejo el código en Github

Muchísimas gracias.

Avatar de Jorge Fernández

Deja una respuesta