Si has empezado con Java hace poco, seguro que tu código hasta ahora ha sido variables sueltas y métodos que trabajan con ellas: un nombreCliente por aquí, un calcularTotal(precio, iva) por allá. Funciona mientras el programa es pequeño, pero en cuanto tienes que manejar varios clientes, varios coches o varias cuentas a la vez, empiezas a arrastrar arrays paralelos y variables sin relación entre sí que en realidad describen la misma cosa.
La Programación Orientada a Objetos resuelve justo eso: en vez de variables sueltas, agrupas los datos y el código que los maneja en un solo bloque, la clase. Vamos a construirlo con ejemplos que puedes tocar (un coche, una cuenta bancaria) en vez de con animales o figuras geométricas abstractas, y vas a acabar con un sistema bancario con menú interactivo funcionando de verdad.
Qué es una clase en la práctica
Imagina que tienes que describir un coche a alguien que nunca ha visto uno. Le dirías qué tiene (marca, modelo, color, velocidad actual) y qué puede hacer (acelerar, frenar, encender). Una clase en Java es exactamente esa descripción, convertida en código: las propiedades son los datos que guarda, y los métodos son las acciones que puede ejecutar.
La diferencia con la variable suelta de antes es que ahora el dato y el comportamiento que lo modifica viven juntos, en el mismo sitio. Eso es lo que vas a ver en el siguiente ejemplo.
Tu primera clase: Coche
Abre tu IDE (IntelliJ IDEA, Eclipse o VS Code con la extensión de Java) y crea un archivo Coche.java:
public class Coche {
// Propiedades: lo que tiene un coche
private String marca;
private String modelo;
private String color;
private int velocidad;
private boolean encendido;
// Constructor: cómo se crea un coche
public Coche(String marca, String modelo, String color) {
this.marca = marca;
this.modelo = modelo;
this.color = color;
this.velocidad = 0;
this.encendido = false;
}
// Métodos: lo que puede hacer un coche
public void encender() {
this.encendido = true;
System.out.println(marca + " " + modelo + " está encendido");
}
public void acelerar(int incremento) {
if (!encendido) {
System.out.println("❌ Primero debes encender el coche");
return;
}
velocidad += incremento;
System.out.println("Velocidad actual: " + velocidad + " km/h");
}
public void frenar(int decremento) {
velocidad -= decremento;
if (velocidad < 0) velocidad = 0;
System.out.println("Frenando... Velocidad: " + velocidad + " km/h");
}
// Getter: para consultar las propiedades desde fuera
public String getInfo() {
return marca + " " + modelo + " " + color + " - " + velocidad + " km/h";
}
}
Fíjate en que marca, modelo y velocidad son private: eso es encapsulación, y no es un capricho de estilo. Si esos campos fueran public, cualquier código externo podría escribir miCoche.velocidad = -50; y dejar el objeto en un estado imposible, sin pasar por ningún control. Con private, el compilador ni siquiera te deja intentarlo desde fuera de la clase: obtienes el error velocidad has private access in Coche. La única forma de cambiar la velocidad es a través de acelerar() o frenar(), que son los que deciden si el cambio tiene sentido.
Ahora crea Main.java para probar el coche:
public class Main {
public static void main(String[] args) {
Coche miCoche = new Coche("Toyota", "Corolla", "Rojo");
Coche cocheAmigo = new Coche("BMW", "X5", "Negro");
System.out.println("=== MI COCHE ===");
miCoche.encender();
miCoche.acelerar(50);
miCoche.acelerar(30);
miCoche.frenar(20);
System.out.println(miCoche.getInfo());
System.out.println("\n=== COCHE DEL AMIGO ===");
cocheAmigo.acelerar(60); // intento sin encender primero
cocheAmigo.encender();
cocheAmigo.acelerar(60);
System.out.println(cocheAmigo.getInfo());
}
}
Ejecuta con el botón Run (o Shift+F10 en IntelliJ). Deberías ver:
=== MI COCHE ===
Toyota Corolla está encendido
Velocidad actual: 50 km/h
Velocidad actual: 80 km/h
Frenando... Velocidad: 60 km/h
Toyota Corolla Rojo - 60 km/h
=== COCHE DEL AMIGO ===
❌ Primero debes encender el coche
BMW X5 está encendido
Velocidad actual: 60 km/h
BMW X5 Negro - 60 km/h
Fíjate en el cocheAmigo.acelerar(60) de la primera línea: como el coche todavía no está encendido, el método corta con return antes de tocar velocidad. Este es justo el tipo de error que se te va a escapar cuando trabajes con objetos: llamar a un método antes de que el objeto esté en el estado que ese método espera. La solución no es acordarte de memoria del orden correcto, es que el propio método compruebe su precondición, como hace aquí acelerar().
Un ejemplo con más peso: sistema bancario
El coche te ha servido para ver la mecánica básica. Ahora vamos con algo donde la encapsulación importa de verdad, porque hay dinero de por medio: una cuenta bancaria, donde cada operación tiene reglas que cumplir. Crea CuentaBancaria.java:
public class CuentaBancaria {
// Nadie puede tocar el saldo directamente, solo a través de los métodos
private String numeroCuenta;
private String titular;
private double saldo;
private boolean activa;
public CuentaBancaria(String numeroCuenta, String titular) {
this.numeroCuenta = numeroCuenta;
this.titular = titular;
this.saldo = 0.0;
this.activa = true;
System.out.println("Cuenta creada para: " + titular);
}
public void depositar(double cantidad) {
if (!activa) {
System.out.println("❌ Cuenta inactiva");
return;
}
if (cantidad <= 0) {
System.out.println("❌ La cantidad debe ser positiva");
return;
}
saldo += cantidad;
System.out.println("Depósito de €" + cantidad + ". Nuevo saldo: €" + saldo);
}
public boolean retirar(double cantidad) {
if (!activa) {
System.out.println("❌ Cuenta inactiva");
return false;
}
if (cantidad <= 0) {
System.out.println("❌ La cantidad debe ser positiva");
return false;
}
if (cantidad > saldo) {
System.out.println("❌ Fondos insuficientes. Saldo actual: €" + saldo);
return false;
}
saldo -= cantidad;
System.out.println("Retiro de €" + cantidad + ". Nuevo saldo: €" + saldo);
return true;
}
// Reutiliza retirar() y depositar(): así la validación de saldo insuficiente
// se aplica también a la transferencia, sin duplicar la comprobación
public void transferir(CuentaBancaria destino, double cantidad) {
System.out.println("Transfiriendo €" + cantidad + " a " + destino.titular);
if (this.retirar(cantidad)) {
destino.depositar(cantidad);
System.out.println("Transferencia completada");
} else {
System.out.println("❌ Transferencia fallida");
}
}
public double getSaldo() { return saldo; }
public String getTitular() { return titular; }
public String getNumeroCuenta() { return numeroCuenta; }
public void mostrarInfo() {
System.out.println("=== CUENTA " + numeroCuenta + " ===");
System.out.println("Titular: " + titular);
System.out.println("Saldo: €" + saldo);
System.out.println("Estado: " + (activa ? "Activa" : "Inactiva"));
}
}
Fíjate en transferir(): no reescribe la lógica de comprobar fondos, llama a this.retirar(cantidad) y a destino.depositar(cantidad), los métodos que ya existían. Si retirar() falla porque no hay saldo suficiente, transferir() se entera por el false que devuelve y no llega a depositar nada en la cuenta destino. Es la misma idea de la encapsulación: la lógica de validar vive en un solo sitio, y todo lo demás confía en ella en vez de repetirla.
Actualiza Main.java para probarlo:
public class Main {
public static void main(String[] args) {
CuentaBancaria cuentaAna = new CuentaBancaria("12345", "Ana García");
CuentaBancaria cuentaLuis = new CuentaBancaria("67890", "Luis Martínez");
System.out.println("\n--- Ana deposita dinero ---");
cuentaAna.depositar(1000);
cuentaAna.depositar(500);
System.out.println("\n--- Luis también deposita ---");
cuentaLuis.depositar(800);
System.out.println("\n--- Ana intenta retirar ---");
cuentaAna.retirar(200);
cuentaAna.retirar(2000); // no tiene suficiente saldo
System.out.println("\n--- Transferencia entre cuentas ---");
cuentaAna.transferir(cuentaLuis, 300);
System.out.println("\n--- Estado final de las cuentas ---");
cuentaAna.mostrarInfo();
System.out.println();
cuentaLuis.mostrarInfo();
}
}
Herencia: tipos de cuenta especializados
Las cuentas de ahorro tienen intereses y un límite de retirada; las cuentas normales no. En vez de copiar toda la clase CuentaBancaria y cambiar un par de cosas, la herencia te deja partir de ella y añadir solo lo que es distinto. Crea CuentaAhorro.java:
public class CuentaAhorro extends CuentaBancaria {
private double tasaInteres;
public CuentaAhorro(String numeroCuenta, String titular, double tasaInteres) {
super(numeroCuenta, titular); // crea primero la parte de CuentaBancaria
this.tasaInteres = tasaInteres;
System.out.println("Cuenta de ahorro creada con " + (tasaInteres*100) + "% interés anual");
}
public void aplicarInteres() {
double interes = getSaldo() * tasaInteres;
depositar(interes);
System.out.println("Interés aplicado: €" + interes);
}
// Sobreescribe retirar() para añadir el límite, pero delega en el padre
@Override
public boolean retirar(double cantidad) {
if (cantidad > 500) {
System.out.println("⚠️ Las cuentas de ahorro no permiten retiros mayores a €500 por transacción");
return false;
}
return super.retirar(cantidad); // si pasa el límite, aplica la validación del padre
}
public double getTasaInteres() { return tasaInteres; }
}
@Override en retirar() no es decorativo: si escribes el nombre del método mal (por ejemplo retirrar), Java no lo trata como un error hasta que pones @Override encima, porque entonces sí comprueba que de verdad estés sobreescribiendo un método que existe en la clase padre. Sin esa anotación, tendrías un método nuevo con nombre distinto, y nadie lo llamaría nunca, sin que el compilador te avisara.
Polimorfismo: la misma llamada, comportamiento distinto
El polimorfismo es lo que pasa cuando llamas al mismo método sobre objetos de tipos distintos y cada uno responde a su manera, sin que quien hace la llamada tenga que saber de qué tipo exacto es cada objeto. Actualiza Main.java:
public class Main {
public static void main(String[] args) {
// Ambas variables son de tipo CuentaBancaria, aunque cuenta2 es en realidad una CuentaAhorro
CuentaBancaria cuenta1 = new CuentaBancaria("001", "Pedro");
CuentaBancaria cuenta2 = new CuentaAhorro("002", "María", 0.03);
CuentaBancaria[] cuentas = {cuenta1, cuenta2};
for (CuentaBancaria cuenta : cuentas) {
cuenta.depositar(1000);
}
System.out.println("\n--- Probando comportamiento diferente ---");
// Misma línea de código, misma llamada a retirar(), resultado distinto
cuenta1.retirar(600); // cuenta normal: sin límite especial
cuenta2.retirar(600); // cuenta de ahorro: topa con el límite de €500
System.out.println("\n--- Aplicando intereses (solo ahorro) ---");
// aplicarInteres() solo existe en CuentaAhorro, no en CuentaBancaria,
// así que hace falta comprobar el tipo antes de poder llamarlo
if (cuenta2 instanceof CuentaAhorro) {
CuentaAhorro cuentaAhorro = (CuentaAhorro) cuenta2;
cuentaAhorro.aplicarInteres();
}
System.out.println("\n--- Estado final ---");
for (CuentaBancaria cuenta : cuentas) {
cuenta.mostrarInfo();
System.out.println();
}
}
}
Ese instanceof más el cast para llamar a aplicarInteres() es la señal de que el diseño se puede pulir. Cuando tengas varias clases que comparten comportamiento pero cada una lo implementa a su manera, merece la pena mirar interfaces y clases abstractas, que evitan tener que comprobar el tipo a mano.
Proyecto final: Banco con menú interactivo
Con las clases anteriores ya puedes gestionar cuentas una a una, pero un banco de verdad necesita un sitio donde crearlas y consultarlas todas, con un menú por el que el usuario navegue. Crea Banco.java:
import java.util.ArrayList;
import java.util.Scanner;
public class Banco {
private String nombre;
private ArrayList<CuentaBancaria> cuentas;
private Scanner scanner;
public Banco(String nombre) {
this.nombre = nombre;
this.cuentas = new ArrayList<>();
this.scanner = new Scanner(System.in);
}
public void crearCuenta() {
System.out.println("\n=== CREAR NUEVA CUENTA ===");
System.out.print("Nombre del titular: ");
String titular = scanner.nextLine();
System.out.print("Número de cuenta: ");
String numeroCuenta = scanner.nextLine();
System.out.println("Tipo de cuenta:");
System.out.println("1. Cuenta Normal");
System.out.println("2. Cuenta de Ahorro");
System.out.print("Selecciona (1-2): ");
int tipo = scanner.nextInt();
scanner.nextLine(); // Limpiar buffer
CuentaBancaria nuevaCuenta;
if (tipo == 2) {
System.out.print("Tasa de interés (ej: 0.03 para 3%): ");
double tasa = scanner.nextDouble();
scanner.nextLine();
nuevaCuenta = new CuentaAhorro(numeroCuenta, titular, tasa);
} else {
nuevaCuenta = new CuentaBancaria(numeroCuenta, titular);
}
cuentas.add(nuevaCuenta);
System.out.println("Cuenta creada correctamente");
}
public void mostrarCuentas() {
if (cuentas.isEmpty()) {
System.out.println("❌ No hay cuentas registradas");
return;
}
System.out.println("\n=== TODAS LAS CUENTAS ===");
for (int i = 0; i < cuentas.size(); i++) {
System.out.print((i+1) + ". ");
cuentas.get(i).mostrarInfo();
System.out.println();
}
}
public void mostrarMenu() {
System.out.println("\n=== " + nombre + " ===");
System.out.println("1. Crear cuenta");
System.out.println("2. Ver todas las cuentas");
System.out.println("3. Salir");
System.out.print("Selecciona una opción: ");
}
public void ejecutar() {
while (true) {
mostrarMenu();
int opcion = scanner.nextInt();
scanner.nextLine();
switch (opcion) {
case 1:
crearCuenta();
break;
case 2:
mostrarCuentas();
break;
case 3:
System.out.println("¡Gracias por usar " + nombre + "!");
return;
default:
System.out.println("❌ Opción inválida");
}
}
}
public static void main(String[] args) {
Banco miBanco = new Banco("StudyCode Bank");
miBanco.ejecutar();
}
}
❌ Error común: print en vez de println al numerar la lista
Fíjate en que mostrarCuentas() usa System.out.print((i+1) + ". "), con print y no println. Si fuera println, el número quedaría en su propia línea y el === CUENTA ... === que imprime mostrarInfo() justo después aparecería suelto en la línea siguiente, sin conexión visual con el número. Con print (sin salto de línea), el "1. " y el "=== CUENTA 12345 ===" quedan en la misma línea, que es el resultado que quieres. Es un detalle fácil de pasar por alto, pero cambia completamente cómo se lee la salida.
Qué toca ahora
Ya tienes las herramientas para modelar casi cualquier cosa con clases: encapsulación para proteger datos, herencia para no repetir código entre clases parecidas, y polimorfismo para tratar objetos distintos de forma uniforme. Lo que se queda fuera de este tutorial, y verás en cuanto trabajes con proyectos algo más grandes, es qué hacer cuando distintas clases necesitan compartir un comportamiento sin tener relación de herencia entre ellas (piensa en el instanceof CuentaAhorro de antes, que tuvimos que usar porque aplicarInteres() solo existe en una subclase): ahí es donde entran las interfaces y las clases abstractas.
Si quieres seguir practicando mientras tanto, añade una tercera clase (Estudiante, Producto, lo que se te ocurra) y una jerarquía de herencia nueva, como Vehiculo con Coche, Moto y Camión heredando de ella. Cuando quieras dar el salto a bases de datos reales y crear una API con Spring Boot, tienes Spring Boot Tutorial: primera aplicación web profesional, o si prefieres reforzar antes las colecciones que usa este Banco (el ArrayList de cuentas), repasa Java Colecciones: ArrayList y HashMap explicados desde cero.