Java y Spring

Java y Spring: conceptos esenciales con ejemplos

Java y Spring reúnen conceptos del lenguaje, programación orientada a objetos, colecciones, APIs y persistencia. Esta guía organiza los términos más comunes y explica cómo se conectan al construir una aplicación.

Los fragmentos son ejemplos didácticos, no una aplicación completa. Algunos usan sintaxis moderna de Java, como record y Stream.toList(), que requiere Java 16 o posterior.

1. Tipos, clases y objetos

Tipos primitivos y wrappers

Los tipos primitivos guardan valores simples: byte, short, int, long, float, double, char y boolean. Sus clases wrapper, Byte, Short, Integer, Long, Float, Double, Character y Boolean, representan esos valores como objetos. Java convierte entre ambos automáticamente en muchos casos, proceso conocido como autoboxing y unboxing.

int poblacionEnMillones = 132;
Integer poblacionComoObjeto = poblacionEnMillones;
Long habitantes = 132_000_000L;

Un primitivo no puede ser null; su wrapper sí. Las colecciones genéricas almacenan objetos, por eso se usa List<Integer> y no List<int>. Al convertir un wrapper nulo a un primitivo puede producirse NullPointerException.

Clases, objetos y constructores

Una clase declara los datos y comportamientos que pueden tener sus instancias. Un objeto es una instancia concreta, creada normalmente con new. El constructor inicializa el estado y comparte el nombre de la clase, pero no declara tipo de retorno.

class Pais {
    private final String nombre;

    Pais(String nombre) {
        this.nombre = nombre;
    }

    String obtenerNombre() {
        return nombre;
    }
}

Pais mexico = new Pais("México");
Pais japon = new Pais("Japón");

Pais es la clase; mexico y japon hacen referencia a dos objetos distintos. this.nombre señala el atributo del objeto actual y permite distinguirlo del parámetro nombre. Si una clase no declara constructores, Java proporciona uno sin argumentos, pero deja de hacerlo cuando se declara cualquier constructor.

Alcance de las variables

El alcance, o scope, indica desde dónde puede utilizarse una variable. Una variable local vive dentro del bloque donde se declara; los atributos pertenecen a cada objeto o a la clase si son static.

if (true) {
    int cantidad = 3;
    System.out.println(cantidad);
}
// cantidad no existe fuera del bloque

Modificadores de acceso

public permite el acceso desde código que pueda ver el tipo. private restringe el miembro a su propia clase. protected permite el acceso dentro del paquete y desde subclases, sujeto a las reglas de Java. Sin modificador explícito se aplica acceso de paquete: solo se accede directamente desde clases del mismo paquete.

Conviene exponer la menor superficie posible. Los miembros private ayudan a encapsular el estado, mientras que los miembros públicos forman parte de la API que otras clases pueden utilizar.

Encapsulamiento, getters y setters

El encapsulamiento protege el estado interno y permite controlar cómo se consulta o modifica. Un getter devuelve un dato; un setter puede validar el nuevo valor. No todos los atributos necesitan ambos métodos: omitir un setter ayuda a mantener valores inmutables.

class Pais {
    private long habitantes;

    public long getHabitantes() {
        return habitantes;
    }

    public void setHabitantes(long habitantes) {
        if (habitantes < 0) {
            throw new IllegalArgumentException("La población no puede ser negativa");
        }
        this.habitantes = habitantes;
    }
}

getNombre() es una convención común para leer una propiedad; para valores booleanos también se usa isActivo(). Un JavaBean suele seguir estas convenciones y ofrecer un constructor público sin argumentos, propiedades privadas y getters o setters. JavaBean es una convención concreta, mientras que POJO describe en general un objeto Java que no depende de una clase base o interfaz especial de un framework.

2. Abstracción, composición y herencia

Interfaces y clases abstractas

Una interfaz expresa una capacidad o contrato. Una clase implementa ese contrato con implements y puede implementar varias interfaces. Una clase abstracta puede compartir estado, constructores y comportamiento implementado, además de declarar métodos que las subclases deben completar; no se puede instanciar directamente.

interface Exportable {
    void exportar();
}

abstract class Documento {
    abstract String obtenerTitulo();
}

class Informe extends Documento implements Exportable {
    @Override
    String obtenerTitulo() {
        return "Resumen";
    }

    @Override
    public void exportar() {
        System.out.println(obtenerTitulo());
    }
}

Como regla práctica, una interfaz representa qué puede hacer un objeto. Una clase abstracta es útil cuando varias subclases comparten una base de estado o implementación. Una clase solo puede extender directamente una clase, aunque puede implementar varias interfaces.

Herencia, composición y super

