Single Responsibility Principle
Explains the Single Responsibility Principle with examples that split responsibilities into focused, testable units for clearer, maintainable code.
Introduction
The Single Responsibility Principle (SRP) states: a class should have one, and only one, reason to change. In other words, each module or class should be responsible for only one part of the functionality provided by the software, and that responsibility should be entirely encapsulated by the class.
SRP is the first of the SOLID principles and often the most misunderstood. It does not mean a class should have only one method. It means that all methods and properties of the class should be aligned with a single purpose or concern. When a class does too much, it becomes a magnet for change, each new requirement touches the same class, increasing the risk of bugs.
In this article, we explore SRP through a real-world scenario: building a Community of Practice management system. You will see how Gathering's architecture cleanly separates responsibilities, and what happens when you mix them.
The Problem: Classes That Do Too Much
A Simple Example: Report Service
Before diving into Gathering's code, let's see a simple SRP violation: a report service that handles data generation, formatting, and email sending in a single class.
Without SRP: One Class with Multiple Responsibilities
1public class ReportService
2{
3 // Responsibility 1: Get data
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 // Responsibility 2: Format the report
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 // Responsibility 3: Send email
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 // Responsibility 4: Orchestrate everything
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}Problems with This Class:
- Data Access: Switching from SQL Server to PostgreSQL requires modifying ReportService.
- Format: Adding PDF format requires modifying ReportService.
- Sending: Switching from SMTP to SendGrid requires modifying ReportService.
- Testing: You cannot test formatting without a real database.
With SRP: Separated Responsibilities
1// Each class has ONE reason to change
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// Orchestration class only coordinates
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}Benefits of SRP Here:
- ✓ Testable: Each component can be tested in isolation with mocks.
- ✓ Interchangeable: Changing from HTML to PDF format only requires a new implementation of IReportFormatter.
- ✓ Focused: Each class has one reason to change.
This simple pattern (separating data access, business logic, and presentation) is the foundation of SRP. Now let's apply it to a larger, real-world system.
Case Study: Separation of Responsibilities in Gathering
Gathering, our Community of Practice management system, demonstrates SRP in every layer of the architecture. Each class has a clear purpose and a single reason to change.
The Domain Entity: Session
The Session class is exclusively responsible for the business logic of a session: its validation rules, state transitions, and domain events.
1// From: Gathering.Domain/Sessions/Session.cs
2// SINGLE Responsibility: session business logic
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}The Command Handler: One Operation
1// From: Gathering.Application/Sessions/Create/CreateSessionCommandHandler.cs
2// SINGLE Responsibility: orchestrating session creation
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 // Each dependency has its own responsibility
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 // Step 1: Validate the command
31 var validationResult = await _validator.ValidateAsync(command, ct);
32 if (!validationResult.IsValid)
33 return Result.Failure<Guid>(/* validation error */);
34
35 // Step 2: Verify that community exists
36 var community = await _communityRepository
37 .GetByIdAsync(command.CommunityId, ct);
38 if (community is null)
39 return Result.Failure<Guid>(CommunityError.NotFound);
40
41 // Step 3: Create the session (delegates to domain)
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 // Step 4: Persist
49 _sessionRepository.Add(result.Value);
50 await _unitOfWork.SaveChangesAsync(ct);
51
52 return Result.Success(result.Value.Id);
53 }
54}SRP in Each Layer:
- Session (Domain): Only business logic (validation, state, events). Knows nothing about databases or HTTP.
- CreateSessionCommandHandler (Application): Only orchestration (validates, verifies, delegates to domain, persists. Contains no business logic.
- SessionRepository (Infrastructure): Only data access (EF Core queries). Has no business or application logic.
- IImageStorageService (Abstraction): Only image storage. Does not care who uses it or why.
SRP in TypeScript
The same principles apply in TypeScript. Here is the pattern of separation of concerns in a Node.js/Express context:
1// ✗ VIOLATION: One class that does everything
2class SessionManager {
3 async createSession(data: CreateSessionDto) {
4 // Validation (responsibility 1)
5 if (!data.title) throw new Error("Title required");
6 if (data.scheduledAt <= new Date()) throw new Error("Must be future");
7
8 // Data access (responsibility 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 // Business logic (responsibility 3)
15 const session = await prisma.session.create({
16 data: { ...data, status: "scheduled" }
17 });
18
19 // Notification (responsibility 4)
20 await sendEmail(community.adminEmail, "New session created");
21
22 return session;
23 }
24}
25
26// ✓ CORRECT: Responsibilities separated
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}Benefits of Applying SRP
1. Testing Becomes Isolated and Fast
Without SRP, testing SessionHandler required setting up a database, email service, and logger. Now, to test SessionValidator, you pass a request object. No mocks, no side effects:
1// C# Test
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_WithValidData_ReturnsSuccess()
28 {
29 var command = new CreateSessionCommand
30 {
31 Title = "SOLID Principles Workshop",
32 ScheduledDate = DateTime.UtcNow.AddDays(7),
33 Speaker = "Jane Smith"
34 };
35
36 var result = _validator.Validate(command);
37
38 Assert.That(result.IsValid, Is.True);
39 Assert.That(result.Errors, Is.Empty);
40 }
41}
42
43// Tests for Session.Create() - Pure domain logic tests
44[TestFixture]
45public class SessionDomainTests
46{
47 [Test]
48 public void Create_WithValidData_ReturnsSuccess()
49 {
50 var result = Session.Create(
51 communityId: Guid.NewGuid(),
52 title: "Clean Code Practices",
53 speaker: "Robert Martin",
54 scheduledAt: DateTimeOffset.UtcNow.AddDays(7)
55 );
56
57 Assert.That(result.IsSuccess, Is.True);
58 Assert.That(result.Value.Title, Is.EqualTo("Clean Code Practices"));
59 Assert.That(result.Value.Status, Is.EqualTo(SessionStatus.Scheduled));
60 }
61
62 [Test]
63 public void Create_WithPastDate_ReturnsFailure()
64 {
65 var result = Session.Create(
66 communityId: Guid.NewGuid(),
67 title: "SOLID Workshop",
68 speaker: "SOLID Expert",
69 scheduledAt: DateTimeOffset.UtcNow.AddDays(-1)
70 );
71
72 Assert.That(result.IsFailure, Is.True);
73 Assert.That(result.Error, Is.EqualTo(SessionError.ScheduledInPast));
74 }
75}
76
77// Tests for CreateSessionCommandHandler - Uses controlled mocks
78[TestFixture]
79public class CreateSessionCommandHandlerTests
80{
81 private CreateSessionCommandHandler _handler = null!;
82 private Mock<ISessionRepository> _sessionRepositoryMock = null!;
83 private Mock<ICommunityRepository> _communityRepositoryMock = null!;
84 private Mock<IUnitOfWork> _unitOfWorkMock = null!;
85 private Mock<IValidator<CreateSessionCommand>> _validatorMock = null!;
86
87 [SetUp]
88 public void Setup()
89 {
90 _sessionRepositoryMock = new Mock<ISessionRepository>();
91 _communityRepositoryMock = new Mock<ICommunityRepository>();
92 _unitOfWorkMock = new Mock<IUnitOfWork>();
93 _validatorMock = new Mock<IValidator<CreateSessionCommand>>();
94
95 _handler = new CreateSessionCommandHandler(
96 _sessionRepositoryMock.Object,
97 _communityRepositoryMock.Object,
98 _unitOfWorkMock.Object,
99 _validatorMock.Object,
100 new NoOpImageStorageService()
101 );
102 }
103
104 [Test]
105 public async Task HandleAsync_WithValidCommand_CreatesSession()
106 {
107 var communityId = Guid.NewGuid();
108 var command = new CreateSessionCommand
109 {
110 CommunityId = communityId,
111 Title = "Design Patterns",
112 Speaker = "Gang of Four",
113 ScheduledDate = DateTime.UtcNow.AddDays(7)
114 };
115
116 var community = new Community { Id = communityId, Name = "Architects" };
117
118 _validatorMock
119 .Setup(v => v.ValidateAsync(command, It.IsAny<CancellationToken>()))
120 .ReturnsAsync(new ValidationResult());
121
122 _communityRepositoryMock
123 .Setup(r => r.GetByIdAsync(communityId, It.IsAny<CancellationToken>()))
124 .ReturnsAsync(community);
125
126 var result = await _handler.HandleAsync(command);
127
128 Assert.That(result.IsSuccess, Is.True);
129 _sessionRepositoryMock.Verify(r => r.Add(It.IsAny<Session>()), Times.Once);
130 _unitOfWorkMock.Verify(u => u.SaveChangesAsync(It.IsAny<CancellationToken>()), Times.Once);
131 }
132}2. Changes Are Localized
Changing the image storage provider only affects AzureBlobStorageService. The handler, validation, and domain do not change.
3. Readability
Each file has a clear purpose. You can understand what a class does by reading its name and constructor.
4. Parallel Development
One developer can work on the repository while another works on validation. There are no conflicts because responsibilities are separated.
5. Reusability
IImageStorageService can be used by any handler that needs to store images. It is not tied to one specific operation.
Common Mistakes
Mistake 1: Taking SRP to the Extreme
SRP does not mean one class per method. It means one cohesive responsibility per class. Session has multiple methods (Create, UpdateStatus, Update) but all serve the same responsibility: managing the business logic of a session.
Mistake 2: Confusing Layers with Responsibilities
Having a “ServiceLayer” does not mean all classes in it have one responsibility. A CommunityService that handles creation, update, deletion, and queries has at least four reasons to change.
Mistake 3: Ignoring Cohesion
If you separate too much, you end up with classes that don't make sense on their own. The key is to group things that change together and separate things that change for different reasons.
Checklist: Detecting SRP Violations
If you answer “yes” to any of these, SRP is likely being violated.
Conclusion
The Single Responsibility Principle is the foundation of maintainable code. When each class has one reason to change, you can understand it, test it, and modify it without affecting the entire system.
The key insight: ask yourself, “what are the reasons this class might need to change?” If the answer is more than one, you have found a violation of SRP.
In the next article, we will explore the Open/Closed Principle, which builds on SRP to ensure that adding new behavior does not require modifying existing code.