BackDev Patterns & Practices
Solid Principles//

Principio de Responsabilidad Única

Explica el Principio de Responsabilidad Única con ejemplos que dividen responsabilidades en unidades enfocadas y testeables para un código más claro y mantenible.

Introducción

El Principio de Responsabilidad Única (SRP) establece: una clase debe tener una, y solo una, razón para cambiar. En otras palabras, cada módulo o clase debe ser responsable de una sola parte de la funcionalidad proporcionada por el software, y esa responsabilidad debe estar completamente encapsulada por la clase.

SRP es el primero de los principios SOLID y a menudo el más mal entendido. No significa que una clase deba tener un solo método. Significa que todos los métodos y propiedades de la clase deben estar alineados con un solo propósito o preocupación. Cuando una clase hace demasiado, se convierte en un imán para los cambios, cada nuevo requerimiento toca la misma clase, incrementando el riesgo de bugs.

En este artículo, exploramos SRP a través de un escenario del mundo real: construir un sistema de gestión de Comunidades de Práctica. Verás cómo la arquitectura de Gathering divide las responsabilidades de forma limpia, y qué sucede cuando las mezclas.

El Problema: Clases Que Hacen Demasiado

Un Ejemplo Simple: Servicio de Reportes

Antes de sumergirnos en el código de Gathering, veamos una violación simple de SRP: un servicio de reportes que maneja generación de datos, formateo y envío de correos en una sola clase.

Sin SRP: Una Clase con Múltiples Responsabilidades

1public class ReportService
2{
3    // Responsabilidad 1: Obtener datos
4    public DataTable GetSalesData(DateTime from, DateTime to)
5    {
6        using var connection = new SqlConnection("...");
7        var query = "SELECT * FROM Sales WHERE Date BETWEEN @from AND @to";
8        // ...
9    }
10
11    // Responsabilidad 2: Formatear el reporte
12    public string FormatAsHtml(DataTable data)
13    {
14        var html = "<html><body><table>";
15        foreach (DataRow row in data.Rows)
16        {
17            html += "<tr>";
18            foreach (var item in row.ItemArray)
19                html += $"<td>{item}</td>";
20            html += "</tr>";
21        }
22        return html + "</table></body></html>";
23    }
24
25    // Responsabilidad 3: Enviar por correo
26    public void SendReport(string htmlContent, string recipient)
27    {
28        var smtpClient = new SmtpClient("smtp.company.com");
29        var message = new MailMessage("reports@company.com", recipient)
30        {
31            Body = htmlContent,
32            IsBodyHtml = true
33        };
34        smtpClient.Send(message);
35    }
36
37    // Responsabilidad 4: Orquestar todo
38    public void GenerateAndSendReport(
39        DateTime from, DateTime to, string recipient)
40    {
41        var data = GetSalesData(from, to);
42        var html = FormatAsHtml(data);
43        SendReport(html, recipient);
44    }
45}

Problemas con esta Clase:

  • Acceso a datos: Cambiar de SQL Server a PostgreSQL requiere modificar ReportService.
  • Formato: Agregar formato PDF requiere modificar ReportService.
  • Envío: Cambiar de SMTP a SendGrid requiere modificar ReportService.
  • Testing: No se puede probar el formato sin una base de datos real.

Con SRP: Responsabilidades Separadas

1// Cada clase tiene UNA razón para cambiar
2
3public interface ISalesDataProvider
4{
5    DataTable GetSalesData(DateTime from, DateTime to);
6}
7
8public interface IReportFormatter
9{
10    string Format(DataTable data);
11}
12
13public interface IReportSender
14{
15    void Send(string content, string recipient);
16}
17
18// La clase de orquestación solo coordina
19public class ReportService
20{
21    private readonly ISalesDataProvider _dataProvider;
22    private readonly IReportFormatter _formatter;
23    private readonly IReportSender _sender;
24
25    public ReportService(
26        ISalesDataProvider dataProvider,
27        IReportFormatter formatter,
28        IReportSender sender)
29    {
30        _dataProvider = dataProvider;
31        _formatter = formatter;
32        _sender = sender;
33    }
34
35    public void GenerateAndSendReport(
36        DateTime from, DateTime to, string recipient)
37    {
38        var data = _dataProvider.GetSalesData(from, to);
39        var content = _formatter.Format(data);
40        _sender.Send(content, recipient);
41    }
42}