La herencia expresa una relación “es un”: una subclase extiende una clase y puede especializar sus miembros accesibles. La composición expresa “tiene un”: un objeto guarda o utiliza otros objetos como colaboradores. Es preferible no heredar únicamente para reutilizar unas pocas líneas.

class Capital {
    private final String nombre;

    Capital(String nombre) {
        this.nombre = nombre;
    }
}

class Pais {
    private final Capital capital;

    Pais(Capital capital) {
        this.capital = capital;
    }
}

super permite invocar el constructor o comportamiento de la clase padre. Una llamada super(...) desde un constructor debe ser su primera instrucción explícita.

Métodos, funciones y polimorfismo

En Java, una operación normalmente se declara como método dentro de una clase. Un método puede recibir parámetros y devolver un resultado; void indica que no devuelve uno. Un método static pertenece a la clase y puede llamarse sin crear una instancia. Por eso, cuando se habla de una “función” en Java, a menudo se alude a un método o a una operación representada por una interfaz funcional.

El polimorfismo permite usar objetos de distintas clases mediante un tipo común. La implementación invocada depende del objeto real:

interface Saludable {
    void saludar();
}

class Mexico implements Saludable {
    @Override
    public void saludar() {
        System.out.println("Hola");
    }
}

Saludable pais = new Mexico();
pais.saludar();

Sobrecarga y sobreescritura

La sobrecarga (overloading) declara métodos con el mismo nombre y parámetros distintos en cantidad, tipo u orden. Cambiar solo el tipo de retorno no basta. La sobreescritura (overriding) reemplaza en una subclase la implementación heredada con una firma compatible; @Override permite que el compilador compruebe la intención.

void buscar(String nombre) { }
void buscar(String nombre, String continente) { } // sobrecarga

class Pais {
    void mostrar() { }
}

class PaisInsular extends Pais {
    @Override
    void mostrar() { } // sobreescritura
}

La sobrecarga ofrece distintas formas de invocar una operación y se resuelve según los argumentos. La sobreescritura participa en el despacho dinámico que hace posible el polimorfismo.

Operadores y cadenas

El operador % devuelve el resto de una división y puede ayudar a comprobar divisibilidad, por ejemplo numero % 2 == 0 para saber si un entero es par. valor++ (postincremento) produce primero el valor anterior y después suma uno; ++valor (preincremento) suma primero y produce el valor nuevo.

String representa texto y es inmutable: sus operaciones producen otra cadena, no cambian la original. equals() compara contenido en cadenas, mientras que == compara valores primitivos o identidad de referencias. length() cuenta unidades UTF-16, que no siempre equivalen a caracteres visibles.

String pais = "Brasil";
int longitud = pais.length();
char primeraLetra = pais.charAt(0);
boolean contiene = pais.contains("sil");
boolean mismoTexto = pais.equals("Brasil");

La sintaxis de longitud depende del tipo: String.length() es un método, los arreglos usan el atributo .length y las colecciones utilizan size(). charAt() usa índices desde cero y lanza StringIndexOutOfBoundsException si el índice no existe. contains() busca una secuencia en una cadena o un elemento en una colección; la búsqueda en String distingue mayúsculas de minúsculas.

3. Arreglos, colecciones y Streams

Arreglos y colecciones

Un arreglo almacena elementos del mismo tipo en posiciones numeradas desde cero y tiene tamaño fijo. ArrayList implementa List con tamaño modificable, conserva el orden de inserción y admite duplicados.

String[] iniciales = {"México", "Japón"};
List<String> paises = new ArrayList<>();
paises.add("México");
paises.add("Japón");

Long[] es un arreglo de referencias a objetos Long, por lo que sus posiciones pueden ser null; long[] almacena primitivos y no admite valores nulos. Ambos arreglos tienen tamaño fijo.

Collection es la interfaz común de List, Set y Queue. Map forma parte del framework de colecciones, pero no extiende Collection: organiza claves únicas asociadas a valores.

  • List conserva una secuencia y permite duplicados.
  • Set evita duplicados según las reglas de igualdad de su implementación. HashSet no garantiza orden; LinkedHashSet conserva el orden de inserción y TreeSet mantiene un orden definido.
  • Queue representa una cola. offer() agrega, poll() recupera y elimina el siguiente elemento, y peek() lo consulta sin eliminarlo. La mayoría de las colas siguen FIFO; PriorityQueue organiza por prioridad.
  • Map<K, V> relaciona claves con valores. put() agrega o reemplaza, get() consulta y remove() elimina una entrada.
Set<String> unicos = new HashSet<>();
unicos.add("México");
unicos.add("México");

Queue<String> cola = new LinkedList<>();
cola.offer("Japón");
String siguiente = cola.poll();

