En el desarrollo de software, el Diseño Orientado a Objetos es realmente importante para crear código que pueda cambiarse, expandirse y volver a utilizarse fácilmente.

Los principios SOLID son un conjunto de cinco principios de diseño en programación orientada a objetos y desarrollo de software que tienen como objetivo crear software más mantenible, flexible y escalable. Fueron introducidas por Robert C. Martin y se utilizan ampliamente como directrices para diseñar código limpio y eficiente. Cada letra de la palabra "SOLID" representa uno de estos principios:

  • S - Principio de responsabilidad única (Single Responsibility Principle)
  • O - Principio de abierto/cerrado (Open/Closed Principle)
  • L - Principio de sustitución de Liskov (Liskov Substitution Principle)
  • I - Principio de segregación de interfaces (Interface Segregation Principle)
  • D - Principio de inversión de dependencias (Dependency Inversion Principle)

En este artículo, examinaremos cómo se usa cada principio en la aplicación Spring Boot:

  1. Principio de responsabilidad única (SRP)

Robert C. Martin lo describe:

Una clase debe tener una, y solo una, razón para cambiar.

El principio de responsabilidad única tiene dos principios clave, como su nombre indica.

Principio de responsabilidad única

Examinemos el uso incorrecto en el siguiente ejemplo.

// Incorrecta implementación de SRP
@RestController
@RequestMapping("/report")
public class ReportController {

    private final ReportService reportService;


    public ReportController(ReportService reportService) {
        this.reportService = reportService;
    }

    @PostMapping("/send")
    public ResponseEntity < Report > generateAndSendReport(@RequestParam String reportContent,
        @RequestParam String to,
        @RequestParam String subject) {
        String report = reportService.generateReport(reportContent);
        reportService.sendReportByEmail(report, to, subject);
        return new ResponseEntity < > (HttpStatus.OK);
    }
}
// Incorrecta implementación de SRP
// The class is responsible for generating a report and sending email
@Service
@Slf4j
public class ReportServiceImpl implements ReportService {

    private final ReportRepository reportRepository;

    public ReportServiceImpl(ReportRepository reportRepository) {
        this.reportRepository = reportRepository;
    }

    @Override
    public String generateReport(String reportContent) {
        Report report = new Report();
        report.setReportContent(reportContent);
        return reportRepository.save(report).toString();
    }

    @Override
    public void sendReportByEmail(Long reportId, String to, String subject) {
        Report report = findReportById(reportId);
        sendEmail(report.getReportContent(), to, subject);
    }

    private Report findReportById(Long reportId) {
        return reportRepository.findById(reportId)
            .orElseThrow(() - > new RuntimeException("Report not found"));
    }

    private void sendEmail(String content, String to, String subject) {
        log.info(content, to, subject);
    }

Como puede ver, ReportService tiene varias responsabilidades, lo que viola la responsabilidad única:

  • Generar informe: La clase es responsable de generar un informe y guardarlo en el repositorio del métodogenerateReport
  • Enviar informe por correo electrónico: La clase también es responsable de enviar un informe por correo electrónico en el método.sendReportByEmail

Al crear el código, debe evitar colocar demasiadas tareas en un solo lugar, ya sea una clase o un método.

Esto hace que el código sea complejo y difícil de manejar. También dificulta la realización de pequeños cambios porque pueden afectar a otras partes del código, lo que requiere probar todo, incluso en busca de actualizaciones menores.

Vamos a corregir esta implementación;

Para adherirse al SRP, estas responsabilidades se separaron en diferentes clases.

// Correcta implementación de SRP
@RestController
@RequestMapping("/report")
public class ReportController {

    private final ReportService reportService;
    private final EmailService emailService;

    public ReportController(ReportService reportService, EmailService emailService) {
        this.reportService = reportService;
        this.emailService = emailService;
    }

