El problema: una fuente de datos que va a cambiar (y no quiero enterarme)
Tengo que pintar un dashboard con datos de SAP. Hoy no tengo SAP: tengo un .txt que imita su export. Mañana habrá un sandbox de OData. Pasado, una conexión real a un tenant. ¿Cómo organizo el código para que ese baile no me obligue a reescribir el backend cada vez?
La tentación es la de siempre: meter HttpClient en el controlador, parsear ahí mismo, y “ya lo refactorizamos cuando llegue OData”. Hasta que llega. Y descubres que la mitad del proyecto sabe demasiado sobre el origen de los datos.
Cuando el origen va a cambiar, lo único que tiene que cambiar es el adaptador. El resto del sistema no se debería enterar.
La idea: un puerto, varios adaptadores
La forma honesta de cumplir esa promesa es la arquitectura hexagonal: capas que apuntan siempre hacia el dominio, con interfaces (puertos) entre las fronteras.
Fuente (Mock | SAP)
│
▼ ISalesRepository.SearchAsync
IngestSales (use case)
│
▼ ISalesStore.SaveAsync
SQLite
│
▼ ISalesStore.ReadAllAsync
SalesAnalytics ────▶ Dashboard
Dos puertos outbound (ISalesRepository para la fuente, ISalesStore para el almacén) y un puerto inbound (HTTP, vía controlador). Vamos por partes.
Paso 1: el dominio, puro
Sin framework, sin HTTP, sin SQL. Solo entidades y el tipo transversal que vamos a usar para hablar de errores:
public record Sale(
DateOnly Date,
string CustomerId,
string ProductName,
int Quantity,
decimal Amount);
Para los errores esperables uso Result<T>/Error propios (sin librerías). Si esto te suena nuevo, te lo conté en detalle en Result Pattern en TypeScript: la idea es la misma, en C#. Los errores no se lanzan, se devuelven como valor.
Paso 2: el puerto
Una interfaz, y nada más. Es la única firma que conoce el origen de los datos:
public interface ISalesRepository
{
Task<Result<IReadOnlyList<Sale>>> SearchAsync(CancellationToken ct = default);
}
Cualquier cosa que quiera “leer ventas” depende de esto. No de un HttpClient, no de un fichero, no de SAP. De esto.
Paso 3: el primer adaptador (el mock)
El mock vive en Infrastructure/Outbound/MockTxt/. Es la única clase que sabe que el fichero está en /sales.txt, que va en ISO-8859-1 (Latin-1, porque SAP) y que las columnas son DATE|CUSTOMER_ID|PRODUCT_NAME|QUANTITY|AMOUNT:
public sealed class MockTxtSalesRepository(HttpClient http) : ISalesRepository
{
public async Task<Result<IReadOnlyList<Sale>>> SearchAsync(CancellationToken ct = default)
{
try
{
var bytes = await http.GetByteArrayAsync("/sales.txt", ct);
var text = Encoding.GetEncoding("ISO-8859-1").GetString(bytes);
// ... parseo a Sale ...
return Result<IReadOnlyList<Sale>>.Success(sales);
}
catch (HttpRequestException ex)
{
return Result<IReadOnlyList<Sale>>.Failure(
Error.Unavailable($"Could not reach the SAP data source: {ex.Message}"));
}
// OperationCanceledException se propaga: cancelar no es fallar.
}
}
Dos detalles importantes:
- Las excepciones de infraestructura se capturan en el borde del adaptador y se traducen a
Result.Failure(Error.Unavailable(...)). Del adaptador hacia arriba, nadie ve una excepción. - Las cancelaciones se re-lanzan. Cancelar es una decisión del cliente, no un error de negocio.
Paso 4: el momento “show me” — SAP real con un segundo adaptador
Hasta aquí, hexagonal de manual. La prueba de fuego: ¿realmente puedo cambiar el origen sin tocar el resto?
Escribo SapODataSalesRepository, que pega contra el sandbox del SAP Business Accelerator Hub (cuenta gratis → API key → OData de API_SALES_ORDER_SRV). Misma firma del puerto, misma disciplina con Result:
public sealed class SapODataSalesRepository(HttpClient http) : ISalesRepository
{
public async Task<Result<IReadOnlyList<Sale>>> SearchAsync(CancellationToken ct = default)
{
try
{
var json = await http.GetStringAsync(
"A_SalesOrderItem?$expand=to_SalesOrder&$top=200&$format=json", ct);
var payload = JsonSerializer.Deserialize<ODataResponse>(json, JsonOptions);
// ... mapeo de Material/RequestedQuantity/NetAmount/SoldToParty/CreationDate a Sale ...
return Result<IReadOnlyList<Sale>>.Success(sales);
}
catch (HttpRequestException ex)
{
return Result<IReadOnlyList<Sale>>.Failure(Error.Unavailable(...));
}
catch (JsonException ex)
{
return Result<IReadOnlyList<Sale>>.Failure(Error.Unexpected(...));
}
}
}
Y en el composition root (Program.cs), una variable de configuración elige cuál se cablea. Esto es lo único que cambia entre “uso el mock” y “uso SAP real”:
if (string.Equals(salesSource, "Sap", StringComparison.OrdinalIgnoreCase))
{
var apiKey = config["Sap:ApiKey"]
?? throw new InvalidOperationException("SalesSource=Sap requires Sap:ApiKey");
builder.Services.AddHttpClient<ISalesRepository, SapODataSalesRepository>(client =>
{
client.BaseAddress = new Uri(config["Sap:BaseUrl"] ?? "https://sandbox.api.sap.com/...");
client.DefaultRequestHeaders.Add("APIKey", apiKey);
});
}
else
{
builder.Services.AddHttpClient<ISalesRepository, MockTxtSalesRepository>(client =>
client.BaseAddress = new Uri(config["SapMock:BaseUrl"] ?? "http://sap-mock:8080"));
}
El controlador, el caso de uso, el dominio: ni se enteran. La cabecera APIKey se inyecta en el composition root, así que el adaptador tampoco conoce el secreto.
Paso 5: y ahora una tienda real — Shopify, el tercer adaptador
Si el puerto cumple su promesa, un origen completamente distinto debería costar lo mismo: otro adaptador. Para demostrarlo añado ShopifyOrdersRepository, que lee pedidos reales de una tienda Shopify vía Admin REST. La única arruga es la autenticación: el Dev Dashboard ya no expone un token estático, así que un ShopifyTokenProvider intercambia client_id + client_secret por un access token (Client Credentials Grant) y lo cachea. El adaptador solo conoce el token; las credenciales entran por el composition root, igual que la API key de SAP.
Resultado: tres orígenes (mock .txt, SAP S/4HANA por OData, Shopify por REST) detrás de la misma interfaz, y SalesSource=Mock|Sap|Shopify elige cuál se cablea. El dominio sigue sin saber que Shopify existe.
Persistencia con un segundo puerto
El siguiente paso del experimento: meter una DB. Pero bien, no en el adaptador de la fuente.
La hexagonal no tiene un solo puerto outbound. Aquí caben dos: la fuente (de dónde vienen los datos) y el almacén (dónde los guardamos nosotros). Cada uno con su contrato:
public interface ISalesStore
{
Task<Result<int>> SaveAsync(IReadOnlyList<Sale> sales, CancellationToken ct = default);
Task<Result<IReadOnlyList<Sale>>> ReadAllAsync(CancellationToken ct = default);
}
El adaptador lo implementa con SQLite y SQL a mano (Microsoft.Data.Sqlite, sin ORM): decimales como texto invariante para no perder precisión, fechas en ISO. Misma disciplina: SqliteException se traduce en el borde a Error.Unavailable.
Bind: el momento en que de verdad encadenas
Con dos puertos aparece el caso de uso que los conecta: leer de la fuente y guardar en el almacén. Tipos: Task<Result<IReadOnlyList<Sale>>> → Task<Result<int>>. Cualquiera de los dos puede fallar; quiero que el fallo del primero corte el segundo, sin un solo if ni try.
Eso es bind:
public sealed class IngestSales(ISalesRepository source, ISalesStore store)
{
public Task<Result<int>> ExecuteAsync(CancellationToken ct = default) =>
source.SearchAsync(ct).BindAsync(sales => store.SaveAsync(sales, ct));
}
Una línea. Si la fuente devuelve Failure(Unavailable), el SaveAsync no se ejecuta y ese mismo error sale por el otro lado. El controlador lo abre con un Match y lo traduce a HTTP en un único sitio (ErrorHttpResults → 404/400/502/500).
La analítica, mientras, ya no lee de la fuente: lee del almacén.
public sealed class SalesAnalytics(ISalesStore store)
{
public async Task<Result<IReadOnlyList<ProductTotal>>> TotalsByProductAsync(...)
{
var sales = await store.ReadAllAsync(ct);
return sales.Map(AggregateByProduct);
}
}
Y para que el dashboard tenga datos al primer render hay un seed al arrancar y un POST /api/sales/refresh para volver a tirar de la fuente cuando quieras.
Testing: dobles sin librerías de mocking
El testing es donde más se nota la disciplina. Cero librerías de mocking. Stubs hechos a mano con factorías estáticas:
public sealed class StubSalesRepository(Result<IReadOnlyList<Sale>> result) : ISalesRepository
{
public static StubSalesRepository Returning(params Sale[] sales) =>
new(Result<IReadOnlyList<Sale>>.Success(sales));
public static StubSalesRepository Failing(Error error) =>
new(Result<IReadOnlyList<Sale>>.Failure(error));
public Task<Result<IReadOnlyList<Sale>>> SearchAsync(CancellationToken ct = default) =>
Task.FromResult(result);
}
Los tests de IngestSales se leen como prosa:
[Fact]
public async Task OnSourceFailure_ShortCircuitsAndDoesNotSave()
{
var error = Error.Unavailable("source down");
var store = StubSalesStore.Containing();
var ingest = new IngestSales(StubSalesRepository.Failing(error), store);
var result = await ingest.ExecuteAsync();
Assert.Same(error, FailureError(result));
Assert.Null(store.LastSaved); // el save nunca corrió
}
Integración con WebApplicationFactory<Program>: registras tus dobles en el contenedor y disparas peticiones reales contra el pipeline entero. Sin internet, sin SAP, sin SQLite real, y sin renunciar a probar el flujo completo.
La promesa cumplida
Volvamos al principio: “cuando el origen cambie, lo único que tiene que cambiar es el adaptador”. ¿Lo cumplimos?
Domain/no sabe lo que es HTTP, OData, SAP, SQLite, ni nada. Lo verifico con ungrep.Application/(SalesAnalytics,IngestSales) solo conoce sus puertos.- Cambiar de mock a SAP o a Shopify = una variable de entorno (
SalesSource=Sap|Shopify) más sus secretos. - Cambiar SQLite por Postgres = un adaptador nuevo que implemente
ISalesStore+ cambiar su registro enProgram.cs.
Eso es todo. No hay magia ni metaprogramación: solo dos interfaces respetadas con disciplina.
Del experimento a algo desplegado
Un experimento que solo corre en localhost convence a medias. La demo en vivo va sobre Google Cloud Run (backend + mock, cada pieza con su Dockerfile) y Vercel (el frontend Next.js). El front pega contra el backend server-side, así que el navegador nunca llama directamente a la API y CORS ni entra en juego.
¿Por qué Cloud Run y no Render? El free tier de Render duerme los servicios tras ~15 min y devuelve un 502 mientras despiertan, y eso dejaba la demo en blanco. Cloud Run también escala a cero, pero el cold-start es de ~1-2 s y la petición espera al contenedor en vez de fallar.
El despliegue está automatizado con GitHub Actions: un push a main que toque backend/ reconstruye y despliega backend + mock, autenticando contra Google Cloud con Workload Identity Federation — sin guardar ninguna clave de service account como secreto del repo.
TL;DR
- Si el origen va a cambiar, escóndelo tras un puerto. Si la persistencia va a cambiar, escóndela tras otro.
- Errores esperables →
Result. Excepciones solo para lo verdaderamente excepcional, capturadas en el borde del adaptador. - Encadena con
Map(transformaciones puras) yBind(pasos que pueden fallar). El primerBindreal aparece en cuanto tienes dos puertos. - El controlador es el único sitio que abre el
Result(conMatch). El dominio nunca conoce HTTP. - Dobles sin librerías de mocking +
WebApplicationFactory. Los tests son simples porque la arquitectura ya hizo el trabajo.
Código: aitorevi/connect-analyzer. Demo en vivo: connect-analyzer.vercel.app (la primera carga puede tardar ~1-2 s si el backend estaba dormido en Cloud Run; no se queda en blanco).
Spoiler: mover la agregación al puerto (que cada origen empuje el GROUP BY donde tenga sentido — SQL para SQLite, $apply para OData) es el siguiente paso del experimento. Quizá sea otro post.