🏗️ Clean Architecture
Separación clara de responsabilidades en capas concéntricas con dependencias hacia el interior
Esta documentación define la arquitectura de software para una aplicación Software como Servicio (SaaS) multi-tenant, diseñada para ser segura, escalable y observable desde su concepción.
La arquitectura se fundamenta en un stack tecnológico moderno y sigue rigurosamente los principios de la Arquitectura Limpia (Clean Architecture) para garantizar un sistema mantenible y extensible.
🏗️ Clean Architecture
Separación clara de responsabilidades en capas concéntricas con dependencias hacia el interior
🔒 Multi-Tenancy Seguro
Aislamiento de datos mediante Row-Level Security (RLS) en PostgreSQL con Finbuckle.MultiTenant
⚡ Alto Rendimiento
Dapper como micro-ORM para control total sobre SQL y rendimiento predecible
📊 Observabilidad
OpenTelemetry completo con traces, metrics y logs enriquecidos por tenant
| Componente | Tecnología | Justificación |
|---|---|---|
| Framework | ASP.NET Core 8 | Rendimiento, modularidad y ecosistema maduro |
| Base de Datos | PostgreSQL + RLS | Seguridad a nivel de motor, escalabilidad |
| ORM | Dapper | Alto rendimiento, control total sobre SQL |
| Multi-Tenancy | Finbuckle.MultiTenant | Estrategias flexibles de resolución |
| Observabilidad | OpenTelemetry | Vendor-agnostic, instrumentación automática |
| Identidad | Custom Identity + 2FA | Control total, sin dependencias de EF Core |
El diseño se basa en capas concéntricas con responsabilidades bien definidas:
Principio fundamental: Todas las dependencias apuntan hacia el interior (Domain). Ningún código interno puede referenciar capas externas.
graph TD
subgraph "Clean Architecture"
D[Core.Domain
Entidades, Agregados, Interfaces]
A[Core.Application
Casos de Uso, DTOs, Interfaces]
I[Infrastructure.*
Implementaciones Concretas]
P[Presentation.WebAPI
Controladores, Middleware]
P --> I
P --> A
I --> A
I --> D
A --> D
style D fill:#aaffaa,stroke:#3c3
style A fill:#ffffaa,stroke:#cc3
style I fill:#ffaaaa,stroke:#c33
style P fill:#ccccff,stroke:#36c
end
subgraph "Sistemas Externos"
Client[Frontend Vue.js]
DB[(PostgreSQL + RLS)]
OTEL[OpenTelemetry Collector]
P -.->|HTTP/REST| Client
I -.->|Dapper/Npgsql| DB
I -.->|OTLP| OTEL
end
sequenceDiagram
participant C as Cliente
participant API as ASP.NET Core API
participant MT as Tenant Resolver
participant RLS as RLS Context
participant DB as PostgreSQL
participant OT as OpenTelemetry
C->>API: HTTP Request
API->>MT: Resolve Tenant (Finbuckle)
MT->>API: TenantInfo
API->>RLS: SET app.current_tenant_id
RLS->>DB: Execute Query with RLS
DB->>RLS: Filtered Results
API->>OT: Traces/Metrics/Logs
API->>C: HTTP Response
La solución se organiza siguiendo Clean Architecture con separación clara de responsabilidades:
📁 Core.Domain/├── 📄 Entities/ # Entidades de negocio├── 📄 ValueObjects/ # Objetos de valor├── 📄 Aggregates/ # Agregados de dominio├── 📄 Interfaces/ # Contratos de repositorio└── 📄 IMustHaveTenant.cs # Interface para RLSDependencias: Ninguna (cero dependencias externas)
📁 Core.Application/├── 📄 Services/ # Casos de uso├── 📄 DTOs/ # Data Transfer Objects├── 📄 Interfaces/ # Contratos de infraestructura└── 📄 Mappers/ # Mapeo de entidadesDependencias: Solo Core.Domain
📁 Infrastructure.Data/├── 📄 Repositories/ # Implementaciones Dapper├── 📄 UnitOfWork/ # Gestión transaccional├── 📄 ConnectionFactory/ # Fábrica de conexiones RLS└── 📄 Migrations/ # Scripts de base de datos📁 Infrastructure.Identity/├── 📄 Stores/ # UserStore personalizado├── 📄 Managers/ # UserManager/SignInManager├── 📄 TwoFactor/ # Implementación 2FA TOTP└── 📄 PasswordHashing/ # Argon2id hashing📁 Infrastructure.Cache/├── 📄 Distributed/ # Redis cache├── 📄 Local/ # In-memory cache└── 📄 TenantAware/ # Prefijos por tenant📁 Presentation.WebAPI/├── 📄 Controllers/ # Endpoints API├── 📄 Middleware/ # Pipeline personalizado├── 📄 Program.cs # Configuración DI/OTEL└── 📄 appsettings.json # Configuración baseResponsabilidades:
📁 Shared.MultiTenancy/├── 📄 TenantContext.cs # Propagación AsyncLocal├── 📄 Extensions/ # Helpers Finbuckle└── 📄 Interfaces/ # Contratos transversales📁 tests/├── 📁 Core.Tests/ # Tests de dominio/aplicación├── 📁 Infrastructure.Tests/ # Tests de infraestructura└── 📁 Integration.Tests/ # Tests end-to-end| Estrategia | Pros | Contras | Nuestra Elección |
|---|---|---|---|
| DB por Tenant | Aislamiento máximo | Alto costo operativo | ❌ |
| Schema por Tenant | Buen aislamiento | Complejidad de gestión | ❌ |
| RLS Compartida | Eficiencia, escalabilidad | Requiere configuración cuidadosa | ✅ |
Se implementan múltiples estrategias en orden de prioridad:
tenant1.app.com)/tenant1/api)X-Tenant-ID// Program.cs - Configuraciónbuilder.Services.AddMultiTenant<AppTenantInfo>() .WithClaimStrategy("tenant_id") .WithHostStrategy("__tenant__.__domain__") .WithBasePathStrategy() .WithHeaderStrategy("X-Tenant-ID") .WithStore<CustomDapperTenantStore>();-- Habilitar RLS en tablaALTER TABLE assets ENABLE ROW LEVEL SECURITY;
-- Crear política de aislamientoCREATE POLICY tenant_isolation_policyON assetsUSING (tenant_id::TEXT = current_setting('app.current_tenant_id'));app.current_tenant_id en sesión PostgreSQLWHERE tenant_id = current_tenantpublic class NpgsqlConnectionFactory : IDbConnectionFactory{ private readonly IMultiTenantContextAccessor<AppTenantInfo> _tenantAccessor;
public IDbConnection CreateConnection() { var tenantInfo = _tenantAccessor.MultiTenantContext?.TenantInfo; if (tenantInfo == null) throw new UnauthorizedAccessException("Tenant context not resolved");
var connection = new NpgsqlConnection(_connectionString); connection.Open();
// CRÍTICO: Establecer contexto RLS using var cmd = connection.CreateCommand(); cmd.CommandText = "SELECT set_config('app.current_tenant_id', @tenant, true)"; cmd.Parameters.AddWithValue("@tenant", tenantInfo.Id); cmd.ExecuteNonQuery();
return connection; }}⚡ Rendimiento
Excepcional: Mapeo con sobrecarga mínima, cercano a ADO.NET nativo
🎯 Control SQL
Total: El desarrollador escribe SQL directamente, optimizaciones finas posibles
📦 Simplicidad
Baja complejidad: Extensión simple sobre IDbConnection
public interface IWriteRepository<in TParameter>{ Task HandleAsync(TParameter parameter);}
public interface IReadRepository<in TQuery, TResult> where TQuery : IQueryParameter<TResult>{ Task<TResult> HandleAsync(TQuery query);}
// Unit of Work por contexto de negociopublic interface IInventarioUnitOfWork : IBaseUnitOfWork{ IProductoRepository Productos { get; }}
public interface IFinanzasUnitOfWork : IBaseUnitOfWork{ IFacturaRepository Facturas { get; }}public class UnitOfWork : IInventarioUnitOfWork, IFinanzasUnitOfWork{ public IDbConnection Connection { get; } public IDbTransaction Transaction { get; }
// Repositorios por contexto public IProductoRepository Productos { get; } public IFacturaRepository Facturas { get; }
public UnitOfWork(IDbConnectionFactory connectionFactory) { // Conexión RLS-aware única Connection = connectionFactory.CreateTenantAwareConnection(); Transaction = Connection.BeginTransaction();
// Repositorios comparten conexión/transacción Productos = new ProductoRepository(Connection, Transaction); Facturas = new FacturaRepository(Connection, Transaction); }}public class ProductoRepository : IProductoRepository{ private readonly IDbConnection _connection; private readonly IDbTransaction _transaction;
public ProductoRepository(IDbConnection connection, IDbTransaction transaction) { _connection = connection; _transaction = transaction; }
public async Task HandleAsync(Producto entity) { var sql = @"INSERT INTO productos (id, tenant_id, nombre) VALUES (@Id, @TenantId, @Nombre)";
await _connection.ExecuteAsync(sql, entity, transaction: _transaction); // RLS automáticamente filtra por tenant_id }}public class PostgresConfigurationProvider : ConfigurationProvider{ public override void Load() { // Cargar desde PostgreSQL LoadAsync().GetAwaiter().GetResult(); OnReload(); // Notificar cambios }
private async Task LoadAsync() { // 1. Configuraciones globales (tenant_id = NULL) // 2. Configuraciones por tenant // 3. Poblar diccionario Data del provider }}// Definir configuración tipadapublic class BillingOptions{ public bool IsEnabled { get; set; } public string CurrencyCode { get; set; } = "USD"; public int MaxUsers { get; set; } = 50;}
// Consumir en serviciopublic class BillingService{ private readonly BillingOptions _options;
public BillingService(IOptionsSnapshot<BillingOptions> options) { _options = options.Value; // Valores frescos por request }}// Agregar proveedor PostgreSQLbuilder.Configuration.AddPostgresConfiguration( connectionString, dbFactory, cache);
// Opciones tenant-awarebuilder.Services.AddMultiTenant<AppTenantInfo>() .WithTenantedConfigure<BillingOptions>((options, tenant) => { if (tenant?.Identifier == "EnterpriseTenant") { options.MaxUsers = 1000; // Override por tenant } });Para mantener consistencia con Dapper y evitar dependencias de Entity Framework, se implementa un sistema de identidad completamente personalizado.
// DapperUserStore: Implementa IUserStore<AppUser>public class DapperUserStore : IUserStore<AppUser>, IUserPasswordStore<AppUser>, IUserTwoFactorStore<AppUser>{ private readonly IDbConnectionFactory _connectionFactory;
public async Task<AppUser> FindByNameAsync(string userName) { using var connection = _connectionFactory.CreateConnection(); var sql = "SELECT * FROM users WHERE username = @UserName"; return await connection.QuerySingleOrDefaultAsync<AppUser>(sql, new { UserName = userName }); }}public class TotpService : ITotpService{ public string GenerateSecret() => Base32Encoding.ToString( RandomNumberGenerator.GetBytes(20));
public string GenerateQrCodeUri(string email, string secret) { return $"otpauth://totp/{Uri.EscapeDataString(email)}" + $"?secret={secret}&issuer=MyApp"; }
public bool ValidateCode(string secret, string code) { var totp = new Totp(Base32Encoding.ToBytes(secret)); return totp.VerifyTotp(code, out _); }}public class Argon2PasswordHasher : IPasswordHasher<AppUser>{ public string HashPassword(AppUser user, string password) { var argon2 = new Argon2id(Encoding.UTF8.GetBytes(password)) { Salt = RandomNumberGenerator.GetBytes(16), DegreeOfParallelism = 8, Iterations = 4, MemorySize = 1024 * 1024 };
return Convert.ToBase64String(argon2.GetBytes(32)); }}🔍 Traces
Distributed Tracing: Seguimiento de requests a través de servicios
📈 Metrics
Application Metrics: CPU, memoria, request rates, errores
📝 Logs
Structured Logging: Correlación automática con TraceId/SpanId
builder.Services.AddOpenTelemetry() .WithTracing(tracing => tracing .AddAspNetCoreInstrumentation() .AddHttpClientInstrumentation() .AddNpgsql() .AddOtlpExporter()) .WithMetrics(metrics => metrics .AddAspNetCoreInstrumentation() .AddRuntimeInstrumentation() .AddOtlpExporter()) .WithLogging(logging => logging .AddOtlpExporter());public class TenantEnrichmentProcessor : BaseProcessor<Activity>{ private readonly IHttpContextAccessor _httpContextAccessor;
public override void OnStart(Activity activity) { var httpContext = _httpContextAccessor.HttpContext; var tenantInfo = httpContext?.GetMultiTenantContext<AppTenantInfo>();
if (tenantInfo?.TenantInfo != null) { activity.SetTag("tenant.id", tenantInfo.TenantInfo.Id); activity.SetTag("tenant.name", tenantInfo.TenantInfo.Name); }
base.OnStart(activity); }}public static class DapperTracing{ public static async Task<T> QueryWithTracing<T>( this IDbConnection connection, string sql, object param = null) { using var activity = ActivitySource.StartActivity("db.query"); activity?.SetTag("db.statement", sql); activity?.SetTag("db.system", "postgresql");
try { return await connection.QueryAsync<T>(sql, param); } catch (Exception ex) { activity?.SetStatus(ActivityStatusCode.Error, ex.Message); throw; } }}| Área | Práctica Recomendada | Justificación |
|---|---|---|
| Secretos | Azure Key Vault/AWS Secrets Manager | Previene exposición en código fuente |
| Base de Datos | Usuario SIN privilegio BYPASSRLS | Garantiza que RLS sea inquebrantable |
| Telemetría | Filtrar PII en OpenTelemetry Collector | Cumplimiento GDPR, previene fugas |
| Autenticación | Rate limiting en login/2FA | Mitiga ataques de fuerza bruta |
🔧 Dapper Optimizations
🗄️ Caching Strategy
📊 Monitoring