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.