1. Introducción
Entonces, ya hemos visto que las interfaces son contratos potentes. ¿Pero qué es exactamente programar a nivel de interfaces? Es una filosofía de diseño que dice: "Depende de abstracciones, no de implementaciones concretas."
Vamos otra vez con una analogía. Quieres preparar café. Compras una cafetera. No te importa si es el modelo concreto "Super-Puper-Machine V3000" o "Mega-Automat Barista 5000", ¿verdad? Lo que te importa es que ese aparato sabe "hacer café", o sea, implementa el "contrato" ICoffeeMaker. Simplemente pulsas el botón "Hacer café" y lo hace. Cómo lo hace — con café molido, cápsulas o lo que sea — como usuario, no te preocupa mucho.
En código, esto significa que en vez de pedir en tus métodos un tipo concreto como Document o Image, vas a pedir IPrintable. Tu código funcionará con cualquier objeto que implemente esa interfaz, sin saber su tipo concreto.
Contrato de interfaz — es la garantía de que cada clase que implementa la interfaz va a proporcionar sí o sí las definiciones de todos los miembros de la interfaz (métodos, propiedades, eventos). Desde el punto de vista del programa, cualquier objeto que implemente la interfaz soporta un "conjunto de capacidades" determinado, y puedes confiar en eso, aunque no sepas cómo está hecho por dentro.
Supón que has quedado con tu colega en que siempre os encontráis junto al sofá rojo en el hall. Cómo llega él allí — en ascensor, andando o corriendo por las paredes — da igual. Lo importante: él estará allí. Pues la interfaz es ese "sofá rojo" para el código.
Ejemplo
public interface IPrintable
{
void Print();
}
Contrato: cualquiera que implemente IPrintable debe saber imprimirse en pantalla. El cómo — ya son detalles.
2. El contrato como punto de interacción entre partes del sistema
Cómo funciona en la práctica
Un sistema puede estar formado por decenas de clases, hechas por personas distintas, en momentos distintos, con objetivos distintos. Pero si todas siguen el mismo contrato (o sea, implementan la misma interfaz), se pueden usar de forma uniforme.
Ejemplo de una app real: imagina una base de clientes. Clase Customer, clase Employee, clase Contractor. Cada una guarda la info a su manera, pero si todas implementan, por ejemplo, la interfaz IContactInfo, que exige los métodos GetEmail() y GetPhoneNumber(), para el código externo da igual de qué tipo es el objeto — lo importante es que puedes sacar el email y el teléfono.
public interface IContactInfo
{
string GetEmail();
string GetPhoneNumber();
}
public class Customer : IContactInfo
{
public string Email { get; set; }
public string Phone { get; set; }
public string GetEmail() => Email;
public string GetPhoneNumber() => Phone;
}
// Igual para Employee y Contractor...
Ahora, si necesitas imprimir los datos de todos los que tienen contacto con tu empresa (da igual quiénes sean), simplemente recorres la lista de IContactInfo, llamas a los métodos que necesitas — y todo funciona.
3. Programar "a nivel de interfaces"
Programar a nivel de interfaces significa escribir código que depende no de clases concretas, sino solo de interfaces (o sea, contratos). La clase que implementa la interfaz puede ser la que sea, mientras cumpla lo que pide la interfaz.
¿Por qué es importante?
- Escalabilidad: puedes añadir nuevos tipos fácilmente, sin tocar el código que ya tienes.
- Testabilidad: puedes sustituir objetos por mocks (fakes) fácilmente al testear.
- Flexibilidad: la implementación puede cambiar cada día, la interfaz sigue estable.
- Limpieza arquitectónica: tus módulos están poco acoplados entre sí, puedes reutilizarlos.
Ejemplo — Programa conocido: "Cuenta bancaria"
En ejemplos anteriores teníamos una clase abstracta BankAccount con un método abstracto Withdraw(), y los tipos concretos (SavingsAccount, CheckingAccount) implementaban los detalles.
Ahora vamos a añadir una interfaz — por ejemplo, para mostrar info del saldo:
public interface IBalanceReporter
{
void ReportBalance();
}
public abstract class BankAccount : IBalanceReporter
{
public double Balance { get; set; }
public abstract void Withdraw(double amount);
public void ReportBalance()
{
Console.WriteLine($"Saldo actual: {Balance} euros");
}
}
Ahora todos los que trabajen con IBalanceReporter pueden llamar a ReportBalance() sin importar el tipo concreto de cuenta.
4. Procesamiento general y polimorfismo a través del contrato
Ejemplo: handler general
En cuanto trabajamos con una interfaz, podemos crear métodos genéricos que no dependen del tipo de objeto:
static void PrintAllBalances(IBalanceReporter[] accounts)
{
foreach (var reporter in accounts)
{
reporter.ReportBalance();
}
}
En esa lista pueden ir cualquier objeto que implemente IBalanceReporter: SavingsAccount, CheckingAccount, incluso un MockAccountForTesting. Y no hay magia, todo funciona de verdad.
Esquemáticamente: qué aporta el contrato de interfaz
+-------------------+ implementa +-------------------+
| BankAccount | <---------------- | IBalanceReporter |
| (SavingsAccount) | |-------------------|
| (CheckingAccount)| | + ReportBalance() |
+-------------------+ +-------------------+
| ^
| |
+-----------+-----------------------+
|
Cualquier otra clase que implemente el contrato
5. Contratos, lógica de negocio y arquitectura
El contrato de interfaz separa claramente el qué debe saber hacer una clase, del cómo lo implementa. Por eso a los arquitectos de software les gusta diseñar la lógica de los sistemas a través de interfaces. Muchas veces primero se diseña la interfaz (el contrato), y la implementación viene después.
Ejemplo de la vida real: sistemas de pago
Monederos electrónicos, tarjetas, PayPal, criptomonedas... Cada uno tiene sus detalles, pero si abstraes y creas una interfaz IPaymentProvider:
public interface IPaymentProvider
{
void Pay(decimal amount);
bool Refund(decimal amount);
}
El código que interactúa con esta interfaz no le importa si paga con tarjeta o con cuenta. Es cómodo para la arquitectura y para la vida: puedes añadir soporte para nuevos sistemas de pago sin tocar el resto del código.
6. Extraer la lógica de negocio al contrato
El contrato permite sacar las reglas de negocio principales al nivel de la interfaz, y dejar los detalles (por ejemplo, comprobar el límite, dar cashback, etc.) a las clases concretas.
Otro ejemplo
public interface ILogger
{
void LogInfo(string message);
void LogError(string message);
}
public class ConsoleLogger : ILogger
{
public void LogInfo(string message) => Console.WriteLine($"INFO: {message}");
public void LogError(string message) => Console.WriteLine($"ERROR: {message}");
}
public class FileLogger : ILogger
{
public void LogInfo(string message) => /* Escribir en archivo */;
public void LogError(string message) => /* Escribir en archivo */;
}
Luego el código cliente es así (da igual con qué logger trabaje):
void DoWork(ILogger logger)
{
logger.LogInfo("El trabajo ha empezado.");
// ... algún trabajo ...
logger.LogError("El trabajo salió mal.");
}
Esto es programar a nivel de interfaces: el código cliente depende solo del contrato (la interfaz), no de la implementación concreta.
7. Errores y trampas típicas
Los que empiezan suelen cometer este error: su código está fuertemente atado a clases concretas, no a interfaces. Esto trae un montón de problemas:
- Es difícil cambiar algo o testear — hay que reescribir mucho código.
- No puedes meter nuevos tipos sin tocar muchas partes.
- Los módulos acaban muy "pegados" entre sí.
Lo contrario — si desde el principio construyes el sistema con interfaces y pasas los parámetros por ellas, puedes conseguir bajo acoplamiento y flexibilidad.
El consejo principal: intenta pensar en la interacción entre módulos como interacción entre contratos, no entre implementaciones concretas.
GO TO FULL VERSION