    @PostMapping("/send")
    public ResponseEntity < Report > generateAndSendReport(@RequestParam String reportContent,
        @RequestParam String to,
        @RequestParam String subject) {
        // correct impl reportService is responsible for generation
        Long reportId = Long.valueOf(reportService.generateReport(reportContent));
        // correct impl emailService is responsible for sending
        emailService.sendReportByEmail(reportId, to, subject);
        return new ResponseEntity < > (HttpStatus.OK);
    }
}
// Correcta implementación de SRP
@Service
public class ReportServiceImpl implements ReportService {

    private final ReportRepository reportRepository;


    public ReportServiceImpl(ReportRepository reportRepository, EmailService emailService) {
        this.reportRepository = reportRepository;
    }

    @Override
    public String generateReport(String reportContent) {
        Report report = new Report();
        report.setReportContent(reportContent);
        return reportRepository.save(report).toString();
    }


@Service
public class EmailServiceImpl implements EmailService {

    private final ReportRepository reportRepository;

    public EmailServiceImpl(ReportRepository reportRepository) {
        this.reportRepository = reportRepository;
    }

    @Override
    public void sendReportByEmail(Long reportId, String to, String subject) {
        Report report = findReportById(reportId);
        if (ObjectUtils.isEmpty(report) || !StringUtils.hasLength(report.getReportContent())) {
            throw new RuntimeException("Report or report content is empty");
        }
    }

    private Report findReportById(Long reportId) {
        return reportRepository.findById(reportId)
                .orElseThrow(() -> new RuntimeException("Report not found"));
    }

}

El código refactorizado incluye los siguientes cambios;

  • ReportServiceImpl es responsable de generar informes.
  • EmailServiceImpl es responsable del envío de informes que fueron generados por correo electrónico.
  • ReportController gestiona el proceso de generación y envío de informes mediante el uso de los servicios adecuados.

2. Principio de apertura/cierre (OCP)

El Principio de Apertura-Cierre dice que una clase debe estar abierta a la extensión y cerrada a la modificación. Esto ayuda a evitar la introducción de errores en una aplicación que funciona. En términos más simples, esto significa que debería poder agregar nueva funcionalidad a una clase sin cambiar su código existente.

Principio de apertura/cierre (OCP)

Examinemos el uso incorrecto en el siguiente ejemplo.

// Incorrecta implementación OCP
public class ReportGeneratorService {
    public String generateReport(Report report) {
        if ("PDF".equals(report.getReportType())) {
            // Incorrect: Direct implementation for generating PDF report
            return "PDF report generated";
        } else if ("Excel".equals(report.getReportType())) {
            // Incorrect: Direct implementation for generating Excel report
            return "Excel report generated";
        } else {
            return "Unsupported report type";
        }
    }
}

En esta implementación incorrecta, el método generateReport de tiene instrucciones condicionales para comprobar el tipo de informe y genera directamente el informe en consecuencia. Esto infringe el principio de apertura-cierre porque si desea agregar compatibilidad con un nuevo tipo de informe, tendría que modificar la claseReportService.

Vamos a corregir esta implementación;

public interface ReportGenerator {
    String generateReport(Report report);
}

// Concrete implementation for generating PDF reports
@Component
public class PdfReportGenerator implements ReportGenerator {
    @Override
    public String generateReport(Report report) {
        // Impl of pdf report
        return String.format("PDF report generated for %s", report.getReportType());
    }
}

// Concrete implementation for generating Excel reports
@Component
public class ExcelReportGenerator implements ReportGenerator {
    @Override
    public String generateReport(Report report) {
        // Impl of excel report
        return String.format("Excel report generated for %s", report.getReportType());
    }
}

// Service that follows OCP
@Service
public class ReportGeneratorService {

    private final Map < String, ReportGenerator > reportGenerators;

    @Autowired
    public ReportGeneratorService(List < ReportGenerator > generators) {
        // Initialize the map of report generators
        this.reportGenerators = generators.stream()
            .collect(Collectors.toMap(generator - > generator.getClass().getSimpleName(), Function.identity()));
    }