Map<String, Long> poblaciones = new HashMap<>();
poblaciones.put("México", 132_000_000L);
Long habitantes = poblaciones.get("México");

Los genéricos describen los tipos que puede contener una estructura. Un parámetro como Map<String, ?> acepta mapas con valores de tipos distintos; al leerlos se reciben como Object. No es seguro agregar un valor arbitrario porque el tipo real del mapa es desconocido, aunque sí puede agregarse null.

Streams y expresiones lambda

Un Stream procesa una secuencia sin ser, por sí mismo, una colección que almacene los elementos. Las operaciones intermedias se pueden encadenar: filter() conserva los elementos que cumplen una condición, map() transforma cada elemento y sorted() ordena. distinct() elimina repetidos según equals(), y limit(n) conserva como máximo los primeros n elementos.

List<String> paisesLargos = paises.stream()
    .filter(nombre -> nombre.length() > 5)
    .map(String::toUpperCase)
    .distinct()
    .sorted()
    .limit(10)
    .toList();

La expresión nombre -> ... es una lambda. Su tipo se deduce de la interfaz funcional esperada, como Predicate para una condición o Function para una transformación. El método map() de Stream transforma elementos y no debe confundirse con la estructura Map de clave y valor.

Las operaciones intermedias suelen evaluarse de manera perezosa: el procesamiento comienza cuando una operación terminal como count() o toList() solicita el resultado. Un Stream normalmente se consume una sola vez. Las operaciones no modifican necesariamente la colección de origen.

4. Spring, persistencia y APIs

Inversión de control e inyección de dependencias

La inyección de dependencias (DI) consiste en recibir los colaboradores que una clase necesita, en lugar de construirlos directamente con new. La inversión de control (IoC) describe que el contenedor de Spring administra la creación y el ciclo de vida de esos objetos, llamados beans.

@Service
class PaisService {
    private final PaisRepository repository;

    PaisService(PaisRepository repository) {
        this.repository = repository;
    }
}

La inyección por constructor hace explícitas las dependencias obligatorias y facilita las pruebas. Con un único constructor, Spring no requiere @Autowired para inyectarlo.

Componentes de Spring y Spring Boot

@Component registra una clase detectada por el escaneo como bean. @Service señala lógica de negocio, @Repository acceso a datos y @Controller atención de solicitudes web. Son anotaciones especializadas de componente que expresan la función de la clase.

@Configuration identifica una clase de configuración. Sus métodos @Bean pueden crear objetos para que el contenedor los administre. Spring Boot facilita la configuración con autoconfiguración, dependencias starter y servidores integrados.

@Configuration
class Configuracion {
    @Bean
    Reloj reloj() {
        return new Reloj();
    }
}

@SpringBootApplication
public class Aplicacion {
    public static void main(String[] args) {
        SpringApplication.run(Aplicacion.class, args);
    }
}

@SpringBootApplication combina configuración, autoconfiguración y escaneo de componentes desde el paquete de la aplicación y sus subpaquetes. Boot reduce configuración repetitiva, pero no sustituye el diseño de la aplicación.

Entidades, DTO y capas

Una entidad @Entity representa datos persistidos mediante Jakarta Persistence (JPA) y necesita una clave primaria @Id. Un DTO (Data Transfer Object) transporta solo los datos que una capa o API debe exponer. Separar el DTO de la entidad evita devolver directamente el modelo de persistencia.

@Entity
class PaisEntity {
    @Id
    private Long id;
    private String nombre;

    protected PaisEntity() { }
}

record PaisDTO(String nombre) { }

PaisDTO convertir(PaisEntity entidad) {
    return new PaisDTO(entidad.getNombre());
}

El mapper convierte una entidad a DTO o viceversa, copiando y transformando los campos necesarios. Puede escribirse a mano o generarse con herramientas especializadas.

Una arquitectura de capas separa responsabilidades: el Controller recibe la solicitud, el Service aplica las reglas de negocio y el Repository consulta o modifica datos. Las entidades representan persistencia y los DTO organizan la entrada y salida de la API.

Spring Data y operaciones de persistencia

Una interfaz de repositorio puede extender JpaRepository para obtener operaciones comunes como findAll(), findById(), save() y deleteById(). Spring Data también deriva consultas del nombre de los métodos, por ejemplo findByNombre(String nombre), si nombre es una propiedad de la entidad.

save() solicita persistir una entidad nueva o actualizar una existente, según cómo se detecte su estado. No significa que siempre se ejecute un UPDATE: el proveedor JPA decide si corresponde insertar o actualizar, y el SQL puede ejecutarse al sincronizar o confirmar la transacción. deleteById(id) solicita eliminar el registro; la aplicación debe considerar si existe y si hay restricciones o relaciones asociadas.