Beneficios de SRP Aquí:

  • ✓ Testeable: Cada componente se puede probar de forma aislada con mocks.
  • ✓ Intercambiable: Cambiar el formato de HTML a PDF solo requiere una nueva implementación de IReportFormatter.
  • ✓ Enfocado: Cada clase tiene una sola razón para cambiar.

Caso de Estudio: Separación de Responsabilidades en Gathering

Gathering, nuestro sistema de gestión de Comunidades de Práctica, demuestra SRP en cada capa de la arquitectura. Cada clase tiene un propósito claro y una sola razón para cambiar.

La Entidad de Dominio: Session

La clase Session es responsable exclusivamente de la lógica de negocio de una sesión: sus reglas de validación, transiciones de estado y eventos de dominio.

1// De: Gathering.Domain/Sessions/Session.cs
2// Responsabilidad ÚNICA: lógica de negocio de una sesión
3public sealed partial class Session : AuditableEntity
4{
5    public Guid Id { get; private set; }
6    public Guid CommunityId { get; private set; }
7    public string Title { get; private set; } = string.Empty;
8    public string Speaker { get; private set; } = string.Empty;
9    public DateTimeOffset ScheduledAt { get; private set; }
10    public SessionStatus Status { get; private set; }
11
12    public static Result<Session> Create(
13        Guid communityId, string title, string speaker,
14        DateTimeOffset scheduledAt, string? description = null,
15        string? image = null)
16    {
17        if (string.IsNullOrWhiteSpace(title))
18            return Result.Failure<Session>(SessionError.TitleRequired);
19
20        if (scheduledAt <= DateTimeOffset.UtcNow)
21            return Result.Failure<Session>(SessionError.ScheduledInPast);
22
23        var session = new Session
24        {
25            Id = Guid.NewGuid(),
26            CommunityId = communityId,
27            Title = title,
28            Speaker = speaker,
29            ScheduledAt = scheduledAt,
30            Status = SessionStatus.Scheduled
31        };
32
33        session.Raise(new SessionCreatedDomainEvent(session.Id));
34        return Result.Success(session);
35    }
36
37    public Result UpdateStatus(SessionStatus newStatus)
38    {
39        if (Status == SessionStatus.Canceled && 
40            newStatus == SessionStatus.Scheduled)
41            return Result.Failure(SessionError.InvalidStatusTransition);
42
43        if (Status == SessionStatus.Completed && 
44            newStatus == SessionStatus.Scheduled)
45            return Result.Failure(SessionError.InvalidStatusTransition);
46
47        Status = newStatus;
48        return Result.Success();
49    }
50}

El Command Handler: Una Sola Operación