    public String generateReport(Report report, String reportType) {
        return reportGenerators.getOrDefault(reportType, unsupportedReportGenerator())
            .generateReport(report);
    }

    private ReportGenerator unsupportedReportGenerator() {
        return report - > "Unsupported report type";
    }
}

Interfaz ->ReportGenerator

  • Se agregó la interfaz ReportGenerator(para definir un método común para la generación de informes).

Implementaciones concretas -> PdfReportGeneratoryExcelReportGenerator

  • Creación de clases implementando la interfaz para la generación de informes en PDF y Excel.
  • El principio de Abierto-Cerrado permite la extensión sin modificar el código existente.

Servicio de generador de informes -> ReportGeneratorService

  • Se introdujo un servicio que administra diferentes implementaciones de generadores de informes.
  • Permite agregar nuevos generadores de informes sin cambiar el código existente.

En resumen, el servicio gestiona estas implementaciones de forma dinámica, lo que facilita la adición de nuevas características sin cambiar el código existente, siguiendo el principio de apertura-cierre.

3. Principio de sustitución de Liskov (LSP)

El Principio de Sustitución de Liskov establece que si tienes una clase, deberías ser capaz de reemplazarla con una subclase sin causar ningún problema en tu programa.

En otras palabras, puede usar la versión especializada donde sea que use la versión más general, y todo debería seguir funcionando correctamente.

Examinemos el uso incorrecto en el siguiente ejemplo.

// Incorrect implementation violating LSP
public class Bird {
    public void fly() {
        // I can fly
    }

    public void swim() {
        // I can swim
    }
}

public class Penguin extends Bird {

    // Penguins cannot fly, but we override the fly method and throws Exception
    @Override
    public void fly() {
        throw new UnsupportedOperationException("Penguins cannot fly");
    }
}

Vamos a corregir esta implementación;

// Correct implementation for LSP
public class Bird {

    // methods
}

public interface Flyable {
    void fly();
}

public interface Swimmable {
    void swim();
}


public class Penguin extends Bird implements Swimmable {
    // Penguins cannot fly, therefore we only implement swim interface
    @Override
    public void swim() {
        System.out.println("I can swim");
    }
}

public class Eagle extends Bird implements Flyable {
    @Override
    public void fly() {
        System.out.println("I can fly");
    }
}
  • la claseBirdsirve como clase base para las aves e incluye propiedades o métodos comunes compartidos entre todas las aves.
  • las interfaces Flyable ySwimmableson para representar comportamientos específicos.
  • En la clase Penguin, se implementó la interfaz Swimmable para reflejar la capacidad de natación de los pingüinos.
  • En la clase Eagle, implementó la interfaz Flyable para reflejar la capacidad de vuelo de las águilas.

Al separar comportamientos específicos en interfaces e implementarlos en subclases, seguimos el Principio de Sustitución de Liskov que nos permite cambiar de subclase sin causar ningún problema sorprendente.

4. Principio de segregación de interfaz (ISP)

El principio de segregación de interfaces establece que las interfaces más grandes deben dividirse en otras más pequeñas.

Al hacerlo, podemos asegurarnos de que las clases de implementación solo tengan que preocuparse por los métodos que les interesan.

Principio de segregación de interfaz (ISP)

Examinemos el uso incorrecto en el siguiente ejemplo.

public interface Athlete {

    void compete();

    void swim();

    void highJump();

    void longJump();
}

// Incorrect implementation violating Interface Segregation
public class JohnDoe implements Athlete {
    @Override
    public void compete() {
        System.out.println("John Doe started competing");
    }

    @Override
    public void swim() {
        System.out.println("John Doe started swimming");
    }

    @Override
    public void highJump() {
        // Not neccessary for John Doe
    }

    @Override
    public void longJump() {
        // Not neccessary for John Doe
    }
}

Imagínate a John Doe como nadador. Se ve obligado a proporcionar implementaciones vacías para  highJumpylongJump, que son irrelevantes para su papel como nadador.

Vamos a corregir esta implementación;

public interface Athlete {