Transacciones

@Transactional define un límite transaccional para una operación. La transacción agrupa operaciones de base de datos para confirmarlas juntas o revertirlas según las reglas configuradas.

@Service
class PaisService {
    private final PaisRepository repository;

    PaisService(PaisRepository repository) {
        this.repository = repository;
    }

    @Transactional
    public void guardar(PaisEntity pais) {
        repository.save(pais);
    }
}

Spring administra la transacción cuando la llamada pasa por el proxy correspondiente y existe un gestor transaccional compatible. Por defecto, las excepciones no comprobadas y Error provocan rollback; las excepciones comprobadas no lo hacen a menos que se configure, por ejemplo, rollbackFor.

Controladores y métodos HTTP

@RestController combina @Controller y @ResponseBody: los valores devueltos se escriben en el cuerpo de la respuesta, normalmente como JSON. @Controller suele utilizarse con vistas; para escribir directamente en el cuerpo se añade @ResponseBody.

Las anotaciones de mapeo conectan métodos Java con rutas y verbos HTTP:

@RestController
@RequestMapping("/paises")
class PaisController {
    private final PaisService service;

    PaisController(PaisService service) {
        this.service = service;
    }

    @GetMapping("/{id}")
    PaisDTO obtener(@PathVariable Long id) {
        return service.obtener(id);
    }

    @PostMapping
    PaisDTO crear(@RequestBody PaisDTO datos) {
        return service.crear(datos);
    }

    @PutMapping("/{id}")
    PaisDTO actualizar(@PathVariable Long id, @RequestBody PaisDTO datos) {
        return service.actualizar(id, datos);
    }

    @DeleteMapping("/{id}")
    void eliminar(@PathVariable Long id) {
        service.eliminar(id);
    }
}

@GetMapping atiende consultas GET, @PostMapping solicitudes POST, @PutMapping solicitudes PUT y @DeleteMapping solicitudes DELETE. El contrato de la API determina la semántica exacta, en particular si PUT reemplaza o actualiza parcialmente un recurso.

GET y SELECT pertenecen a capas diferentes: GET es un método HTTP que solicita un recurso; SELECT es una instrucción SQL que consulta filas. Un controlador puede atender GET y un repositorio ejecutar un SELECT para construir la respuesta.

Spring Batch y microservicios

Spring Batch ejecuta trabajos que procesan datos en lotes. Un Job representa el proceso completo y se compone de uno o más Step. Un Step puede ejecutar una tarea simple (Tasklet) o procesar elementos en bloques mediante un reader, processor y writer.

JobLauncher inicia un Job con parámetros. JobRepository guarda metadatos de instancias y ejecuciones, parámetros, estados y avances; no es el repositorio de las entidades de negocio. La persistencia de metadatos permite dar seguimiento y, según la configuración, reanudar trabajos.

Los microservicios separan capacidades de negocio en servicios autónomos que se comunican mediante APIs o mensajería y pueden desplegarse por separado. Separar clases no basta para tener microservicios: también se necesitan contratos, despliegue, seguridad, observabilidad y tolerancia a fallos apropiados.

5. Concurrencia

Un hilo (thread) es una secuencia de ejecución dentro de un proceso. Runnable describe una tarea sin argumentos ni valor de retorno; Thread puede ejecutarla concurrentemente. Llamar a run() directamente no inicia otro hilo: para empezar su ejecución concurrente se llama a start().

Runnable tarea = () -> System.out.println("Procesando países");
Thread hilo = new Thread(tarea);
hilo.start();

Una lambda implementa el método abstracto de una interfaz funcional. Puede escribirse como (parametros) -> expresion o con un bloque. La lambda describe una acción, pero no crea por sí sola un hilo. Si varios hilos comparten datos mutables, hay que considerar condiciones de carrera y mecanismos de coordinación; para administrar grupos de tareas suele preferirse un ejecutor en vez de crear hilos manualmente.

Cómo conectar las piezas

En una aplicación típica, una solicitud HTTP llega a un controller, que delega en un service. El service recibe un repository mediante inyección de dependencias, ejecuta reglas de negocio y puede delimitar la operación con una transacción. El repository trabaja con entidades; el controller devuelve DTO para mantener separado el contrato de la API.

Las interfaces, el encapsulamiento y la composición reducen el acoplamiento entre esas capas. Las colecciones y Streams ayudan a transformar datos en memoria, mientras que Spring Batch organiza trabajos de procesamiento más largos. Elegir cada herramienta según su responsabilidad hace que el código sea más fácil de probar y mantener.

© Arturo Juárez Monroy