1// De: Gathering.Application/Sessions/Create/CreateSessionCommandHandler.cs
2// Responsabilidad ÚNICA: orquestar la creación de una sesión
3public sealed class CreateSessionCommandHandler 
4    : ICommandHandler<CreateSessionCommand, Guid>
5{
6    private readonly ISessionRepository _sessionRepository;
7    private readonly ICommunityRepository _communityRepository;
8    private readonly IUnitOfWork _unitOfWork;
9    private readonly IValidator<CreateSessionCommand> _validator;
10    private readonly IImageStorageService _imageStorageService;
11
12    // Cada dependencia tiene su propia responsabilidad
13    public CreateSessionCommandHandler(
14        ISessionRepository sessionRepository,
15        ICommunityRepository communityRepository,
16        IUnitOfWork unitOfWork,
17        IValidator<CreateSessionCommand> validator,
18        IImageStorageService imageStorageService)
19    {
20        _sessionRepository = sessionRepository;
21        _communityRepository = communityRepository;
22        _unitOfWork = unitOfWork;
23        _validator = validator;
24        _imageStorageService = imageStorageService;
25    }
26
27    public async Task<Result<Guid>> HandleAsync(
28        CreateSessionCommand command, CancellationToken ct = default)
29    {
30        // Paso 1: Validar el comando
31        var validationResult = await _validator.ValidateAsync(command, ct);
32        if (!validationResult.IsValid)
33            return Result.Failure<Guid>(/* error de validación */);
34
35        // Paso 2: Verificar que la comunidad existe
36        var community = await _communityRepository
37            .GetByIdAsync(command.CommunityId, ct);
38        if (community is null)
39            return Result.Failure<Guid>(CommunityError.NotFound);
40
41        // Paso 3: Crear la sesión (delega al dominio)
42        var result = Session.Create(
43            command.CommunityId, command.Title, 
44            command.Speaker, command.ScheduledAt);
45        if (result.IsFailure)
46            return Result.Failure<Guid>(result.Error);
47
48        // Paso 4: Persistir
49        _sessionRepository.Add(result.Value);
50        await _unitOfWork.SaveChangesAsync(ct);
51
52        return Result.Success(result.Value.Id);
53    }
54}

SRP en Cada Capa:

  • Session (Dominio): Solo lógica de negocio (validación, estado, eventos). No sabe nada de bases de datos ni HTTP.
  • CreateSessionCommandHandler (Aplicación): Solo orquestación (valida, verifica, delega al dominio, persiste). No contiene lógica de negocio.
  • SessionRepository (Infraestructura): Solo acceso a datos (EF Core queries). No tiene lógica de negocio ni de aplicación.
  • IImageStorageService (Abstracción): Solo almacenamiento de imágenes. No le importa quién la usa ni por qué.

SRP en TypeScript

Los mismos principios aplican en TypeScript. Aquí está el patrón de separación de responsabilidades en un contexto Node.js/Express:

1// ✗ VIOLACIÓN: Una clase que hace todo
2class SessionManager {
3  async createSession(data: CreateSessionDto) {
4    // Validación (responsabilidad 1)
5    if (!data.title) throw new Error("Title required");
6    if (data.scheduledAt <= new Date()) throw new Error("Must be future");
7
8    // Acceso a datos (responsabilidad 2)  
9    const community = await prisma.community.findUnique({
10      where: { id: data.communityId }
11    });
12    if (!community) throw new Error("Community not found");
13
14    // Lógica de negocio (responsabilidad 3)
15    const session = await prisma.session.create({
16      data: { ...data, status: "scheduled" }
17    });
18
19    // Notificación (responsabilidad 4)
20    await sendEmail(community.adminEmail, "New session created");
21    
22    return session;
23  }
24}
25
26// ✓ CORRECTO: Responsabilidades separadas
27interface SessionRepository {
28  create(session: Session): Promise<Session>;
29  findById(id: string): Promise<Session | null>;
30}
31
32interface CommunityRepository {
33  findById(id: string): Promise<Community | null>;
34}
35
36interface NotificationService {
37  notifySessionCreated(session: Session): Promise<void>;
38}
39
40class CreateSessionHandler {
41  constructor(
42    private sessions: SessionRepository,
43    private communities: CommunityRepository,
44    private notifications: NotificationService
45  ) {}
46
47  async handle(command: CreateSessionCommand): Promise<Result<Session>> {
48    const community = await this.communities.findById(command.communityId);
49    if (!community) return failure("Community not found");
50
51    const session = Session.create(
52      command.communityId,
53      command.title,
54      command.speaker,
55      command.scheduledAt
56    );
57
58    if (!session.isSuccess) return failure(session.error);
59
60    await this.sessions.create(session.value);
61    await this.notifications.notifySessionCreated(session.value);
62
63    return success(session.value);
64  }
65}

Beneficios de Aplicar SRP

1. Testing Simplificado

Cada clase se puede probar de forma aislada. Pruebas de Session verifican lógica de negocio sin base de datos. Pruebas del handler usan mocks para las dependencias.

1// Tests para CreateSessionCommandValidator - Sin mocks, sin base de datos
2[TestFixture]
3public class CreateSessionCommandValidatorTests
4{
5    private CreateSessionCommandValidator _validator = null!;
6
7    [SetUp]
8    public void Setup() => _validator = new CreateSessionCommandValidator();
9
10    [Test]
11    public void Validate_WithEmptyTitle_ReturnsError()
12    {
13        var command = new CreateSessionCommand 
14        { 
15            Title = "", 
16            ScheduledDate = DateTime.UtcNow.AddDays(7),
17            Speaker = "John Doe"
18        };
19
20        var result = _validator.Validate(command);
21
22        Assert.That(result.IsValid, Is.False);
23        Assert.That(result.Errors, Contains.Item("Title is required"));
24    }
25
26    [Test]
27    public void Validate_WithPastDate_ReturnsError()
28    {
29        var command = new CreateSessionCommand 
30        { 
31            Title = "SOLID Principles Workshop",
32            ScheduledDate = DateTime.UtcNow.AddDays(-1),
33            Speaker = "Jane Smith"
34        };
35
36        var result = _validator.Validate(command);
37
38        Assert.That(result.IsValid, Is.False);
39        Assert.That(result.Errors, Contains.Item("Session cannot be in the past"));
40    }
41
42    [Test]
43    public void Validate_WithValidData_ReturnsSuccess()
44    {
45        var command = new CreateSessionCommand 
46        { 
47            Title = "SOLID Principles Workshop",
48            ScheduledDate = DateTime.UtcNow.AddDays(7),
49            Speaker = "Jane Smith"
50        };
51
52        var result = _validator.Validate(command);
53
54        Assert.That(result.IsValid, Is.True);
55        Assert.That(result.Errors, Is.Empty);
56    }
57}
58
59// Tests para Session.Create() - Pruebas de lógica de dominio pura
60[TestFixture]
61public class SessionDomainTests
62{
63    [Test]
64    public void Create_WithValidData_ReturnsSuccess()
65    {
66        var result = Session.Create(
67            communityId: Guid.NewGuid(),
68            title: "Clean Code Practices",
69            speaker: "Robert Martin",
70            scheduledAt: DateTimeOffset.UtcNow.AddDays(7)
71        );
72
73        Assert.That(result.IsSuccess, Is.True);
74        Assert.That(result.Value.Title, Is.EqualTo("Clean Code Practices"));
75        Assert.That(result.Value.Status, Is.EqualTo(SessionStatus.Scheduled));
76    }
77
78    [Test]
79    public void Create_WithPastDate_ReturnsFailure()
80    {
81        var result = Session.Create(
82            communityId: Guid.NewGuid(),
83            title: "SOLID Workshop",
84            speaker: "SOLID Expert",
85            scheduledAt: DateTimeOffset.UtcNow.AddDays(-1)
86        );
87
88        Assert.That(result.IsFailure, Is.True);
89        Assert.That(result.Error, Is.EqualTo(SessionError.ScheduledInPast));
90    }
91}
92
93// Tests para CreateSessionCommandHandler - Usa mocks controlados
94[TestFixture]
95public class CreateSessionCommandHandlerTests
96{
97    private CreateSessionCommandHandler _handler = null!;
98    private Mock<ISessionRepository> _sessionRepositoryMock = null!;
99    private Mock<ICommunityRepository> _communityRepositoryMock = null!;
100    private Mock<IUnitOfWork> _unitOfWorkMock = null!;
101    private Mock<IValidator<CreateSessionCommand>> _validatorMock = null!;
102
103    [SetUp]
104    public void Setup()
105    {
106        _sessionRepositoryMock = new Mock<ISessionRepository>();
107        _communityRepositoryMock = new Mock<ICommunityRepository>();
108        _unitOfWorkMock = new Mock<IUnitOfWork>();
109        _validatorMock = new Mock<IValidator<CreateSessionCommand>>();
110
111        _handler = new CreateSessionCommandHandler(
112            _sessionRepositoryMock.Object,
113            _communityRepositoryMock.Object,
114            _unitOfWorkMock.Object,
115            _validatorMock.Object,
116            new NoOpImageStorageService() // Mock simple
117        );
118    }
119
120    [Test]
121    public async Task HandleAsync_WithValidCommand_CreatesSession()
122    {
123        var communityId = Guid.NewGuid();
124        var command = new CreateSessionCommand 
125        { 
126            CommunityId = communityId,
127            Title = "Design Patterns",
128            Speaker = "Gang of Four",
129            ScheduledDate = DateTime.UtcNow.AddDays(7)
130        };
131
132        var community = new Community { Id = communityId, Name = "Architects" };
133        
134        _validatorMock
135            .Setup(v => v.ValidateAsync(command, It.IsAny<CancellationToken>()))
136            .ReturnsAsync(new ValidationResult());
137        
138        _communityRepositoryMock
139            .Setup(r => r.GetByIdAsync(communityId, It.IsAny<CancellationToken>()))
140            .ReturnsAsync(community);
141
142        var result = await _handler.HandleAsync(command);
143
144        Assert.That(result.IsSuccess, Is.True);
145        _sessionRepositoryMock.Verify(r => r.Add(It.IsAny<Session>()), Times.Once);
146        _unitOfWorkMock.Verify(u => u.SaveChangesAsync(It.IsAny<CancellationToken>()), Times.Once);
147    }
148}

2. Cambios Localizados

Cambiar el proveedor de almacenamiento de imágenes solo afecta AzureBlobStorageService. El handler, la validación y el dominio no cambian.

3. Legibilidad

Cada archivo tiene un propósito claro. Puedes entender qué hace una clase con solo leer su nombre y su constructor.

4. Desarrollo en Paralelo

Un desarrollador puede trabajar en el repositorio mientras otro trabaja en la validación. No hay conflictos porque las responsabilidades están separadas.

5. Reutilización

IImageStorageService puede ser usado por cualquier handler que necesite almacenar imágenes. No está atada a una operación específica.

Errores Comunes

Error 1: Llevar SRP al Extremo

SRP no significa una clase por método. Significa una responsabilidad cohesiva por clase. Session tiene múltiples métodos (Create, UpdateStatus, Update) pero todos sirven a la misma responsabilidad: gestionar la lógica de negocio de una sesión.

Error 2: Confundir Capas con Responsabilidades

Tener un “ServiceLayer” no significa que todas las clases ahí dentro tengan una sola responsabilidad. Un CommunityService que maneja creación, actualización, eliminación y consultas tiene al menos cuatro razones para cambiar.

Error 3: Ignorar la Cohesión

Si separas demasiado, terminas con clases que no tienen sentido por sí solas. La clave es agrupar cosas que cambian juntas y separar las que cambian por razones diferentes.

Checklist: Detectando Violaciones de SRP

Si respondes “sí” a alguna de estas preguntas, SRP probablemente está siendo violado.

Conclusión

El Principio de Responsabilidad Única es la base de todos los principios SOLID. Al asegurar que cada clase tiene una sola razón para cambiar, creas un código que es más fácil de entender, probar y mantener.

Gathering demuestra esto a través de su arquitectura por capas: el Dominio maneja las reglas de negocio, la Aplicación orquesta operaciones, y la Infraestructura implementa los detalles técnicos. Cada clase tiene un propósito claro y bien definido.

La clave: pregúntate “¿cuáles son las razones por las que esta clase podría cambiar?” Si encuentras más de una, es momento de separar responsabilidades.

En el siguiente artículo, exploramos el Principio Abierto/Cerrado, que asegura que puedas extender el comportamiento de un sistema sin modificar el código existente.

About

Dev Patterns & Practices is a space for long-form thinking on design, technology, and craft. Every piece is written with care and the belief that the best ideas deserve room to breathe.