    void compete();
}

public interface JumpingAthlete {

    void highJump();

    void longJump();
}

public interface SwimmingAthlete {

    void swim();
}

// Correct implementation for Interface Segregation
public class JohnDoe implements Athlete, SwimmingAthlete {
    @Override
    public void compete() {
        System.out.println("John Doe started competing");
    }

    @Override
    public void swim() {
        System.out.println("John Doe started swimming");
    }
}

La interfaz original se ha dividido en tres interfaces separadas: para actividades generales, para actividades relacionadas con el salto y para nadar.

Esto se adhiere al principio de segregación de interfaz, lo que garantiza que una clase no se vea obligada a implementar métodos que no necesita.

Obtuve el ejemplo de la publicación escrita por Emmanouil Gkatziouras

5. Principio de inversión de dependencias (DIP)

El Principio de Inversión de Dependencias (DIP) establece que los módulos de alto nivel no deben depender de los módulos de bajo nivel; Ambos deben depender de abstracciones. Las abstracciones no deben depender de los detalles.

Principio de inversión de dependencias (DIP)

Examinemos el uso incorrecto en el siguiente ejemplo.

// Incorrect impl of Dependency Inversion Principle
@Service
public class PayPalPaymentService {

    public void processPayment(Order order) {
        // payment processing logic
    }
}

@RestController
public class PaymentController {

    // Direct dependency on a specific implementation
    private final PayPalPaymentService paymentService;

    // Constructor directly initializes a specific implementation
    public PaymentController() {
        this.paymentService = new PayPalPaymentService();
    }

    @PostMapping("/pay")
    public void pay(@RequestBody Order order) {
        paymentService.processPayment(order);
    }
}

Vamos a corregir esta implementación;

// Introduced interface
public interface PaymentService {
    void processPayment(Order order);
}

// Implemented interface in a service class
@Service
public class PayPalPaymentService implements PaymentService {
    @Override
    public void processPayment(Order order) {
        // payment processing logic
    }
}

@RestController
public class PaymentController {

    private final PaymentService paymentService;

    // Constructor injection
    public PaymentController(PaymentService paymentService) {
        this.paymentService = paymentService;
    }

    @PostMapping("/pay")
    public void pay(@RequestBody Order order) {
        paymentService.processPayment(order);
    }
}
  • Se introdujo la interfazPaymentService
  • Se inyectó la interfaz PaymentService en el constructor del controlador para proporcionar abstracción en el controlador.
  • El controlador depende de la abstracción lo que permite la inyección de dependencias de cualquier clase que implemente la interfazPaymentService

El Principio de Inversión de Dependencias (DIP) y la Inyección de Dependencias (DI) son conceptos conectados en Spring Framework. DIP, introducido por el tío Bob Martin, se trata de mantener el código vagamente conectado. Separa el código para la inyección de dependencias en Spring, donde el framework administra la aplicación durante el tiempo de ejecución.

Conclusión

Los principios SOLID son esenciales en la programación orientada a objetos (POO) porque proporcionan un conjunto de directrices y mejores prácticas para diseñar software que sea más fácil de mantener, flexible y escalable.

En este artículo, comenzamos discutiendo los errores en la aplicación de los principios SOLID en las aplicaciones Java. Después de eso, examinamos el ejemplo relacionado para ver cómo se solucionaron estos problemas.

Todos los ejemplos se presentan en un nivel básico, puede consultar las referencias proporcionadas para obtener más información.

Referencias

https://www.freecodecamp.org/news/solid-principles-explained-in-plain-english/

https://www.baeldung.com/solid-principles

https://www.linkedin.com/pulse/solid-principles-software-development-java-ramiro-victor/

https://amrtechuniverse.com/solid-principles-in-spring-boot

https://dzone.com/articles/solid-principles-interface-segregation-principle

https://trekhleb.medium.com/solid-principles-around-you-6db2f0e12139

https://springframework.guru/principles-of-object-oriented-design/dependency-inversion-principle/

Compartir: